feat: certificates, landing pages, ai tutor, website update, compliance

This commit is contained in:
Rotimi Best
2026-05-18 03:47:22 +01:00
parent c31ad63955
commit ac3a2c14a7
644 changed files with 56050 additions and 5439 deletions
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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"
@@ -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"
@@ -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>
@@ -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}
+1 -1
View File
@@ -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
@@ -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>