feat: HLS streaming, transcripts, agent course generation & landing-page course previews (#663)
New features: - HLS video encoding/streaming (hls routes, hls-encoder) and a media transcripts pipeline (transcripts route + media-transcript queries) - AI assistant chat vs. run model: agent-course-generation worker/queue, run-monitor pane, chat-changes card, content ask-ai bar - Per-theme course-page previews across org landing-page themes (bold, classic, corporate, editorial, etc.) with a shared course token/component set - View-as-student mode and login-link / link-invite invite flows Refactor: - Extract packages/core consolidating config (env, storage, dashboard-url), services and utils previously duplicated under apps/api/src/config - Remove legacy agent service modules (chat-tools, chat-context, context-window, document, fetch-url, lesson-content) - Relax Cross-Origin-Resource-Policy to allow cross-origin media embedding - Add format script to tenant-router so turbo run format covers it
This commit is contained in:
+74
-61
@@ -15,10 +15,12 @@
|
||||
MoneyIcon,
|
||||
ReviewIcon,
|
||||
PersonIcon,
|
||||
ContentIcon,
|
||||
HoverableItem,
|
||||
PreviewIcon
|
||||
} from '@cio/ui/custom/moving-icons';
|
||||
import type { TCourseUpdate } from '@cio/utils/validation/course';
|
||||
import type { LandingSectionKey } from '@cio/ui/custom/org-landing-page';
|
||||
|
||||
import { IconButton } from '@cio/ui/custom/icon-button';
|
||||
import { CloseButton } from '$features/ui';
|
||||
@@ -30,6 +32,7 @@
|
||||
import ReviewsForm from './reviews-form.svelte';
|
||||
import CertificateForm from './certificate-form.svelte';
|
||||
import InstructorForm from './instructor-form.svelte';
|
||||
import SkillsToolsForm from './skills-tools-form.svelte';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { courseApi } from '$features/course/api';
|
||||
import { generateSlug } from '@cio/utils/functions';
|
||||
@@ -37,7 +40,6 @@
|
||||
import * as Sidebar from '@cio/ui/base/sidebar';
|
||||
import { useSidebar } from '@cio/ui/base/sidebar';
|
||||
|
||||
// import CustomPromptBtn from '$lib/components/AI/AIButton/CustomPromptBtn.svelte';
|
||||
import type { Course } from '$features/course/utils/types';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
@@ -46,112 +48,122 @@
|
||||
courseId: string;
|
||||
syncCourseStore: (course: Course) => void;
|
||||
onClose?: () => void;
|
||||
selectedSectionKey?: LandingSectionKey | null;
|
||||
}
|
||||
|
||||
let { course = $bindable(), courseId, syncCourseStore, onClose }: Props = $props();
|
||||
let {
|
||||
course = $bindable(),
|
||||
courseId,
|
||||
syncCourseStore,
|
||||
onClose,
|
||||
selectedSectionKey = $bindable(null)
|
||||
}: Props = $props();
|
||||
|
||||
const sidebar = useSidebar();
|
||||
let loading = $state(false);
|
||||
|
||||
interface Section {
|
||||
key: number;
|
||||
path: string;
|
||||
key: LandingSectionKey;
|
||||
title: string;
|
||||
icon: Component;
|
||||
enableAIWriter?: boolean;
|
||||
initPrompt?: string;
|
||||
domId: string;
|
||||
}
|
||||
|
||||
const sections: Section[] = [
|
||||
const sections: Section[] = $derived([
|
||||
{
|
||||
key: 1,
|
||||
path: '',
|
||||
key: 'header',
|
||||
title: $t('course.navItem.landing_page.editor.title.header'),
|
||||
icon: HeaderIcon
|
||||
icon: HeaderIcon,
|
||||
domId: 'header'
|
||||
},
|
||||
{
|
||||
key: 2,
|
||||
path: 'metadata.requirements',
|
||||
key: 'requirement',
|
||||
title: $t('course.navItem.landing_page.editor.title.requirement'),
|
||||
icon: ExerciseIcon,
|
||||
enableAIWriter: true,
|
||||
initPrompt: $t('course.navItem.landing_page.editor.title.requirement')
|
||||
domId: 'requirement'
|
||||
},
|
||||
{
|
||||
key: 3,
|
||||
path: 'metadata.description',
|
||||
key: 'description',
|
||||
title: $t('course.navItem.landing_page.editor.title.description'),
|
||||
icon: LessonIcon,
|
||||
enableAIWriter: true,
|
||||
initPrompt: 'Please write a detailed course description for this course:'
|
||||
domId: 'description'
|
||||
},
|
||||
{
|
||||
key: 4,
|
||||
path: 'metadata.goals',
|
||||
key: 'goals',
|
||||
title: $t('course.navItem.landing_page.editor.title.goals'),
|
||||
icon: GoalIcon,
|
||||
enableAIWriter: true,
|
||||
initPrompt: 'What should a student expect to learn from this course:'
|
||||
domId: 'goals'
|
||||
},
|
||||
{
|
||||
key: 5,
|
||||
path: '',
|
||||
key: 'chips',
|
||||
title: $t('course.navItem.landing_page.editor.title.chips'),
|
||||
icon: ContentIcon,
|
||||
domId: 'course-chips'
|
||||
},
|
||||
{
|
||||
key: 'certificate',
|
||||
title: $t('course.navItem.landing_page.editor.title.certificate'),
|
||||
icon: CertificateIcon
|
||||
icon: CertificateIcon,
|
||||
domId: 'certificate'
|
||||
},
|
||||
{
|
||||
key: 6,
|
||||
path: '',
|
||||
key: 'reviews',
|
||||
title: $t('course.navItem.landing_page.editor.title.reviews'),
|
||||
icon: ReviewIcon
|
||||
icon: ReviewIcon,
|
||||
domId: 'reviews'
|
||||
},
|
||||
{
|
||||
key: 7,
|
||||
path: '',
|
||||
key: 'instructor',
|
||||
title: $t('course.navItem.landing_page.editor.title.instructor'),
|
||||
icon: PersonIcon
|
||||
icon: PersonIcon,
|
||||
domId: 'instructor'
|
||||
},
|
||||
{
|
||||
key: 8,
|
||||
path: '',
|
||||
key: 'pricing',
|
||||
title: $t('course.navItem.landing_page.editor.title.pricing'),
|
||||
icon: MoneyIcon
|
||||
icon: MoneyIcon,
|
||||
domId: 'pricing'
|
||||
}
|
||||
];
|
||||
let selectedSection: Section | null = $state(null);
|
||||
]);
|
||||
|
||||
const selectedSection = $derived(
|
||||
selectedSectionKey ? (sections.find((s) => s.key === selectedSectionKey) ?? null) : null
|
||||
);
|
||||
|
||||
function handleClose() {
|
||||
if (!selectedSection) {
|
||||
if (!selectedSectionKey) {
|
||||
onClose?.();
|
||||
return;
|
||||
}
|
||||
|
||||
selectedSection = null;
|
||||
selectedSectionKey = null;
|
||||
}
|
||||
|
||||
function handleSectionSelect(sectionKey: number) {
|
||||
function handleSectionSelect(key: LandingSectionKey) {
|
||||
return () => {
|
||||
selectedSection = sections.find((section) => section.key === sectionKey) || null;
|
||||
|
||||
if (selectedSection) {
|
||||
const sectionId = selectedSection.title.toLowerCase();
|
||||
|
||||
const sectionEl = document.getElementById(sectionId);
|
||||
if (sectionEl) {
|
||||
sectionEl.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'start'
|
||||
});
|
||||
}
|
||||
selectedSectionKey = key;
|
||||
const section = sections.find((s) => s.key === key);
|
||||
if (section) {
|
||||
const el = document.getElementById(section.domId);
|
||||
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!selectedSection) return;
|
||||
const el = document.getElementById(selectedSection.domId);
|
||||
if (el) {
|
||||
untrack(() => {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
async function handleSave() {
|
||||
loading = true;
|
||||
course.slug = course.slug || generateSlug(course.title, { appendTimestamp: true });
|
||||
|
||||
console.log('course', course);
|
||||
const updatePayload = {
|
||||
...course,
|
||||
type: course.type!,
|
||||
@@ -185,7 +197,6 @@
|
||||
function setter(value: unknown, setterKey: string) {
|
||||
if (typeof value === 'undefined') return;
|
||||
|
||||
// Untrack the course change to avoid unnecessary re-renders
|
||||
const _course = untrack(() => cloneDeep(course));
|
||||
set(_course, setterKey, value);
|
||||
|
||||
@@ -254,21 +265,23 @@
|
||||
</Sidebar.Group>
|
||||
{:else}
|
||||
<div class="p-4">
|
||||
{#if selectedSection.key === 1}
|
||||
{#if selectedSection.key === 'header'}
|
||||
<HeaderForm bind:course />
|
||||
{:else if selectedSection.key === 2}
|
||||
{:else if selectedSection.key === 'requirement'}
|
||||
<RequirementForm bind:course {setter} />
|
||||
{:else if selectedSection.key === 3}
|
||||
{:else if selectedSection.key === 'description'}
|
||||
<DescriptionForm bind:course {setter} />
|
||||
{:else if selectedSection.key === 4}
|
||||
{:else if selectedSection.key === 'goals'}
|
||||
<GoalsForm bind:course {setter} />
|
||||
{:else if selectedSection.key === 5}
|
||||
{:else if selectedSection.key === 'chips'}
|
||||
<SkillsToolsForm bind:course {setter} />
|
||||
{:else if selectedSection.key === 'certificate'}
|
||||
<CertificateForm bind:course {setter} />
|
||||
{:else if selectedSection.key === 6}
|
||||
{:else if selectedSection.key === 'reviews'}
|
||||
<ReviewsForm bind:course {setter} />
|
||||
{:else if selectedSection.key === 7}
|
||||
{:else if selectedSection.key === 'instructor'}
|
||||
<InstructorForm bind:course {setter} />
|
||||
{:else if selectedSection.key === 8}
|
||||
{:else if selectedSection.key === 'pricing'}
|
||||
<PricingForm bind:course {setter} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
+24
-4
@@ -54,8 +54,28 @@
|
||||
bind:value={course.metadata.videoUrl}
|
||||
/>
|
||||
<div class="mt-7">
|
||||
<p class="mb-3">{$t('course.navItem.landing_page.editor.header_form.replace_cover')}</p>
|
||||
<Button variant="outline" onclick={widgetControl}>
|
||||
{$t('course.navItem.landing_page.editor.header_form.replace')}
|
||||
</Button>
|
||||
<p class="mb-3 font-bold">{$t('course.navItem.landing_page.editor.header_form.replace_cover')}</p>
|
||||
|
||||
{#if course.logo}
|
||||
<div class="border-border mb-3 overflow-hidden rounded-md border">
|
||||
<img src={course.logo} alt={course.title ?? ''} class="block aspect-video w-full object-cover" />
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class="border-border bg-muted text-muted-foreground mb-3 flex aspect-video w-full items-center justify-center rounded-md border border-dashed text-xs"
|
||||
>
|
||||
{$t('course.navItem.landing_page.editor.header_form.no_cover')}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-2">
|
||||
<Button variant="outline" onclick={widgetControl}>
|
||||
{$t('course.navItem.landing_page.editor.header_form.replace')}
|
||||
</Button>
|
||||
{#if course.logo}
|
||||
<Button variant="outline" onclick={() => (course.logo = '')}>
|
||||
{$t('course.navItem.landing_page.editor.header_form.remove')}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+2
-2
@@ -19,7 +19,7 @@
|
||||
let role = $state(get(course, 'metadata.instructor.role'));
|
||||
let imgUrl = $state(get(course, 'metadata.instructor.imgUrl'));
|
||||
let description = $state(get(course, 'metadata.instructor.description'));
|
||||
let courseNo = $state(get(course, 'metadata.instructor.courseNo'));
|
||||
let courseNo = $state(get(course, 'metadata.instructor.coursesNo'));
|
||||
let avatar: File | undefined = $state();
|
||||
let isUploading = $state(false);
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
setter(description, 'metadata.instructor.description');
|
||||
});
|
||||
$effect(() => {
|
||||
setter(courseNo, 'metadata.instructor.courseNo');
|
||||
setter(courseNo, 'metadata.instructor.coursesNo');
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
|
||||
+155
@@ -0,0 +1,155 @@
|
||||
<script lang="ts">
|
||||
import XIcon from '@lucide/svelte/icons/x';
|
||||
import PlusIcon from '@lucide/svelte/icons/plus';
|
||||
import { Input } from '@cio/ui/base/input';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
|
||||
import type { Course } from '$features/course/utils/types';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
interface Props {
|
||||
course: Course;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
setter: (value: any, key: string) => void;
|
||||
}
|
||||
|
||||
let { course = $bindable(), setter }: Props = $props();
|
||||
|
||||
let skillsDraft = $state('');
|
||||
let toolsDraft = $state('');
|
||||
|
||||
const skills = $derived((course.metadata?.skills ?? []) as string[]);
|
||||
const tools = $derived((course.metadata?.tools ?? []) as string[]);
|
||||
|
||||
function addSkill() {
|
||||
const value = skillsDraft.trim();
|
||||
if (!value) return;
|
||||
if (skills.includes(value)) {
|
||||
skillsDraft = '';
|
||||
return;
|
||||
}
|
||||
setter([...skills, value], 'metadata.skills');
|
||||
skillsDraft = '';
|
||||
}
|
||||
|
||||
function removeSkill(skill: string) {
|
||||
setter(
|
||||
skills.filter((s) => s !== skill),
|
||||
'metadata.skills'
|
||||
);
|
||||
}
|
||||
|
||||
function addTool() {
|
||||
const value = toolsDraft.trim();
|
||||
if (!value) return;
|
||||
if (tools.includes(value)) {
|
||||
toolsDraft = '';
|
||||
return;
|
||||
}
|
||||
setter([...tools, value], 'metadata.tools');
|
||||
toolsDraft = '';
|
||||
}
|
||||
|
||||
function removeTool(tool: string) {
|
||||
setter(
|
||||
tools.filter((t) => t !== tool),
|
||||
'metadata.tools'
|
||||
);
|
||||
}
|
||||
|
||||
function handleSkillKey(event: KeyboardEvent) {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
addSkill();
|
||||
}
|
||||
}
|
||||
|
||||
function handleToolKey(event: KeyboardEvent) {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
addTool();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-6">
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-semibold">
|
||||
{$t('course.navItem.landing_page.editor.title.skills')}
|
||||
</p>
|
||||
<p class="mb-3 text-xs text-gray-500">
|
||||
{$t('course.navItem.landing_page.editor.skills_help')}
|
||||
</p>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<Input
|
||||
bind:value={skillsDraft}
|
||||
onkeydown={handleSkillKey}
|
||||
placeholder={$t('course.navItem.landing_page.editor.skills_placeholder')}
|
||||
/>
|
||||
<Button type="button" variant="outline" size="sm" onclick={addSkill} disabled={!skillsDraft.trim()}>
|
||||
<PlusIcon size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{#if skills.length > 0}
|
||||
<div class="mt-3 flex flex-wrap gap-2">
|
||||
{#each skills as skill (skill)}
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded-full border border-gray-300 px-3 py-1 text-xs text-gray-700"
|
||||
>
|
||||
{skill}
|
||||
<button
|
||||
type="button"
|
||||
class="text-gray-400 hover:text-gray-700"
|
||||
onclick={() => removeSkill(skill)}
|
||||
aria-label={$t('course.navItem.landing_page.editor.remove')}
|
||||
>
|
||||
<XIcon size={12} />
|
||||
</button>
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-semibold">
|
||||
{$t('course.navItem.landing_page.editor.title.tools')}
|
||||
</p>
|
||||
<p class="mb-3 text-xs text-gray-500">
|
||||
{$t('course.navItem.landing_page.editor.tools_help')}
|
||||
</p>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<Input
|
||||
bind:value={toolsDraft}
|
||||
onkeydown={handleToolKey}
|
||||
placeholder={$t('course.navItem.landing_page.editor.tools_placeholder')}
|
||||
/>
|
||||
<Button type="button" variant="outline" size="sm" onclick={addTool} disabled={!toolsDraft.trim()}>
|
||||
<PlusIcon size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{#if tools.length > 0}
|
||||
<div class="mt-3 flex flex-wrap gap-2">
|
||||
{#each tools as tool (tool)}
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded-full border border-gray-300 px-3 py-1 text-xs text-gray-700"
|
||||
>
|
||||
{tool}
|
||||
<button
|
||||
type="button"
|
||||
class="text-gray-400 hover:text-gray-700"
|
||||
onclick={() => removeTool(tool)}
|
||||
aria-label={$t('course.navItem.landing_page.editor.remove')}
|
||||
>
|
||||
<XIcon size={12} />
|
||||
</button>
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
+36
-551
@@ -1,74 +1,31 @@
|
||||
<script lang="ts">
|
||||
import get from 'lodash/get';
|
||||
import pluralize from 'pluralize';
|
||||
import { page } from '$app/state';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { resolve } from '$app/paths';
|
||||
import type { Component } from 'svelte';
|
||||
import { SafeHtmlContent } from '@cio/ui/custom/safe-html-content';
|
||||
import PlayIcon from '@lucide/svelte/icons/play';
|
||||
import UserIcon from '@lucide/svelte/icons/user';
|
||||
import StarIcon from '@lucide/svelte/icons/star';
|
||||
import UsersIcon from '@lucide/svelte/icons/users';
|
||||
import BookOpenIcon from '@lucide/svelte/icons/book-open';
|
||||
import ClockIcon from '@lucide/svelte/icons/clock';
|
||||
import { resolve } from '$app/paths';
|
||||
import get from 'lodash/get';
|
||||
|
||||
import { BlurFade } from '@cio/ui/custom/animation/blurfade';
|
||||
import { Sparkle } from '@cio/ui/custom/animation/sparkle';
|
||||
import { MagicCard } from '@cio/ui/custom/animation/magic-card';
|
||||
|
||||
import { getLectureNo } from '$features/course/utils/functions';
|
||||
import {
|
||||
importThemeNavHero,
|
||||
normalizeLandingPageSettings,
|
||||
themeHeaderShellClass,
|
||||
themeStyle,
|
||||
themeRendersNavInsideHero
|
||||
} from '$features/org/utils/landing-page';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
import { basePath } from '$lib/utils/store/app';
|
||||
import { user } from '$lib/utils/store/user';
|
||||
import { NAV_ITEM_KEY, NAV_ITEMS } from './constants';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { calDateDiff } from '$lib/utils/functions/date';
|
||||
import { handleOpenWidget, reviewsModalStore } from './store';
|
||||
import type { Course, CourseMetadata } from '$features/course/utils/types';
|
||||
import { courseApi } from '$features/course/api';
|
||||
import { filterNavItems, getCourseLessons, getCourseSections, getTotalLessons } from './utils';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
|
||||
import { Chip } from '@cio/ui/custom/chip';
|
||||
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/powered-by.svelte';
|
||||
import { importCourseLandingPageTheme, normalizeLandingPageSettings } from '$features/org/utils/landing-page';
|
||||
import type { Course } from '$features/course/utils/types';
|
||||
import type { AccountOrg } from '$features/app/types';
|
||||
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';
|
||||
import PricingSection from './components/pricing-section.svelte';
|
||||
import NavSection from './components/nav-section.svelte';
|
||||
import SectionsDisplay from './components/sections-display.svelte';
|
||||
import { observeIntersection } from './components/intersection-observer';
|
||||
import { buildCourseLandingPageProps } from './utils';
|
||||
import { handleOpenWidget } from './store';
|
||||
|
||||
interface Props {
|
||||
editMode?: boolean;
|
||||
courseData: Course;
|
||||
showStandaloneShell?: boolean;
|
||||
org?: AccountOrg | null;
|
||||
/** Pre-resolved theme component from the route's load function (eliminates the flash on SSR pages). */
|
||||
themeComponent?: Component | null;
|
||||
}
|
||||
|
||||
let { editMode = false, courseData = $bindable(), showStandaloneShell = false }: Props = $props();
|
||||
let { editMode = false, courseData = $bindable(), org = null, themeComponent = null }: Props = $props();
|
||||
|
||||
/** Editor / standalone shell: show org-theme hero + nav from `currentOrg.landingpage`. Org-site embed omits (parent provides hero). */
|
||||
const showMarketingHero = $derived(showStandaloneShell || editMode);
|
||||
|
||||
const landingSettings = $derived(normalizeLandingPageSettings($currentOrg.landingpage));
|
||||
|
||||
const heroShellClass = $derived(`ui:w-full ${themeHeaderShellClass(landingSettings.theme)}`);
|
||||
const heroShellStyle = $derived(themeStyle(landingSettings.theme));
|
||||
const navInsideHero = $derived(themeRendersNavInsideHero(landingSettings.theme));
|
||||
|
||||
const orgNameForNav = $derived($currentOrg.name || get(courseData, 'org.name', '') || 'ClassroomIO');
|
||||
const logoUrlForNav = $derived($currentOrg.avatarUrl || undefined);
|
||||
const activeOrg = $derived(org ?? $currentOrg);
|
||||
const landingSettings = $derived(normalizeLandingPageSettings(activeOrg.landingpage));
|
||||
|
||||
const authAction = $derived(
|
||||
$user.isLoggedIn
|
||||
@@ -82,47 +39,35 @@
|
||||
}
|
||||
);
|
||||
|
||||
const courseHero = $derived.by(() => {
|
||||
const enrollHref = $derived.by(() => {
|
||||
const slug = typeof courseData.slug === 'string' && courseData.slug.length > 0 ? courseData.slug : '';
|
||||
const enrollHref = slug ? resolve(`/course/${slug}/enroll`, {}) : '#';
|
||||
const enrollmentsOpen = get(courseData, 'metadata.allowNewStudent') === true;
|
||||
|
||||
return {
|
||||
...landingSettings.hero,
|
||||
heading: get(courseData, 'title', '') || landingSettings.hero.heading,
|
||||
subheading: get(courseData, 'description', '') || landingSettings.hero.subheading,
|
||||
primaryAction: {
|
||||
label: t.get('course.navItem.landing_page.start_course'),
|
||||
href: enrollHref,
|
||||
disabled: editMode || !enrollmentsOpen
|
||||
},
|
||||
image: get(courseData, 'logo', '') || landingSettings.hero.image
|
||||
};
|
||||
return slug ? resolve(`/course/${slug}/enroll`, {}) : '#';
|
||||
});
|
||||
|
||||
/** Matches API `buildCourseContent` — do not use `metadata.isContentGroupingEnabled` (dashboard normalizes undefined → true). */
|
||||
const lessonsLayoutIsGrouped = $derived(courseData.content?.grouped === true);
|
||||
const enrollmentsOpen = $derived(get(courseData, 'metadata.allowNewStudent') === true);
|
||||
const enrollDisabled = $derived(editMode || !enrollmentsOpen);
|
||||
|
||||
const landingProps = $derived(
|
||||
buildCourseLandingPageProps(courseData, activeOrg, {
|
||||
enrollHref,
|
||||
enrollDisabled,
|
||||
authAction
|
||||
})
|
||||
);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let NavComponent = $state<Component<any> | null>(null);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let HeroComponent = $state<Component<any> | null>(null);
|
||||
let ThemeComponent = $state<Component<any> | null>(themeComponent);
|
||||
|
||||
$effect(() => {
|
||||
if (!showMarketingHero) {
|
||||
NavComponent = null;
|
||||
HeroComponent = null;
|
||||
|
||||
return;
|
||||
}
|
||||
// Skip client-side loading if the theme was already resolved server-side.
|
||||
if (ThemeComponent) return;
|
||||
|
||||
const theme = landingSettings.theme;
|
||||
let cancelled = false;
|
||||
|
||||
void importThemeNavHero(theme).then(({ NavComponent: nav, HeroComponent: hero }) => {
|
||||
void importCourseLandingPageTheme(theme).then((mod) => {
|
||||
if (!cancelled) {
|
||||
NavComponent = nav;
|
||||
HeroComponent = hero;
|
||||
ThemeComponent = mod.default;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -130,473 +75,13 @@
|
||||
cancelled = true;
|
||||
};
|
||||
});
|
||||
|
||||
const reviews = $derived(get(courseData, 'metadata.reviews') || []);
|
||||
|
||||
const lessons = $derived(getCourseLessons(courseData));
|
||||
const totalRatings = $derived(reviews?.reduce((acc = 0, review) => acc + (review?.rating || 0), 0));
|
||||
const averageRating = $derived(reviews?.length ? (totalRatings / reviews.length).toFixed(1) : null);
|
||||
const expandDescription = $derived(Array(reviews?.length ?? 0).fill(false));
|
||||
|
||||
const instructor = $derived((get(courseData, 'metadata.instructor') || {}) as Record<string, unknown>);
|
||||
const organizationName = $derived(get(courseData, 'org.name') || $currentOrg.name || '');
|
||||
const instructorName = $derived.by(() => {
|
||||
const name = get(instructor, 'name');
|
||||
return typeof name === 'string' && name.trim().length > 0 ? name : organizationName;
|
||||
});
|
||||
const instructorImageUrl = $derived.by(() => {
|
||||
const imageUrl = get(instructor, 'imgUrl');
|
||||
return typeof imageUrl === 'string' && imageUrl.trim().length > 0 ? imageUrl : '/images/avatar.svg';
|
||||
});
|
||||
const certificate = $derived(
|
||||
get(courseData, 'metadata.certificate', {
|
||||
templateUrl: '/images/certificate-template.svg'
|
||||
}) as CourseMetadata['certificate']
|
||||
);
|
||||
|
||||
const navItems = $derived(filterNavItems(courseData, reviews));
|
||||
const navItemKeys = $derived(navItems.map((item) => item.key));
|
||||
|
||||
let startCoursePayment = $state(false);
|
||||
let isVisible = $state(false);
|
||||
let observer: { destroy: () => void };
|
||||
|
||||
let activeNav = $state(NAV_ITEMS[0].key);
|
||||
|
||||
const courseSections = $derived(getCourseSections(courseData));
|
||||
const totalLessons = $derived(getTotalLessons(courseSections));
|
||||
const studentCount = $derived(courseData.group?.members?.length ?? 0);
|
||||
|
||||
function locationHashChanged() {
|
||||
activeNav = window.location.hash;
|
||||
}
|
||||
|
||||
function toggleDescription(id: number) {
|
||||
expandDescription[id] = !expandDescription[id];
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
window.onhashchange = locationHashChanged;
|
||||
|
||||
const targetNode = document.querySelector('.target-component');
|
||||
observer = observeIntersection(targetNode, (inView: boolean) => {
|
||||
isVisible = inView;
|
||||
});
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
observer?.destroy();
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if !editMode && showStandaloneShell}
|
||||
<PoweredBy />
|
||||
{/if}
|
||||
|
||||
<div class="flex w-full flex-col items-center bg-white dark:bg-black">
|
||||
{#if showMarketingHero}
|
||||
<div class={heroShellClass} style={heroShellStyle}>
|
||||
{#if NavComponent && HeroComponent}
|
||||
{#if navInsideHero}
|
||||
<HeroComponent hero={courseHero} orgName={orgNameForNav}>
|
||||
{#snippet navigation()}
|
||||
<NavComponent
|
||||
orgName={orgNameForNav}
|
||||
logoUrl={logoUrlForNav}
|
||||
navItems={landingSettings.navItems}
|
||||
{authAction}
|
||||
/>
|
||||
{/snippet}
|
||||
</HeroComponent>
|
||||
{:else}
|
||||
<NavComponent
|
||||
orgName={orgNameForNav}
|
||||
logoUrl={logoUrlForNav}
|
||||
navItems={landingSettings.navItems}
|
||||
{authAction}
|
||||
/>
|
||||
<HeroComponent hero={courseHero} orgName={orgNameForNav} />
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if editMode && $handleOpenWidget.open}
|
||||
<div class="mx-auto w-full max-w-7xl px-6 py-2">
|
||||
<UploadWidget imageURL={courseApi.course?.logo} onchange={(newLogo) => (courseApi.course!.logo = newLogo)} />
|
||||
</div>
|
||||
{/if}
|
||||
{#if ThemeComponent}
|
||||
{#if editMode && $handleOpenWidget.open}
|
||||
<div class="mx-auto w-full max-w-7xl px-6 py-3">
|
||||
<UploadWidget imageURL={courseData.logo} onchange={(newLogo) => (courseData.logo = newLogo)} />
|
||||
</div>
|
||||
|
||||
<!-- Social Proof Strip -->
|
||||
{#if reviews.length > 0 || studentCount > 0 || lessons.length > 0}
|
||||
<div class="w-full border-b border-gray-200 bg-gray-50 dark:border-neutral-800 dark:bg-neutral-900">
|
||||
<div
|
||||
class="mx-auto flex w-full max-w-6xl flex-wrap items-center justify-center gap-6 px-6 py-4 text-sm md:justify-start"
|
||||
>
|
||||
{#if averageRating}
|
||||
<div class="flex items-center gap-1.5 text-gray-700 dark:text-gray-300">
|
||||
<StarIcon size={16} class="fill-yellow-400 text-yellow-400" />
|
||||
<span class="font-semibold">{averageRating}</span>
|
||||
<span class="text-gray-500 dark:text-gray-400"
|
||||
>({reviews.length} {reviews.length === 1 ? 'review' : 'reviews'})</span
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
{#if studentCount > 0}
|
||||
<div class="flex items-center gap-1.5 text-gray-700 dark:text-gray-300">
|
||||
<UsersIcon size={16} />
|
||||
<span>{studentCount} {studentCount === 1 ? 'student' : 'students'}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if lessons.length > 0}
|
||||
<div class="flex items-center gap-1.5 text-gray-700 dark:text-gray-300">
|
||||
<BookOpenIcon size={16} />
|
||||
<span>{pluralize('lesson', lessons.length, true)}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if courseData.type}
|
||||
<div class="flex items-center gap-1.5 text-gray-700 dark:text-gray-300">
|
||||
<ClockIcon size={16} />
|
||||
<span>{courseData.type === 'LIVE_CLASS' ? 'Live Class' : 'Self-Paced'}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Body -->
|
||||
<div class="w-full bg-white dark:bg-black">
|
||||
<div
|
||||
class="mx-auto my-2 flex w-full max-w-6xl flex-col-reverse items-center justify-between px-4 lg:flex-row lg:items-start lg:py-8"
|
||||
>
|
||||
<!-- Course Details -->
|
||||
<div class="w-full max-w-[680px] min-w-[60%] p-3 lg:mr-10">
|
||||
<!-- Navigation -->
|
||||
<UnderlineTabs.Root bind:value={activeNav} class="py-2">
|
||||
<UnderlineTabs.List>
|
||||
{#each navItems as navItem (navItem.key)}
|
||||
<UnderlineTabs.Trigger value={navItem.key}>
|
||||
<a href={resolve(page.url.pathname + navItem.key, {})}>
|
||||
{$t(navItem.label)}
|
||||
</a>
|
||||
</UnderlineTabs.Trigger>
|
||||
{/each}
|
||||
</UnderlineTabs.List>
|
||||
</UnderlineTabs.Root>
|
||||
|
||||
<!-- Sections - Requirement -->
|
||||
{#if navItemKeys.includes(NAV_ITEM_KEY.REQUIREMENT)}
|
||||
<NavSection id="requirement">
|
||||
<BlurFade delay={0.1}>
|
||||
<h3 class="mt-0 mb-3 text-2xl font-semibold">
|
||||
{$t('course.navItem.landing_page.requirement')}
|
||||
</h3>
|
||||
<HTMLRender className="text-sm font-light leading-relaxed">
|
||||
<SafeHtmlContent content={get(courseData, 'metadata.requirements', '')} />
|
||||
</HTMLRender>
|
||||
</BlurFade>
|
||||
</NavSection>
|
||||
{/if}
|
||||
|
||||
<!-- Sections - Course Description -->
|
||||
{#if navItemKeys.includes(NAV_ITEM_KEY.DESCRIPTION)}
|
||||
<NavSection id="description">
|
||||
<BlurFade delay={0.1}>
|
||||
<h3 class="mt-0 mb-3 text-2xl font-semibold">
|
||||
{$t('course.navItem.landing_page.description')}
|
||||
</h3>
|
||||
<HTMLRender className="dark:text-white text-sm font-light leading-relaxed">
|
||||
<SafeHtmlContent content={get(courseData, 'metadata.description', '')} />
|
||||
</HTMLRender>
|
||||
</BlurFade>
|
||||
</NavSection>
|
||||
{/if}
|
||||
|
||||
<!-- Sections - Goal -->
|
||||
{#if navItemKeys.includes(NAV_ITEM_KEY.GOALS)}
|
||||
<NavSection id="goals">
|
||||
<BlurFade delay={0.1}>
|
||||
<h3 class="mt-0 mb-3 text-2xl font-semibold">{$t('course.navItem.landing_page.learn')}</h3>
|
||||
<HTMLRender className="text-sm font-light leading-relaxed">
|
||||
<SafeHtmlContent content={get(courseData, 'metadata.goals', '')} />
|
||||
</HTMLRender>
|
||||
</BlurFade>
|
||||
</NavSection>
|
||||
{/if}
|
||||
|
||||
<!-- Sections - Certificate -->
|
||||
{#if navItemKeys.includes(NAV_ITEM_KEY.CERTIFICATE)}
|
||||
<NavSection id="certificate">
|
||||
<BlurFade delay={0.1}>
|
||||
<h3 class="mt-0 text-2xl font-semibold">{$t('course.navItem.landing_page.certificate')}</h3>
|
||||
<p class="mb-4 text-sm font-light dark:text-white">
|
||||
{$t('course.navItem.landing_page.certificate_text')}
|
||||
</p>
|
||||
<MagicCard
|
||||
gradientColor="#3b82f6"
|
||||
gradientOpacity={0.15}
|
||||
class="inline-block w-auto border-0 bg-transparent py-0 dark:bg-transparent"
|
||||
>
|
||||
<Image src={certificate?.templateUrl} alt="certificate template" className="max-h-[215px] w-auto" />
|
||||
</MagicCard>
|
||||
</BlurFade>
|
||||
</NavSection>
|
||||
{/if}
|
||||
|
||||
<!-- Sections - Lessons (layout follows `content.grouped` from API, not client-normalized metadata) -->
|
||||
{#if !lessonsLayoutIsGrouped}
|
||||
<NavSection id="lessons">
|
||||
<BlurFade delay={0.1}>
|
||||
<div class="mb-3 flex w-full items-center justify-between">
|
||||
<h3 class="mt-0 mb-3 text-2xl font-semibold">
|
||||
{$t('course.navItem.landing_page.content')}
|
||||
</h3>
|
||||
<p class="text-sm font-light dark:text-white">
|
||||
{pluralize('lesson', lessons.length, true)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each lessons as lesson, index (lesson.id)}
|
||||
<BlurFade delay={0.05 * Math.min(index, 10)}>
|
||||
<div
|
||||
class="rounded-lg border border-gray-200 px-3 py-1.5 transition-colors hover:border-gray-400 dark:border-neutral-700 dark:hover:border-neutral-500"
|
||||
>
|
||||
<Chip value={getLectureNo(index + 1, '0')} />
|
||||
<p class="ml-2 inline text-xs font-light dark:text-white">
|
||||
{lesson.title}
|
||||
</p>
|
||||
</div>
|
||||
</BlurFade>
|
||||
{/each}
|
||||
</div>
|
||||
</BlurFade>
|
||||
</NavSection>
|
||||
{:else}
|
||||
<NavSection id="lessons">
|
||||
<BlurFade delay={0.1}>
|
||||
<!-- header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-2xl font-semibold">{$t('course.navItem.landing_page.course_content')}</h1>
|
||||
<span class="text-xs font-normal">
|
||||
{pluralize($t('course.navItem.landing_page.modules'), courseSections.length, true)},
|
||||
{pluralize($t('course.navItem.landing_page.lessons'), totalLessons, true)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#each courseSections as section, index (section.id)}
|
||||
<BlurFade delay={0.05 * Math.min(index, 8)}>
|
||||
<SectionsDisplay
|
||||
exerciseCount={section.exerciseCount}
|
||||
lessonCount={section.lessons?.length}
|
||||
lessons={section.lessons}
|
||||
title={section.title!}
|
||||
/>
|
||||
</BlurFade>
|
||||
{/each}
|
||||
</BlurFade>
|
||||
</NavSection>
|
||||
{/if}
|
||||
|
||||
<!-- Sections - Reviews -->
|
||||
{#if navItemKeys.includes(NAV_ITEM_KEY.REVIEWS)}
|
||||
<NavSection id="reviews">
|
||||
<BlurFade delay={0.1}>
|
||||
<div class="mb-6 flex items-center gap-3">
|
||||
<h2 class="text-2xl font-semibold">
|
||||
{$t('course.navItem.landing_page.reviews')}
|
||||
</h2>
|
||||
{#if averageRating}
|
||||
<Sparkle
|
||||
text={averageRating}
|
||||
colors={{ first: '#facc15', second: '#f97316' }}
|
||||
sparklesCount={3}
|
||||
class="text-xl font-bold"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</BlurFade>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
{#each reviews.slice(0, 4) as review, id (review.id)}
|
||||
{#if !review.hide}
|
||||
<BlurFade delay={0.1 + id * 0.08}>
|
||||
<div
|
||||
class="flex flex-row items-start gap-3 rounded-lg border border-gray-100 p-4 dark:border-neutral-800"
|
||||
>
|
||||
<!-- image container -->
|
||||
<Avatar.Root class="mt-1 size-10 shrink-0">
|
||||
{#if review.avatar_url}
|
||||
<Avatar.Image src={review.avatar_url} alt={review.name ? review.name : 'Avatar'} />
|
||||
{/if}
|
||||
<Avatar.Fallback>
|
||||
<UserIcon class="ui:size-5 ui:text-muted-foreground" />
|
||||
</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
|
||||
<!-- profile content -->
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="mb-0.5 font-medium">{review.name}</p>
|
||||
<!-- ratings -->
|
||||
<div class="mb-2 flex items-center gap-0.5">
|
||||
{#each Array(5) as _, starIndex (starIndex)}
|
||||
<StarIcon
|
||||
size={14}
|
||||
class={starIndex < (review.rating || 0)
|
||||
? 'fill-yellow-400 text-yellow-400'
|
||||
: 'text-gray-300 dark:text-neutral-600'}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="mb-2 overflow-hidden" style:max-height={expandDescription[id] ? 'none' : '60px'}>
|
||||
<p class="text-sm leading-relaxed text-gray-600 dark:text-gray-400">
|
||||
{review.description}
|
||||
</p>
|
||||
</div>
|
||||
{#if !expandDescription[id] && review.description.split(' ').length > 12}
|
||||
<button
|
||||
class="ui:text-primary text-sm font-normal underline"
|
||||
onclick={() => toggleDescription(id)}>See More</button
|
||||
>
|
||||
{/if}
|
||||
{#if expandDescription[id]}
|
||||
<button
|
||||
class="ui:text-primary text-sm font-normal underline"
|
||||
onclick={() => toggleDescription(id)}>See Less</button
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</BlurFade>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{#if reviews.length > 4}
|
||||
<Button class="mt-4" variant="outline" onclick={() => ($reviewsModalStore.open = true)}>
|
||||
{$t('course.navItem.landing_page.see_all')}
|
||||
</Button>
|
||||
{/if}
|
||||
|
||||
<!-- Reviews Modal -->
|
||||
<Dialog.Root bind:open={$reviewsModalStore.open}>
|
||||
<Dialog.Content class="w-9/12">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{$t('course.navItem.landing_page.reviews_modal.title')}</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
<div class="flex">
|
||||
<!-- ratings -->
|
||||
<div class="w-1/3">
|
||||
<h2 class="text-xl">
|
||||
{averageRating}
|
||||
{$t('course.navItem.landing_page.reviews_modal.rating')}
|
||||
</h2>
|
||||
<h2 class="mt-2 text-lg font-semibold">
|
||||
{reviews.length}
|
||||
{$t('course.navItem.landing_page.reviews_modal.ratings')}
|
||||
</h2>
|
||||
</div>
|
||||
<!-- reviews -->
|
||||
<div class="flex w-4/6 flex-wrap">
|
||||
{#each reviews as review, id (review.id)}
|
||||
<!-- review -->
|
||||
<div class="my-2 flex w-full flex-row items-start">
|
||||
<!-- image container -->
|
||||
<Avatar.Root class="mt-1 h-10 w-10">
|
||||
{#if review.avatar_url}
|
||||
<Avatar.Image src={review.avatar_url} alt={review.name ? review.name : 'Avatar'} />
|
||||
{/if}
|
||||
<Avatar.Fallback>
|
||||
<UserIcon class="ui:size-5 ui:text-muted-foreground" />
|
||||
</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
|
||||
<!-- profile content -->
|
||||
<div class="w-11/12 pl-2.5">
|
||||
<p class="mb-0.5 font-medium">{review.name}</p>
|
||||
<!-- ratings -->
|
||||
<div class="flex flex-row items-center gap-1">
|
||||
<div class="flex items-center gap-0.5">
|
||||
{#each Array(5) as _, starIndex (starIndex)}
|
||||
<StarIcon
|
||||
size={14}
|
||||
class={starIndex < (review.rating || 0)
|
||||
? 'fill-yellow-400 text-yellow-400'
|
||||
: 'text-gray-300 dark:text-neutral-600'}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
<p class="text-xs text-gray-600">
|
||||
{calDateDiff(review.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="mb-2 overflow-hidden transition-all"
|
||||
style:max-height={expandDescription[id] ? 'none' : '60px'}
|
||||
>
|
||||
<p class="my-2 text-sm leading-relaxed text-gray-600">
|
||||
{review.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
</NavSection>
|
||||
{/if}
|
||||
|
||||
<!-- Sections - Instructor -->
|
||||
<NavSection id="instructor">
|
||||
<BlurFade delay={0.1}>
|
||||
<h3 class="mt-0 mb-4 text-2xl font-semibold">
|
||||
{$t('course.navItem.landing_page.instructor')}
|
||||
</h3>
|
||||
<div class="mb-4 flex items-center gap-4">
|
||||
<img
|
||||
alt="Author Avatar"
|
||||
class="block h-20 w-20 rounded-full object-cover ring-2 ring-gray-200 dark:ring-neutral-700"
|
||||
src={instructorImageUrl}
|
||||
/>
|
||||
<div>
|
||||
<p class="text-base font-medium dark:text-white">
|
||||
{instructorName}
|
||||
</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{get(instructor, 'role', '')}
|
||||
</p>
|
||||
{#if get(instructor, 'courseNo', '')}
|
||||
<p class="mt-1 flex items-center gap-1.5 text-sm text-gray-600 dark:text-gray-300">
|
||||
<PlayIcon size={14} class="filled" />
|
||||
<span>
|
||||
{get(instructor, 'courseNo', '')}
|
||||
{$t('course.navItem.landing_page.courses')}
|
||||
</span>
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-sm leading-relaxed font-light dark:text-white">
|
||||
{get(instructor, 'description', '')}
|
||||
</p>
|
||||
</BlurFade>
|
||||
</NavSection>
|
||||
</div>
|
||||
|
||||
<!-- Pricing Details -->
|
||||
<PricingSection {courseData} {editMode} bind:startCoursePayment className="target-component" />
|
||||
</div>
|
||||
{#if !isVisible}
|
||||
<PricingSection {courseData} {editMode} bind:startCoursePayment mobile={true} className="w-full" />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
:global(.list ul li) {
|
||||
margin-left: 1rem;
|
||||
list-style-type: disc;
|
||||
}
|
||||
</style>
|
||||
<svelte:component this={ThemeComponent} {...landingProps} />
|
||||
{/if}
|
||||
|
||||
@@ -4,6 +4,9 @@ import { NAV_ITEMS, NAV_ITEM_KEY } from './constants';
|
||||
import { ContentType } from '@cio/utils/constants/content';
|
||||
import type { Review } from '$features/course/utils/types';
|
||||
import get from 'lodash/get';
|
||||
import type { AccountOrg } from '$features/app/types';
|
||||
import { normalizeLandingPageSettings } from '$features/org/utils/landing-page';
|
||||
import type { CourseLandingPageProps, OrgLandingPageTheme } from '@cio/ui/custom/org-landing-page';
|
||||
|
||||
export type LandingPageLesson = {
|
||||
id: string;
|
||||
@@ -105,3 +108,138 @@ export function filterNavItems(course: Course, reviews: Review[]) {
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export function buildCourseLandingPageProps(
|
||||
course: Course,
|
||||
org: AccountOrg,
|
||||
options: {
|
||||
enrollHref: string;
|
||||
enrollDisabled: boolean;
|
||||
authAction?: { label: string; href: string };
|
||||
}
|
||||
): CourseLandingPageProps {
|
||||
const landing = normalizeLandingPageSettings(org.landingpage);
|
||||
const metadata = course.metadata ?? null;
|
||||
const reviewsRaw = (metadata?.reviews ?? []) as NonNullable<NonNullable<Course['metadata']>['reviews']>;
|
||||
const reviewsVisible = reviewsRaw.filter((r) => !r?.hide);
|
||||
|
||||
const sectionDisplay = (metadata?.sectionDisplay ?? {}) as Record<string, boolean>;
|
||||
const isVisible = (key: string, fallback = true) =>
|
||||
typeof sectionDisplay[key] === 'boolean' ? sectionDisplay[key] : fallback;
|
||||
|
||||
const sections = getCourseSections(course);
|
||||
const totalLessons = getTotalLessons(sections);
|
||||
|
||||
const averageRating =
|
||||
reviewsVisible.length > 0
|
||||
? reviewsVisible.reduce((sum, r) => sum + (r.rating || 0), 0) / reviewsVisible.length
|
||||
: undefined;
|
||||
|
||||
const hasCertificate = Boolean(metadata?.certificate?.templateUrl || course.certificate?.design?.templateId);
|
||||
|
||||
const courseTypeLabel =
|
||||
course.type === 'LIVE_CLASS' ? 'Live class' : course.type === 'COMPLIANCE' ? 'Compliance' : 'Self-paced';
|
||||
|
||||
const includeRequirement = isVisible(NAV_ITEM_KEY.REQUIREMENT) && Boolean(metadata?.requirements);
|
||||
const includeDescription = isVisible(NAV_ITEM_KEY.DESCRIPTION) && Boolean(metadata?.description);
|
||||
const includeGoals = isVisible(NAV_ITEM_KEY.GOALS) && Boolean(metadata?.goals);
|
||||
const includeCertificate = isVisible(NAV_ITEM_KEY.CERTIFICATE) && Boolean(metadata?.certificate?.templateUrl);
|
||||
const includeReviews = isVisible(NAV_ITEM_KEY.REVIEWS, reviewsVisible.length > 0);
|
||||
const includeInstructor = isVisible(NAV_ITEM_KEY.INSTRUCTOR);
|
||||
|
||||
const instructor = (metadata?.instructor ?? {}) as NonNullable<Course['metadata']>['instructor'] & {
|
||||
name?: string;
|
||||
role?: string;
|
||||
description?: string;
|
||||
imgUrl?: string;
|
||||
coursesNo?: number;
|
||||
};
|
||||
|
||||
const stats = [
|
||||
{ label: 'Lessons', value: totalLessons.toString() },
|
||||
averageRating !== undefined ? { label: 'Rating', value: averageRating.toFixed(1) } : null,
|
||||
hasCertificate ? { label: 'Certificate', value: 'Included' } : null
|
||||
].filter(Boolean) as Array<{ label: string; value: string }>;
|
||||
|
||||
const pricingCtaLabel = course.cost && course.cost > 0 ? 'Enrol now' : 'Start free';
|
||||
|
||||
return {
|
||||
theme: landing.theme as OrgLandingPageTheme,
|
||||
orgName: org.name ?? '',
|
||||
logoUrl: org.avatarUrl ?? undefined,
|
||||
navItems: landing.navItems,
|
||||
authAction: options.authAction,
|
||||
hero: {
|
||||
heading: course.title ?? landing.hero.heading,
|
||||
subheading: course.description ?? landing.hero.subheading,
|
||||
primaryAction: {
|
||||
label: pricingCtaLabel,
|
||||
href: options.enrollHref,
|
||||
disabled: options.enrollDisabled
|
||||
},
|
||||
secondaryAction: { label: 'View curriculum', href: '#curriculum' },
|
||||
image: course.logo || undefined,
|
||||
stats
|
||||
},
|
||||
socialProof: {
|
||||
rating: averageRating,
|
||||
lessons: totalLessons > 0 ? totalLessons : undefined,
|
||||
type: courseTypeLabel,
|
||||
hasCertificate
|
||||
},
|
||||
info: {
|
||||
requirements: includeRequirement ? (metadata?.requirements ?? undefined) : undefined,
|
||||
description: includeDescription ? (metadata?.description ?? undefined) : undefined,
|
||||
goals: includeGoals ? (metadata?.goals ?? undefined) : undefined,
|
||||
certificateUrl: includeCertificate ? metadata?.certificate?.templateUrl : undefined
|
||||
},
|
||||
curriculum: {
|
||||
grouped: course.content?.grouped ?? false,
|
||||
sections: sections.map((section) => ({
|
||||
id: section.id,
|
||||
title: section.title ?? 'Lessons',
|
||||
lessons: section.lessons.map((lesson) => ({
|
||||
id: lesson.id,
|
||||
title: lesson.title ?? 'Untitled lesson'
|
||||
})),
|
||||
exerciseCount: section.exerciseCount
|
||||
}))
|
||||
},
|
||||
chips: {
|
||||
skills: metadata?.skills ?? undefined,
|
||||
tools: metadata?.tools ?? undefined
|
||||
},
|
||||
instructor: includeInstructor
|
||||
? {
|
||||
name: instructor.name?.trim() || org.name || '',
|
||||
role: instructor.role || undefined,
|
||||
imgUrl: instructor.imgUrl || org.avatarUrl || undefined,
|
||||
description: instructor.description || undefined,
|
||||
coursesNo: instructor.coursesNo || undefined
|
||||
}
|
||||
: { name: '' },
|
||||
reviews: {
|
||||
items: includeReviews
|
||||
? reviewsVisible.map((r, idx) => ({
|
||||
id: `review-${r.id ?? idx}`,
|
||||
name: r.name ?? 'Anonymous',
|
||||
avatarUrl: r.avatar_url || undefined,
|
||||
rating: r.rating ?? 0,
|
||||
description: r.description ?? '',
|
||||
createdAt: r.created_at ? new Date(r.created_at).toISOString() : undefined
|
||||
}))
|
||||
: [],
|
||||
averageRating
|
||||
},
|
||||
pricing: {
|
||||
cost: course.cost ?? 0,
|
||||
currency: course.currency ?? 'USD',
|
||||
discount: metadata?.discount,
|
||||
showDiscount: metadata?.showDiscount,
|
||||
ctaLabel: pricingCtaLabel,
|
||||
ctaHref: options.enrollHref,
|
||||
reward: metadata?.reward?.show ? { show: true, description: metadata.reward.description ?? '' } : undefined
|
||||
},
|
||||
footer: landing.footer
|
||||
};
|
||||
}
|
||||
|
||||
@@ -17,12 +17,13 @@
|
||||
});
|
||||
|
||||
const poster = $derived(source.type === 'upload' ? source.metadata?.thumbnailUrl : undefined);
|
||||
const isHls = $derived(Boolean(source.hls));
|
||||
</script>
|
||||
|
||||
<div class="mb-5 {className}">
|
||||
{#if isMuse}
|
||||
<MusePlayer svid={source.metadata?.svid} {options} />
|
||||
{:else}
|
||||
<PlyrPlayer src={source.url} {poster} {options} />
|
||||
<PlyrPlayer src={source.url} {poster} hls={isHls} {options} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -4,18 +4,47 @@
|
||||
import type { MediaPlayerOptions } from '../types';
|
||||
import { isYoutubeUrl, getYoutubeVideoId } from '../utils';
|
||||
import { PLYR_DEFAULT_CONTROLS } from './constants';
|
||||
import { classroomio } from '$lib/utils/services/api';
|
||||
import type Plyr from 'plyr';
|
||||
|
||||
/**
|
||||
* Resolve an HLS source. The caller passes either a full URL or the
|
||||
* relative shape `/hls/{assetId}/{rest}` we store in `lesson.videos[].link`.
|
||||
* Routes through the typed Hono client so playback uses the same base
|
||||
* URL as every other API call — direct to `PUBLIC_SERVER_URL` in local dev,
|
||||
* and `${origin}/proxy/...` in production. Cloud tenant-router intercepts
|
||||
* `/proxy/hls/*` and serves directly from R2.
|
||||
*
|
||||
* Hono's `$url()` only substitutes named `:params`, leaving wildcards as
|
||||
* a literal `/*` suffix — we strip it and append the real path.
|
||||
*/
|
||||
function resolveHlsUrl(src: string): string {
|
||||
if (/^https?:\/\//i.test(src)) return src;
|
||||
|
||||
const match = src.match(/^\/?hls\/([^/]+)\/(.+)$/);
|
||||
if (!match) return src;
|
||||
|
||||
const [, assetId, rest] = match;
|
||||
const built = classroomio.hls[':assetId']['*'].$url({ param: { assetId } });
|
||||
return built.toString().replace(/\/\*$/, '') + '/' + rest;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
src: string;
|
||||
poster?: string;
|
||||
/**
|
||||
* When true, `src` points at an HLS master playlist (.m3u8) and the
|
||||
* player attaches hls.js to handle adaptive bitrate streaming.
|
||||
*/
|
||||
hls?: boolean;
|
||||
options?: MediaPlayerOptions;
|
||||
}
|
||||
|
||||
let { src, poster, options = {} }: Props = $props();
|
||||
let { src, poster, hls = false, options = {} }: Props = $props();
|
||||
let videoElement: HTMLVideoElement | null = null;
|
||||
let containerElement: HTMLDivElement | null = null;
|
||||
let playerInstance: Plyr | null = null;
|
||||
let hlsInstance: import('hls.js').default | null = null;
|
||||
|
||||
const playsinline = $derived(options.playsinline !== false);
|
||||
const maxHeight = $derived(options.maxHeight || '400px');
|
||||
@@ -43,6 +72,36 @@
|
||||
iconPrefix: 'plyr'
|
||||
});
|
||||
} else if (videoElement) {
|
||||
// For HLS, attach hls.js to feed the manifest into the video element
|
||||
// before Plyr wraps it. Safari uses the native HLS playback path
|
||||
// (`canPlayType('application/vnd.apple.mpegurl')`), no hls.js needed.
|
||||
if (hls) {
|
||||
const hlsUrl = resolveHlsUrl(src);
|
||||
const supportsNativeHls = videoElement.canPlayType('application/vnd.apple.mpegurl') !== '';
|
||||
if (supportsNativeHls) {
|
||||
// Native HLS (Safari) — set src here since the markup branch sees
|
||||
// canPlayType before videoElement mounts and may render undefined.
|
||||
videoElement.src = hlsUrl;
|
||||
} else {
|
||||
const HlsModule = await import('hls.js');
|
||||
const Hls = HlsModule.default;
|
||||
if (Hls.isSupported()) {
|
||||
hlsInstance = new Hls({
|
||||
enableWorker: true,
|
||||
// Browser needs to send session cookies to the API when the
|
||||
// dashboard is on a different origin (dev / self-hosted).
|
||||
xhrSetup: (xhr) => {
|
||||
xhr.withCredentials = true;
|
||||
}
|
||||
});
|
||||
hlsInstance.loadSource(hlsUrl);
|
||||
hlsInstance.attachMedia(videoElement);
|
||||
} else {
|
||||
videoElement.src = hlsUrl;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For regular HTML5 video
|
||||
// playsinline is set as an HTML attribute on the video element, not a Plyr option
|
||||
playerInstance = new Plyr(videoElement, {
|
||||
@@ -56,6 +115,15 @@
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (hlsInstance) {
|
||||
try {
|
||||
hlsInstance.destroy();
|
||||
} catch {
|
||||
// Ignore — hls.js may have already torn itself down.
|
||||
} finally {
|
||||
hlsInstance = null;
|
||||
}
|
||||
}
|
||||
if (playerInstance) {
|
||||
try {
|
||||
// Check if player is an embed (YouTube/Vimeo) and handle accordingly
|
||||
@@ -93,8 +161,12 @@
|
||||
{:else}
|
||||
<!-- HTML5 video -->
|
||||
<!-- svelte-ignore a11y_media_has_caption -->
|
||||
<!-- crossorigin omitted so cross-origin poster (e.g. R2 r2.dev) displays without CORS; r2.dev may not send CORS headers even when bucket CORS is set -->
|
||||
<video bind:this={videoElement} {src} {poster} {playsinline} class="plyr-player"></video>
|
||||
<!-- For HLS, hls.js or native HLS attaches programmatically in onMount; the bare <video> has no src here. -->
|
||||
{#if hls}
|
||||
<video bind:this={videoElement} {poster} {playsinline} class="plyr-player"></video>
|
||||
{:else}
|
||||
<video bind:this={videoElement} {src} {poster} {playsinline} class="plyr-player"></video>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- ClassroomIO Logo Overlay -->
|
||||
|
||||
@@ -3,6 +3,11 @@ export type VideoType = 'youtube' | 'generic' | 'upload' | 'muse' | 'google_driv
|
||||
export interface VideoSource {
|
||||
type: VideoType;
|
||||
url: string;
|
||||
/**
|
||||
* When set, `url` points at an HLS master playlist (.m3u8). The player
|
||||
* attaches hls.js instead of feeding the URL into a raw HTML5 video.
|
||||
*/
|
||||
hls?: boolean;
|
||||
metadata?: {
|
||||
svid?: string; // muse.ai video ID
|
||||
thumbnailUrl?: string; // upload thumbnail / poster
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import ArrowUpRightIcon from '@lucide/svelte/icons/arrow-up-right';
|
||||
|
||||
import { PublicCoursePoweredBy } from '@cio/ui/custom/public-course';
|
||||
import { TENANT_ROOT_DOMAIN } from '@cio/utils/constants/domains';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { currentOrg, isFreePlan } from '$lib/utils/store/org';
|
||||
import { cn } from '@cio/ui/tools';
|
||||
@@ -44,7 +45,7 @@
|
||||
/>
|
||||
{:else if $isFreePlan}
|
||||
<a
|
||||
href={`https://classroomio.com?utm_source=${$currentOrg.siteName}.classroomio.com`}
|
||||
href={`https://classroomio.com?utm_source=${$currentOrg.siteName}.${TENANT_ROOT_DOMAIN}`}
|
||||
target="_blank"
|
||||
class={cn('group fixed right-9 bottom-14 z-50 hover:no-underline', className)}
|
||||
>
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { courseApi } from '$features/course/api';
|
||||
import { isOrgAdmin } from '$lib/utils/store/org';
|
||||
import { isStudentExperience } from '$lib/utils/store/app';
|
||||
import { ROLE } from '@cio/utils/constants';
|
||||
|
||||
interface Props {
|
||||
allowedRoles?: number[];
|
||||
@@ -14,16 +16,21 @@
|
||||
let { allowedRoles = [], onDenied = () => {}, onlyStudent = false, children }: Props = $props();
|
||||
|
||||
const userRole: number | null = $derived.by(() => {
|
||||
// In the student experience everyone is treated as a student, so a teacher
|
||||
// previews exactly what a student sees — no matter their real course role.
|
||||
if ($isStudentExperience) return ROLE.STUDENT;
|
||||
|
||||
const user = courseApi.group.people.find((person) => person.profileId === $profile.id);
|
||||
return user ? Number(user.roleId) : null;
|
||||
});
|
||||
|
||||
const show = $derived.by(() => {
|
||||
if (onlyStudent) {
|
||||
// No org-admin bypass while in the student experience — treat the viewer as a student.
|
||||
if (onlyStudent || $isStudentExperience) {
|
||||
return isAllowed(userRole);
|
||||
} else {
|
||||
return isAllowed(userRole) || !!$isOrgAdmin;
|
||||
}
|
||||
|
||||
return isAllowed(userRole) || !!$isOrgAdmin;
|
||||
});
|
||||
|
||||
function isAllowed(userRole: number | null) {
|
||||
@@ -32,7 +39,8 @@
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!$isOrgAdmin && userRole !== null && !isAllowed(userRole)) {
|
||||
const determined = $isStudentExperience || userRole !== null;
|
||||
if (determined && !show) {
|
||||
onDenied();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -3,10 +3,11 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { basePath, globalStore } from '$lib/utils/store/app';
|
||||
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
|
||||
import RocketIcon from '@lucide/svelte/icons/rocket';
|
||||
import BellPlusIcon from '@lucide/svelte/icons/bell-plus';
|
||||
import BookOpenIcon from '@lucide/svelte/icons/book-open';
|
||||
import BadgeHelpIcon from '@lucide/svelte/icons/badge-help';
|
||||
import LogOutIcon from '@lucide/svelte/icons/log-out';
|
||||
import MessageSquarePlusIcon from '@lucide/svelte/icons/message-square-plus';
|
||||
import * as Sidebar from '@cio/ui/base/sidebar';
|
||||
import { UserAvatar } from '@cio/ui/custom/user-avatar';
|
||||
import { useSidebar } from '@cio/ui/base/sidebar';
|
||||
@@ -16,8 +17,12 @@
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
import { showUserJotWidget } from '$lib/utils/services/userjot';
|
||||
import { ROLE } from '@cio/utils/constants';
|
||||
|
||||
const SUPPORT_EMAIL = 'help@classroomio.com';
|
||||
const DOCS_URL = 'https://classroomio.com/docs';
|
||||
|
||||
const sidebar = useSidebar();
|
||||
|
||||
function getRoleLabel(roleId: number): string {
|
||||
@@ -107,33 +112,31 @@
|
||||
<div class="cursor-pointer space-y-2">
|
||||
{#if !$globalStore.isOrgSite}
|
||||
<div class="space-y-4">
|
||||
<DropdownMenu.Item class="m-0">
|
||||
<a href="https://classroomio.com/roadmap" target="_blank" class="flex w-full items-center gap-2">
|
||||
<DropdownMenu.Item class="m-0" onclick={() => showUserJotWidget('updates')}>
|
||||
<span class="flex w-full items-center gap-2">
|
||||
<BellPlusIcon size={16} />
|
||||
<p class="text-sm">{$t('profileMenu.whats_new')}</p>
|
||||
</span>
|
||||
</DropdownMenu.Item>
|
||||
|
||||
<DropdownMenu.Item class="m-0" onclick={() => showUserJotWidget('feedback')}>
|
||||
<span class="flex w-full items-center gap-2">
|
||||
<MessageSquarePlusIcon size={16} />
|
||||
<p class="text-sm">{$t('profileMenu.feedback')}</p>
|
||||
</span>
|
||||
</DropdownMenu.Item>
|
||||
|
||||
<DropdownMenu.Item class="m-0">
|
||||
<a href={DOCS_URL} target="_blank" rel="noopener noreferrer" class="flex w-full items-center gap-2">
|
||||
<BookOpenIcon size={16} />
|
||||
<p class="text-sm">{$t('profileMenu.documentation')}</p>
|
||||
</a>
|
||||
</DropdownMenu.Item>
|
||||
|
||||
<DropdownMenu.Item class="m-0">
|
||||
<a
|
||||
href="https://classroomio.com/blog/launch-week"
|
||||
target="_blank"
|
||||
class="flex w-full items-center gap-2"
|
||||
>
|
||||
<RocketIcon size={16} />
|
||||
<p class="text-sm">{$t('profileMenu.launch_week')}</p>
|
||||
</a>
|
||||
</DropdownMenu.Item>
|
||||
|
||||
<DropdownMenu.Item class="m-0">
|
||||
<a
|
||||
href="https://classroomio.com/contact"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex h-full w-full items-center justify-start space-x-2 text-start"
|
||||
>
|
||||
<BadgeHelpIcon size={20} />
|
||||
<p class="text-sm dark:text-white">{$t('org_navigation.help')}</p>
|
||||
<a href="mailto:{SUPPORT_EMAIL}" class="flex w-full items-center gap-2">
|
||||
<BadgeHelpIcon size={16} />
|
||||
<p class="text-sm">{$t('profileMenu.need_help')}</p>
|
||||
</a>
|
||||
</DropdownMenu.Item>
|
||||
</div>
|
||||
|
||||
@@ -1,26 +1,29 @@
|
||||
<script module lang="ts">
|
||||
// Module-level singleton: one Promise shared across all mounted instances.
|
||||
// Dynamic imports are already cached by the JS engine after the first load,
|
||||
// but reusing the same Promise avoids redundant microtask chains on remounts.
|
||||
let pending: Promise<typeof import('@cio/ui/custom/editor')> | null = null;
|
||||
|
||||
function loadEditor() {
|
||||
pending ??= import('@cio/ui/custom/editor');
|
||||
return pending;
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { Editor } from '@cio/ui/custom/editor';
|
||||
// Type-only imports are erased at build time — no static TipTap dependency.
|
||||
import type { HTMLContent, TiptapEditor } from '@cio/ui/custom/editor';
|
||||
|
||||
interface Props {
|
||||
placeholder?: string | ((node: any) => string);
|
||||
// Content of the editor
|
||||
content?: HTMLContent;
|
||||
// Whether the toolbar should be visible
|
||||
showToolBar?: boolean;
|
||||
// Whether the editor is editable
|
||||
editable?: boolean;
|
||||
// Whether to enable localStorage persistence
|
||||
enablePersistence?: boolean;
|
||||
// localStorage key for content persistence
|
||||
contentStorageKey?: string;
|
||||
// localStorage key for editable state persistence
|
||||
editableStorageKey?: string;
|
||||
// CSS class for the editor wrapper
|
||||
class?: string;
|
||||
// CSS class for the editor itself
|
||||
editorClass?: string;
|
||||
// Callback functions
|
||||
onChange?: (content: HTMLContent) => void;
|
||||
onReady?: (editor: TiptapEditor) => void;
|
||||
onEditorDestroy?: () => void;
|
||||
@@ -42,17 +45,21 @@
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<Editor
|
||||
{content}
|
||||
{showToolBar}
|
||||
{editable}
|
||||
{enablePersistence}
|
||||
{contentStorageKey}
|
||||
{editableStorageKey}
|
||||
class={className}
|
||||
{editorClass}
|
||||
{placeholder}
|
||||
onContentChange={onChange}
|
||||
onEditorReady={onReady}
|
||||
{onEditorDestroy}
|
||||
/>
|
||||
{#await loadEditor()}
|
||||
<div class="ui:animate-pulse ui:rounded-md ui:bg-muted ui:h-32 ui:w-full" />
|
||||
{:then { Editor }}
|
||||
<Editor
|
||||
{content}
|
||||
{showToolBar}
|
||||
{editable}
|
||||
{enablePersistence}
|
||||
{contentStorageKey}
|
||||
{editableStorageKey}
|
||||
class={className}
|
||||
{editorClass}
|
||||
{placeholder}
|
||||
onContentChange={onChange}
|
||||
onEditorReady={onReady}
|
||||
{onEditorDestroy}
|
||||
/>
|
||||
{/await}
|
||||
|
||||
@@ -46,10 +46,10 @@
|
||||
const MAX_IMAGE_SIZE = 500 * FileDropZone.KILOBYTE;
|
||||
|
||||
async function handleImageClick(img: string) {
|
||||
$handleOpenWidget.open = false;
|
||||
|
||||
onchange?.(img);
|
||||
imageURL = img;
|
||||
|
||||
$handleOpenWidget.open = false;
|
||||
}
|
||||
|
||||
async function handleFilesUpload(files: File[]) {
|
||||
@@ -84,7 +84,7 @@
|
||||
isSearching = true;
|
||||
|
||||
try {
|
||||
unsplashImages = await queryUnsplash(searchQuery || 'rocks');
|
||||
unsplashImages = await queryUnsplash(searchQuery || 'nature landscape architecture');
|
||||
} catch (error) {
|
||||
snackbar.error('snackbar.landing_page_settings.error.fetch_error');
|
||||
console.error('Error fetching images from Unsplash:', error);
|
||||
@@ -102,7 +102,7 @@
|
||||
if (!isOpen) $handleOpenWidget.open = false;
|
||||
}}
|
||||
>
|
||||
<Dialog.Content class="ui:z-300! w-3/5">
|
||||
<Dialog.Content class="ui:z-300! w-[95%] max-w-2xl!">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{$t('course.navItem.landing_page.upload_widget.title')}</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
@@ -142,10 +142,10 @@
|
||||
</Button>
|
||||
</form>
|
||||
{#if unsplashImages && unsplashImages.length > 0}
|
||||
<div class="hide-scrollbar flex max-h-[300px] flex-row flex-wrap items-center gap-2 px-[10px]">
|
||||
<div class="hide-scrollbar grid max-h-[400px] grid-cols-4 gap-3 px-[10px]">
|
||||
{#each unsplashImages as unsplashImages (unsplashImages.id)}
|
||||
<div>
|
||||
<div class="relative h-[130px] w-[195px] overflow-hidden">
|
||||
<div class="relative aspect-[3/2] overflow-hidden">
|
||||
<button onclick={() => handleImageClick(unsplashImages.urls.regular)}>
|
||||
<img
|
||||
src={unsplashImages.urls.regular}
|
||||
@@ -155,7 +155,7 @@
|
||||
</button>
|
||||
</div>
|
||||
{#if unsplashImages.user.name}
|
||||
<p class="mt-1 text-center text-xs font-light text-gray-500">
|
||||
<p class="mt-1 truncate text-center text-xs font-light text-gray-500">
|
||||
By <a
|
||||
href={`https://unsplash.com/@${unsplashImages.user.username}`}
|
||||
target="_blank"
|
||||
|
||||
Reference in New Issue
Block a user