feat: certificates, landing pages, ai tutor, website update, compliance
This commit is contained in:
@@ -66,11 +66,20 @@
|
||||
console.log('catch error', error);
|
||||
}
|
||||
}
|
||||
|
||||
const authBackgroundUrl = $derived($currentOrg.customization.auth?.backgroundImage?.trim() ?? '');
|
||||
</script>
|
||||
|
||||
<div class="auth-ui-background relative flex min-h-screen w-full items-center justify-center overflow-hidden p-4">
|
||||
<DotPattern fillColor="rgb(2 51 189 / 0.25)" class="absolute inset-0 h-full w-full" />
|
||||
<Card.Root class="ui:w-full relative z-10 max-w-[400px]">
|
||||
{#if authBackgroundUrl}
|
||||
<div class="absolute inset-0 z-0">
|
||||
<img src={authBackgroundUrl} alt="" class="h-full w-full object-cover" decoding="async" />
|
||||
<div class="absolute inset-0 bg-black/45" aria-hidden="true"></div>
|
||||
</div>
|
||||
{:else}
|
||||
<DotPattern fillColor="rgb(2 51 189 / 0.25)" class="absolute inset-0 z-0 h-full w-full" />
|
||||
{/if}
|
||||
<Card.Root class="ui:w-full relative z-10 max-w-[400px] shadow-sm">
|
||||
{#if !showOnlyContent || showLogo}
|
||||
<Card.Header class="ui:flex ui:flex-col ui:items-center ui:gap-4">
|
||||
<Avatar.Root>
|
||||
|
||||
+10
@@ -2,6 +2,7 @@
|
||||
import get from 'lodash/get';
|
||||
import { Label } from '@cio/ui/base/label';
|
||||
import { Switch } from '@cio/ui/base/switch';
|
||||
import AIGenerateButton from '$features/agent/components/ai-generate-button.svelte';
|
||||
|
||||
import type { Course } from '$features/course/utils/types';
|
||||
import { NAV_ITEM_KEY } from '../../constants';
|
||||
@@ -24,6 +25,15 @@
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<span class="ui:text-sm ui:font-medium">{$t('course.navItem.landing_page.editor.title.description')}</span>
|
||||
<AIGenerateButton
|
||||
courseId={course.id}
|
||||
format="html"
|
||||
context={`the detailed description section on the course landing page${course.title ? ` for a course called "${course.title}"` : ''}`}
|
||||
onInsert={(text) => setter(text, 'metadata.description')}
|
||||
/>
|
||||
</div>
|
||||
<TextEditor content={course.metadata.description} onChange={(content) => setter(content, `metadata.description`)} />
|
||||
</div>
|
||||
|
||||
|
||||
+21
-52
@@ -158,6 +158,7 @@
|
||||
slug: course.slug!,
|
||||
isPublished: course.isPublished ?? undefined,
|
||||
overview: course.overview ?? undefined,
|
||||
compliance: course.compliance ?? undefined,
|
||||
certificate: course.certificate
|
||||
? {
|
||||
...course.certificate,
|
||||
@@ -232,54 +233,26 @@
|
||||
|
||||
<Sidebar.Content class="flex-1 overflow-y-auto">
|
||||
{#if !selectedSection}
|
||||
{#if sidebar.open}
|
||||
<Sidebar.Group>
|
||||
<Sidebar.GroupLabel class="px-2">
|
||||
{$t('course.navItem.landing_page.editor.page_builder')}
|
||||
</Sidebar.GroupLabel>
|
||||
<Sidebar.GroupContent>
|
||||
<Sidebar.Menu>
|
||||
{#each sections as section (section.key)}
|
||||
{@const SectionIcon = section.icon}
|
||||
<HoverableItem>
|
||||
{#snippet children(isHovered)}
|
||||
<Sidebar.MenuItem>
|
||||
<Sidebar.MenuButton
|
||||
onclick={handleSectionSelect(section.key)}
|
||||
tooltipContent={section.title + ' ' + $t('course.navItem.landing_page.editor.section')}
|
||||
>
|
||||
<SectionIcon {isHovered} size={16} />
|
||||
<span>{section.title} {$t('course.navItem.landing_page.editor.section')}</span>
|
||||
<ChevronRightIcon size={16} />
|
||||
</Sidebar.MenuButton>
|
||||
</Sidebar.MenuItem>
|
||||
{/snippet}
|
||||
</HoverableItem>
|
||||
{/each}
|
||||
</Sidebar.Menu>
|
||||
</Sidebar.GroupContent>
|
||||
</Sidebar.Group>
|
||||
{:else}
|
||||
<Sidebar.Menu>
|
||||
{#each sections as section (section.key)}
|
||||
{@const SectionIcon = section.icon}
|
||||
<HoverableItem>
|
||||
{#snippet children(isHovered)}
|
||||
<Sidebar.MenuItem>
|
||||
<Sidebar.MenuButton
|
||||
onclick={handleSectionSelect(section.key)}
|
||||
tooltipContent={section.title + ' ' + $t('course.navItem.landing_page.editor.section')}
|
||||
>
|
||||
<SectionIcon {isHovered} size={16} />
|
||||
<span>{section.title} {$t('course.navItem.landing_page.editor.section')}</span>
|
||||
</Sidebar.MenuButton>
|
||||
</Sidebar.MenuItem>
|
||||
{/snippet}
|
||||
</HoverableItem>
|
||||
{/each}
|
||||
</Sidebar.Menu>
|
||||
{/if}
|
||||
{:else if sidebar.open || !sidebar.isMobile}
|
||||
<Sidebar.Group>
|
||||
<Sidebar.GroupLabel class="px-2">
|
||||
{$t('course.navItem.landing_page.editor.page_builder')}
|
||||
</Sidebar.GroupLabel>
|
||||
<Sidebar.GroupContent>
|
||||
<Sidebar.Menu>
|
||||
{#each sections as section (section.key)}
|
||||
{@const SectionIcon = section.icon}
|
||||
<Sidebar.MenuItem>
|
||||
<Sidebar.MenuButton onclick={handleSectionSelect(section.key)} tooltipContent={section.title}>
|
||||
<SectionIcon size={16} />
|
||||
<span>{section.title}</span>
|
||||
<ChevronRightIcon size={16} />
|
||||
</Sidebar.MenuButton>
|
||||
</Sidebar.MenuItem>
|
||||
{/each}
|
||||
</Sidebar.Menu>
|
||||
</Sidebar.GroupContent>
|
||||
</Sidebar.Group>
|
||||
{:else}
|
||||
<div class="p-4">
|
||||
{#if selectedSection.key === 1}
|
||||
<HeaderForm bind:course />
|
||||
@@ -299,9 +272,5 @@
|
||||
<PricingForm bind:course {setter} />
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-center justify-center p-4">
|
||||
<p class="text-muted-foreground text-sm">Expand sidebar to edit</p>
|
||||
</div>
|
||||
{/if}
|
||||
</Sidebar.Content>
|
||||
|
||||
+11
-1
@@ -2,6 +2,7 @@
|
||||
import get from 'lodash/get';
|
||||
import { Label } from '@cio/ui/base/label';
|
||||
import { Switch } from '@cio/ui/base/switch';
|
||||
import AIGenerateButton from '$features/agent/components/ai-generate-button.svelte';
|
||||
|
||||
import type { Course } from '$features/course/utils/types';
|
||||
import { NAV_ITEM_KEY } from '../../constants';
|
||||
@@ -23,7 +24,16 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="">
|
||||
<div>
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<span class="ui:text-sm ui:font-medium">{$t('course.navItem.landing_page.editor.title.goals')}</span>
|
||||
<AIGenerateButton
|
||||
courseId={course.id}
|
||||
format="html"
|
||||
context={`the "what you'll learn" goals section on the landing page${course.title ? ` for a course called "${course.title}"` : ''}`}
|
||||
onInsert={(text) => setter(text, 'metadata.goals')}
|
||||
/>
|
||||
</div>
|
||||
<TextEditor content={course.metadata.goals} onChange={(content) => setter(content, `metadata.goals`)} />
|
||||
</div>
|
||||
|
||||
|
||||
+19
-2
@@ -2,6 +2,7 @@
|
||||
import { TextareaField } from '@cio/ui/custom/textarea-field';
|
||||
import { InputField } from '@cio/ui/custom/input-field';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import AIGenerateButton from '$features/agent/components/ai-generate-button.svelte';
|
||||
import { handleOpenWidget } from '../../store';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
@@ -17,7 +18,15 @@
|
||||
labelClassName="font-bold"
|
||||
label={$t('course.navItem.landing_page.editor.header_form.title')}
|
||||
bind:value={course.title}
|
||||
/>
|
||||
>
|
||||
{#snippet labelAction()}
|
||||
<AIGenerateButton
|
||||
courseId={course.id}
|
||||
context="the title field on the landing page for an online course"
|
||||
onInsert={(text) => (course.title = text)}
|
||||
/>
|
||||
{/snippet}
|
||||
</InputField>
|
||||
|
||||
<TextareaField
|
||||
label={$t('course.navItem.landing_page.editor.header_form.description')}
|
||||
@@ -25,7 +34,15 @@
|
||||
rows={6}
|
||||
className="mt-5"
|
||||
labelClassName="font-bold"
|
||||
/>
|
||||
>
|
||||
{#snippet labelAction()}
|
||||
<AIGenerateButton
|
||||
courseId={course.id}
|
||||
context={`the short description on the landing page of a course${course.title ? ` called "${course.title}"` : ''}`}
|
||||
onInsert={(text) => (course.description = text)}
|
||||
/>
|
||||
{/snippet}
|
||||
</TextareaField>
|
||||
|
||||
<InputField
|
||||
className="mt-5"
|
||||
|
||||
+10
-1
@@ -2,6 +2,7 @@
|
||||
import get from 'lodash/get';
|
||||
import { TextareaField } from '@cio/ui/custom/textarea-field';
|
||||
import { InputField } from '@cio/ui/custom/input-field';
|
||||
import AIGenerateButton from '$features/agent/components/ai-generate-button.svelte';
|
||||
import UploadImage from '$features/ui/upload-image.svelte';
|
||||
import { uploadImage } from '$lib/utils/services/upload';
|
||||
import type { Course } from '$features/course/utils/types';
|
||||
@@ -87,7 +88,15 @@
|
||||
className="mt-5"
|
||||
labelClassName="font-bold"
|
||||
bind:value={description}
|
||||
/>
|
||||
>
|
||||
{#snippet labelAction()}
|
||||
<AIGenerateButton
|
||||
courseId={course.id}
|
||||
context={`the instructor bio on the landing page${course.title ? ` for a course called "${course.title}"` : ''}${name ? `. The instructor's name is ${name}` : ''}`}
|
||||
onInsert={(text) => (description = text)}
|
||||
/>
|
||||
{/snippet}
|
||||
</TextareaField>
|
||||
|
||||
<InputField
|
||||
className="mt-5"
|
||||
|
||||
+10
@@ -2,6 +2,7 @@
|
||||
import get from 'lodash/get';
|
||||
import { Label } from '@cio/ui/base/label';
|
||||
import { Switch } from '@cio/ui/base/switch';
|
||||
import AIGenerateButton from '$features/agent/components/ai-generate-button.svelte';
|
||||
|
||||
import type { Course } from '$features/course/utils/types';
|
||||
import { NAV_ITEM_KEY } from '../../constants';
|
||||
@@ -24,6 +25,15 @@
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<span class="ui:text-sm ui:font-medium">{$t('course.navItem.landing_page.editor.title.requirement')}</span>
|
||||
<AIGenerateButton
|
||||
courseId={course.id}
|
||||
format="html"
|
||||
context={`the requirements/prerequisites section on the landing page${course.title ? ` for a course called "${course.title}"` : ''}`}
|
||||
onInsert={(text) => setter(text, 'metadata.requirements')}
|
||||
/>
|
||||
</div>
|
||||
<TextEditor content={course.metadata.requirements} onChange={(content) => setter(content, `metadata.requirements`)} />
|
||||
</div>
|
||||
|
||||
|
||||
+4
-7
@@ -11,7 +11,6 @@
|
||||
import type { Course } from '$features/course/utils/types';
|
||||
import { capturePosthogEvent } from '$lib/utils/services/posthog';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { BorderBeam } from '@cio/ui/custom/animation/border-beam';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
@@ -123,12 +122,10 @@
|
||||
</div>
|
||||
{:else}
|
||||
<aside
|
||||
class="relative w-[90%] max-w-[405px] overflow-hidden rounded-xl border border-gray-200 shadow-lg lg:sticky lg:w-[350px] lg:min-w-[250px] xl:w-[405px] xl:min-w-[330px] {editMode
|
||||
class="ui:relative ui:w-[90%] ui:max-w-[405px] ui:rounded-lg ui:border ui:border-border ui:bg-card lg:sticky lg:w-[350px] lg:min-w-[250px] xl:w-[405px] xl:min-w-[330px] {editMode
|
||||
? 'lg:top-0'
|
||||
: 'lg:top-10'} h-fit dark:border-neutral-700 dark:bg-neutral-800 {className}"
|
||||
: 'lg:top-10'} h-fit {className}"
|
||||
>
|
||||
<BorderBeam size={150} duration={12} colorFrom="#3b82f6" colorTo="#8b5cf6" borderWidth={1.5} />
|
||||
|
||||
<div class="p-4 lg:p-10">
|
||||
<!-- Pricing -->
|
||||
<div class="mb-6">
|
||||
@@ -177,8 +174,8 @@
|
||||
|
||||
<!-- Gift Container -->
|
||||
{#if courseData?.metadata?.reward?.show}
|
||||
<div class="flex flex-col items-center border-t border-gray-200 p-10 dark:border-neutral-700">
|
||||
<HTMLRender>
|
||||
<div class="ui:border-t ui:border-border flex flex-col items-center p-10">
|
||||
<HTMLRender className="text-sm font-light leading-relaxed">
|
||||
<SafeHtmlContent content={get(courseData, 'metadata.reward.description', '') as string} />
|
||||
</HTMLRender>
|
||||
</div>
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
import * as UnderlineTabs from '@cio/ui/custom/underline-tabs';
|
||||
import * as Dialog from '@cio/ui/base/dialog';
|
||||
import Image from '$features/ui/image.svelte';
|
||||
import PoweredBy from '$features/ui/upgrade-powered-by.svelte';
|
||||
import PoweredBy from '$features/ui/powered-by.svelte';
|
||||
import UploadWidget from '$features/ui/upload-widget/upload-widget.svelte';
|
||||
import HTMLRender from '$features/ui/html-render.svelte';
|
||||
import * as Avatar from '@cio/ui/base/avatar';
|
||||
@@ -298,9 +298,9 @@
|
||||
<h3 class="mt-0 mb-3 text-2xl font-semibold">
|
||||
{$t('course.navItem.landing_page.requirement')}
|
||||
</h3>
|
||||
<ul class="list font-light">
|
||||
<HTMLRender><SafeHtmlContent content={get(courseData, 'metadata.requirements', '')} /></HTMLRender>
|
||||
</ul>
|
||||
<HTMLRender className="text-sm font-light leading-relaxed">
|
||||
<SafeHtmlContent content={get(courseData, 'metadata.requirements', '')} />
|
||||
</HTMLRender>
|
||||
</BlurFade>
|
||||
</NavSection>
|
||||
{/if}
|
||||
@@ -324,9 +324,9 @@
|
||||
<NavSection id="goals">
|
||||
<BlurFade delay={0.1}>
|
||||
<h3 class="mt-0 mb-3 text-2xl font-semibold">{$t('course.navItem.landing_page.learn')}</h3>
|
||||
<ul class="list font-light">
|
||||
<HTMLRender><SafeHtmlContent content={get(courseData, 'metadata.goals', '')} /></HTMLRender>
|
||||
</ul>
|
||||
<HTMLRender className="text-sm font-light leading-relaxed">
|
||||
<SafeHtmlContent content={get(courseData, 'metadata.goals', '')} />
|
||||
</HTMLRender>
|
||||
</BlurFade>
|
||||
</NavSection>
|
||||
{/if}
|
||||
|
||||
@@ -16,7 +16,7 @@ export { default as LoadingPage } from './analytics/loading-page.svelte';
|
||||
export * from './question';
|
||||
export { default as UploadWidget } from './upload-widget/upload-widget.svelte';
|
||||
export { default as PageLoadProgress } from './page-load-progress.svelte';
|
||||
export { default as PoweredBy } from './upgrade-powered-by.svelte';
|
||||
export { default as PoweredBy } from './powered-by.svelte';
|
||||
export { default as RefreshPageData } from './refresh-page-data.svelte';
|
||||
export { default as RoleBasedSecurity } from './role-based-security.svelte';
|
||||
export { default as Search } from './search.svelte';
|
||||
|
||||
@@ -43,10 +43,9 @@ export function generateBreadcrumbs(
|
||||
}
|
||||
|
||||
if (!matchedNavItem) {
|
||||
// Try to match by checking if pathname matches the item path
|
||||
for (const item of navItems) {
|
||||
const fullItemPath = `${currentOrgPath}${item.path}`;
|
||||
if (isActive(pathname, fullItemPath)) {
|
||||
if (isActive(pathname, fullItemPath, item.matchPattern)) {
|
||||
matchedNavItem = item;
|
||||
break;
|
||||
}
|
||||
@@ -54,8 +53,9 @@ export function generateBreadcrumbs(
|
||||
}
|
||||
|
||||
if (matchedNavItem) {
|
||||
// Handle hash URLs (like settings) - use the actual path instead of '#'
|
||||
const href = matchedNavItem.useHashUrl ? `${currentOrgPath}${matchedNavItem.path}` : matchedNavItem.url;
|
||||
const parentCrumbPath =
|
||||
matchedNavItem.useHashUrl && matchedNavItem.items?.length ? matchedNavItem.items[0].path : matchedNavItem.path;
|
||||
const href = matchedNavItem.useHashUrl ? `${currentOrgPath}${parentCrumbPath}` : matchedNavItem.url;
|
||||
|
||||
breadcrumbs.push({
|
||||
label: t(matchedNavItem.title),
|
||||
@@ -75,6 +75,26 @@ export function generateBreadcrumbs(
|
||||
}
|
||||
}
|
||||
|
||||
// Collapsible nav children matched by full path (e.g. Stats → Analytics, Settings → Billing)
|
||||
if (matchedNavItem?.items && !searchParams) {
|
||||
const parentBase = `${currentOrgPath}${matchedNavItem.path}`.replace(/\/$/, '');
|
||||
const normalizedPathname = pathname.replace(/\/$/, '');
|
||||
|
||||
if (normalizedPathname !== parentBase) {
|
||||
const activeSub = matchedNavItem.items.find((sub) => {
|
||||
const subBase = sub.url.replace(/\/$/, '');
|
||||
return normalizedPathname === subBase || normalizedPathname.startsWith(`${subBase}/`);
|
||||
});
|
||||
if (activeSub) {
|
||||
breadcrumbs.push({
|
||||
label: t(activeSub.title),
|
||||
href: activeSub.url
|
||||
});
|
||||
return breadcrumbs;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle nested routes
|
||||
if (pathSegments.length > 1 && matchedNavItem) {
|
||||
const secondSegment = pathSegments[1];
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
ApiIcon,
|
||||
ChartColumnIcon,
|
||||
AttachmentIcon,
|
||||
CommunityIcon,
|
||||
CourseIcon,
|
||||
@@ -20,12 +21,14 @@ import type { Component } from 'svelte';
|
||||
import { isActive } from '$lib/utils/functions/app';
|
||||
|
||||
export interface NavItem {
|
||||
title: string; // translation key
|
||||
title: string;
|
||||
url: string;
|
||||
path: string; // Actual path (e.g., '/settings') for breadcrumb generation
|
||||
icon?: Component;
|
||||
isActive?: boolean;
|
||||
isExpanded?: boolean;
|
||||
/** When set, `isActive` for this item is determined by this regex on the pathname */
|
||||
matchPattern?: string;
|
||||
items?: NavItem[]; // for nested items like settings
|
||||
isPaid?: boolean; // Show upgrade indicator for free plan users
|
||||
disabled?: boolean;
|
||||
@@ -76,6 +79,27 @@ export const baseNavConfig: NavItemConfig[] = [
|
||||
icon: DashboardIcon,
|
||||
matchPattern: '^/org/[^/]+/dash(/.*)?$'
|
||||
},
|
||||
{
|
||||
group: 'home',
|
||||
titleKey: 'org_navigation.stats',
|
||||
path: '/stats',
|
||||
icon: ChartColumnIcon,
|
||||
useHashUrl: true,
|
||||
matchPattern: '^/org/[^/]+/(analytics|compliance)(/.*)?$',
|
||||
items: [
|
||||
{
|
||||
titleKey: 'org_navigation.analytics',
|
||||
path: '/analytics',
|
||||
matchPattern: '^/org/[^/]+/analytics(/.*)?$'
|
||||
},
|
||||
{
|
||||
titleKey: 'org_navigation.compliance',
|
||||
path: '/compliance',
|
||||
requiresAdmin: true,
|
||||
matchPattern: '^/org/[^/]+/compliance(/.*)?$'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
group: 'home',
|
||||
titleKey: 'org_navigation.setup',
|
||||
@@ -195,6 +219,10 @@ export const baseNavConfig: NavItemConfig[] = [
|
||||
titleKey: 'settings.tabs.ai_credits_tab',
|
||||
path: '/settings/ai-credits'
|
||||
},
|
||||
{
|
||||
titleKey: 'settings.tabs.ai_tutor_tab',
|
||||
path: '/settings/ai-tutor'
|
||||
},
|
||||
{
|
||||
titleKey: 'settings.tabs.auth_tab',
|
||||
matchPattern: '^/org/[^/]+/settings/auth(/.*)?$',
|
||||
@@ -211,6 +239,10 @@ export const baseNavConfig: NavItemConfig[] = [
|
||||
path: 'ai-credits',
|
||||
titleKey: 'settings.tabs.ai_credits_tab'
|
||||
},
|
||||
{
|
||||
path: 'ai-tutor',
|
||||
titleKey: 'settings.tabs.ai_tutor_tab'
|
||||
},
|
||||
{
|
||||
path: 'customize-lms',
|
||||
titleKey: 'settings.tabs.customize_lms_tab'
|
||||
@@ -257,6 +289,12 @@ export function getOrgNavigationItems(
|
||||
continue;
|
||||
}
|
||||
|
||||
const visibleSubConfigs = config.items?.filter((sub) => !sub.requiresAdmin || isOrgAdmin) ?? [];
|
||||
|
||||
if (config.items && visibleSubConfigs.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const url = config.path === '' ? currentOrgPath : `${currentOrgPath}${config.path}`;
|
||||
const fullPath = config.path === '' ? `/org/${currentOrg.siteName}` : `/org/${currentOrg.siteName}${config.path}`;
|
||||
|
||||
@@ -269,6 +307,7 @@ export function getOrgNavigationItems(
|
||||
url: config.useHashUrl ? '#' : url,
|
||||
path: config.path, // Store actual path for breadcrumb generation
|
||||
icon: config.icon,
|
||||
matchPattern,
|
||||
isActive: isActive(pagePathname, fullPath, matchPattern),
|
||||
isExpanded: config.items ? isActive(pagePathname, fullPath, matchPattern) : undefined,
|
||||
disabled: Boolean(config.disableWhenNotAdmin && !isOrgAdmin),
|
||||
@@ -279,18 +318,18 @@ export function getOrgNavigationItems(
|
||||
};
|
||||
|
||||
// Handle nested items (like settings sub-items)
|
||||
if (config.items) {
|
||||
item.items = config.items.map((subConfig) => {
|
||||
const matchPattern =
|
||||
if (visibleSubConfigs.length > 0) {
|
||||
item.items = visibleSubConfigs.map((subConfig) => {
|
||||
const subMatchPattern =
|
||||
typeof subConfig.matchPattern === 'function'
|
||||
? subConfig.matchPattern(currentOrg.siteName!)
|
||||
: subConfig.matchPattern;
|
||||
const url = `${currentOrgPath}${subConfig.path}`;
|
||||
const subUrl = `${currentOrgPath}${subConfig.path}`;
|
||||
|
||||
return {
|
||||
title: t(subConfig.titleKey),
|
||||
isActive: isActive(pagePathname, url, matchPattern, true),
|
||||
url: url,
|
||||
isActive: isActive(pagePathname, subUrl, subMatchPattern, true),
|
||||
url: subUrl,
|
||||
path: subConfig.path,
|
||||
isPaid: subConfig.isPaid
|
||||
};
|
||||
@@ -321,6 +360,7 @@ export function getOrgNavigationGroups(
|
||||
t: (key: string) => string,
|
||||
pagePathname: string
|
||||
): NavGroup[] {
|
||||
const pathnameOnly = pagePathname.split('?')[0];
|
||||
const groupedMap = new Map<string | null, NavItem[]>();
|
||||
|
||||
for (const groupDef of GROUP_ORDER) {
|
||||
@@ -332,6 +372,12 @@ export function getOrgNavigationGroups(
|
||||
continue;
|
||||
}
|
||||
|
||||
const visibleSubConfigs = config.items?.filter((sub) => !sub.requiresAdmin || isOrgAdmin) ?? [];
|
||||
|
||||
if (config.items && visibleSubConfigs.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const url = config.path === '' ? currentOrgPath : `${currentOrgPath}${config.path}`;
|
||||
const fullPath = config.path === '' ? `/org/${currentOrg.siteName}` : `/org/${currentOrg.siteName}${config.path}`;
|
||||
const matchPattern =
|
||||
@@ -342,8 +388,9 @@ export function getOrgNavigationGroups(
|
||||
url: config.useHashUrl ? '#' : url,
|
||||
path: config.path,
|
||||
icon: config.icon,
|
||||
isActive: isActive(pagePathname, fullPath, matchPattern),
|
||||
isExpanded: config.items ? isActive(pagePathname, fullPath, matchPattern) : undefined,
|
||||
matchPattern,
|
||||
isActive: isActive(pathnameOnly, fullPath, matchPattern),
|
||||
isExpanded: config.items ? isActive(pathnameOnly, fullPath, matchPattern) : undefined,
|
||||
disabled: Boolean(config.disableWhenNotAdmin && !isOrgAdmin),
|
||||
useHashUrl: config.useHashUrl,
|
||||
nestedRoutes: config.nestedRoutes,
|
||||
@@ -351,8 +398,8 @@ export function getOrgNavigationGroups(
|
||||
isPaid: config.isPaid
|
||||
};
|
||||
|
||||
if (config.items) {
|
||||
item.items = config.items.map((subConfig) => {
|
||||
if (visibleSubConfigs.length > 0) {
|
||||
item.items = visibleSubConfigs.map((subConfig) => {
|
||||
const subMatchPattern =
|
||||
typeof subConfig.matchPattern === 'function'
|
||||
? subConfig.matchPattern(currentOrg.siteName!)
|
||||
@@ -360,7 +407,7 @@ export function getOrgNavigationGroups(
|
||||
const subUrl = `${currentOrgPath}${subConfig.path}`;
|
||||
return {
|
||||
title: t(subConfig.titleKey),
|
||||
isActive: isActive(pagePathname, subUrl, subMatchPattern, true),
|
||||
isActive: isActive(pathnameOnly, subUrl, subMatchPattern, true),
|
||||
url: subUrl,
|
||||
path: subConfig.path,
|
||||
isPaid: subConfig.isPaid
|
||||
|
||||
+31
-2
@@ -1,19 +1,48 @@
|
||||
<script lang="ts">
|
||||
import ArrowUpRightIcon from '@lucide/svelte/icons/arrow-up-right';
|
||||
|
||||
import { PublicCoursePoweredBy } from '@cio/ui/custom/public-course';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { currentOrg, isFreePlan } from '$lib/utils/store/org';
|
||||
import { cn } from '@cio/ui/tools';
|
||||
|
||||
type Props = {
|
||||
/** Floating promo chip (landing / marketing) vs subdued sidebar attribution. */
|
||||
variant?: 'floating' | 'sidebar';
|
||||
class?: string;
|
||||
/** Sidebar: logo only when rail is collapsed. */
|
||||
showOnlyLogo?: boolean;
|
||||
/** Passed as utm_content for sidebar attribution. */
|
||||
courseSlug?: string | null;
|
||||
orgSlug?: string | null;
|
||||
/** Sidebar: utm_source override (default distinguishes LMS attribution). */
|
||||
sidebarUtmSource?: string;
|
||||
};
|
||||
|
||||
let { class: className, showOnlyLogo = false }: Props = $props();
|
||||
let {
|
||||
variant = 'floating',
|
||||
class: className,
|
||||
showOnlyLogo = false,
|
||||
courseSlug = null,
|
||||
orgSlug = null,
|
||||
sidebarUtmSource = 'lms-sidebar'
|
||||
}: Props = $props();
|
||||
|
||||
const attributionOrgSlug = $derived(orgSlug ?? $currentOrg.siteName ?? null);
|
||||
</script>
|
||||
|
||||
{#if $isFreePlan}
|
||||
{#if variant === 'sidebar'}
|
||||
<PublicCoursePoweredBy
|
||||
{courseSlug}
|
||||
orgSlug={attributionOrgSlug}
|
||||
utmSource={sidebarUtmSource}
|
||||
label={$t('public_course.powered_by.label')}
|
||||
brand={$t('public_course.powered_by.brand')}
|
||||
compact={showOnlyLogo}
|
||||
align={showOnlyLogo ? 'center' : 'start'}
|
||||
class={className}
|
||||
/>
|
||||
{:else if $isFreePlan}
|
||||
<a
|
||||
href={`https://classroomio.com?utm_source=${$currentOrg.siteName}.classroomio.com`}
|
||||
target="_blank"
|
||||
@@ -12,7 +12,8 @@
|
||||
import type { AccountOrg } from '$features/app/types';
|
||||
|
||||
import { setTheme } from '$lib/utils/functions/theme';
|
||||
import { currentOrg, currentOrgPath, orgs } from '$lib/utils/store/org';
|
||||
import { currentOrg, currentOrgPath, mergeAccountOrgFromServer, orgs } from '$lib/utils/store/org';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
import ComingSoon from '$features/ui/coming-soon.svelte';
|
||||
import { PUBLIC_IS_SELFHOSTED } from '$env/static/public';
|
||||
@@ -26,11 +27,20 @@
|
||||
|
||||
let { variant = 'sidebar' }: Props = $props();
|
||||
|
||||
function rootIdFor(org: Pick<AccountOrg, 'id' | 'parentOrganizationId'>): string {
|
||||
return org.parentOrganizationId ?? org.id;
|
||||
}
|
||||
|
||||
const accountRootId = $derived(rootIdFor($currentOrg));
|
||||
|
||||
const accountWorkspaces = $derived($orgs.filter((org) => rootIdFor(org) === accountRootId));
|
||||
const otherOrgs = $derived($orgs.filter((org) => rootIdFor(org) !== accountRootId));
|
||||
|
||||
function onClick(org: AccountOrg) {
|
||||
if (org.id === $currentOrg.id) return;
|
||||
|
||||
localStorage.setItem('classroomio_org_sitename', org.siteName!);
|
||||
currentOrg.set(org);
|
||||
currentOrg.set(mergeAccountOrgFromServer(org));
|
||||
|
||||
setTheme(org.theme!);
|
||||
window.location.href = $currentOrgPath;
|
||||
@@ -96,9 +106,11 @@
|
||||
side={sidebar.isMobile ? 'bottom' : 'right'}
|
||||
sideOffset={4}
|
||||
>
|
||||
<DropdownMenu.Label class="ui:text-muted-foreground ui:text-xs">Organizations</DropdownMenu.Label>
|
||||
<DropdownMenu.Label class="ui:text-muted-foreground ui:text-xs"
|
||||
>{$t('account.switcher.your_account')}</DropdownMenu.Label
|
||||
>
|
||||
|
||||
{#each $orgs as org (org.name)}
|
||||
{#each accountWorkspaces as org (org.id)}
|
||||
<DropdownMenu.Item onSelect={() => onClick(org)} class="gap-2 p-2">
|
||||
<Avatar.Root class="flex size-8 items-center justify-center rounded-lg">
|
||||
<Avatar.Image src={org.avatarUrl} alt={org.name} />
|
||||
@@ -109,6 +121,23 @@
|
||||
</DropdownMenu.Item>
|
||||
{/each}
|
||||
|
||||
{#if otherOrgs.length > 0}
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Label class="ui:text-muted-foreground ui:text-xs"
|
||||
>{$t('account.switcher.other_organizations')}</DropdownMenu.Label
|
||||
>
|
||||
{#each otherOrgs as org (org.id)}
|
||||
<DropdownMenu.Item onSelect={() => onClick(org)} class="gap-2 p-2">
|
||||
<Avatar.Root class="flex size-8 items-center justify-center rounded-lg">
|
||||
<Avatar.Image src={org.avatarUrl} alt={org.name} />
|
||||
<Avatar.Fallback class="rounded-lg">{shortenName(org.name)}</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
|
||||
{org.name}
|
||||
</DropdownMenu.Item>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
<DropdownMenu.Separator />
|
||||
|
||||
<DropdownMenu.Item class="cursor-not-allowed gap-2 p-2 opacity-50">
|
||||
@@ -162,9 +191,11 @@
|
||||
side={sidebar.isMobile ? 'bottom' : 'right'}
|
||||
sideOffset={4}
|
||||
>
|
||||
<DropdownMenu.Label class="ui:text-muted-foreground ui:text-xs">Organizations</DropdownMenu.Label>
|
||||
<DropdownMenu.Label class="ui:text-muted-foreground ui:text-xs"
|
||||
>{$t('account.switcher.your_account')}</DropdownMenu.Label
|
||||
>
|
||||
|
||||
{#each $orgs as org (org.name)}
|
||||
{#each accountWorkspaces as org (org.id)}
|
||||
<DropdownMenu.Item onSelect={() => onClick(org)} class="gap-2 p-2">
|
||||
<Avatar.Root class="flex size-8 items-center justify-center rounded-lg">
|
||||
<Avatar.Image src={org.avatarUrl} alt={org.name} />
|
||||
@@ -175,6 +206,23 @@
|
||||
</DropdownMenu.Item>
|
||||
{/each}
|
||||
|
||||
{#if otherOrgs.length > 0}
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Label class="ui:text-muted-foreground ui:text-xs"
|
||||
>{$t('account.switcher.other_organizations')}</DropdownMenu.Label
|
||||
>
|
||||
{#each otherOrgs as org (org.id)}
|
||||
<DropdownMenu.Item onSelect={() => onClick(org)} class="gap-2 p-2">
|
||||
<Avatar.Root class="flex size-8 items-center justify-center rounded-lg">
|
||||
<Avatar.Image src={org.avatarUrl} alt={org.name} />
|
||||
<Avatar.Fallback class="rounded-lg">{shortenName(org.name)}</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
|
||||
{org.name}
|
||||
</DropdownMenu.Item>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
<DropdownMenu.Separator />
|
||||
|
||||
<DropdownMenu.Item class="cursor-not-allowed gap-2 p-2 opacity-50">
|
||||
|
||||
@@ -23,11 +23,12 @@
|
||||
let { imageURL = $bindable(''), onchange }: Props = $props();
|
||||
|
||||
const tabs = [
|
||||
{ label: 'Unsplash', value: 'unsplash' },
|
||||
{ label: 'Upload', value: 'upload' }
|
||||
{ label: 'Upload', value: 'upload' },
|
||||
{ label: 'Unsplash', value: 'unsplash' }
|
||||
];
|
||||
|
||||
let isUploading = $state(false);
|
||||
let isSearching = $state(false);
|
||||
let currentTab = $state(tabs[0].value);
|
||||
let searchQuery = $state('');
|
||||
let unsplashImages: {
|
||||
@@ -80,11 +81,15 @@
|
||||
};
|
||||
|
||||
async function handleSubmit() {
|
||||
isSearching = true;
|
||||
|
||||
try {
|
||||
unsplashImages = await queryUnsplash(searchQuery || 'rocks');
|
||||
} catch (error) {
|
||||
snackbar.error('snackbar.landing_page_settings.error.fetch_error');
|
||||
console.error('Error fetching images from Unsplash:', error);
|
||||
} finally {
|
||||
isSearching = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,7 +106,7 @@
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{$t('course.navItem.landing_page.upload_widget.title')}</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
<div class="w-full bg-white p-5 dark:bg-inherit">
|
||||
<div class="w-full bg-white p-2 dark:bg-inherit">
|
||||
<UnderlineTabs.Root bind:value={currentTab}>
|
||||
<UnderlineTabs.List>
|
||||
{#each tabs as tab}
|
||||
@@ -110,7 +115,7 @@
|
||||
</UnderlineTabs.Trigger>
|
||||
{/each}
|
||||
</UnderlineTabs.List>
|
||||
<UnderlineTabs.Content value={tabs[1].value}>
|
||||
<UnderlineTabs.Content value="upload">
|
||||
<div class="w-full {isUploading ? 'ui:opacity-50 ui:pointer-events-none' : ''}">
|
||||
<FileDropZone.Root
|
||||
accept={FileDropZone.ACCEPT_IMAGE}
|
||||
@@ -127,12 +132,12 @@
|
||||
</FileDropZone.Root>
|
||||
</div>
|
||||
</UnderlineTabs.Content>
|
||||
<UnderlineTabs.Content value={tabs[0].value}>
|
||||
<UnderlineTabs.Content value="unsplash">
|
||||
<!-- Your Images content here -->
|
||||
<div class="h-full overflow-y-scroll">
|
||||
<form onsubmit={preventDefault(handleSubmit)} class="mt-1 flex gap-2 pb-3">
|
||||
<InputField className="ml-2 w-[85%]" bind:value={searchQuery} />
|
||||
<Button type="submit" variant="outline">
|
||||
<Button type="submit" variant="outline" loading={isSearching}>
|
||||
{$t('course.navItem.landing_page.upload_widget.submit')}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
Reference in New Issue
Block a user