feat: certificates, landing pages, ai tutor, website update, compliance
This commit is contained in:
@@ -1,44 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { Component } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
value: string | number;
|
||||
subtitle?: string;
|
||||
icon: Component;
|
||||
iconColor?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
let { title, value, subtitle = '', icon, iconColor = 'blue', className = '' }: Props = $props();
|
||||
|
||||
let colorClasses = $derived({
|
||||
blue: 'bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400',
|
||||
green: 'bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400',
|
||||
purple: 'bg-purple-50 dark:bg-purple-900/20 text-purple-600 dark:text-purple-400',
|
||||
orange: 'bg-orange-50 dark:bg-orange-900/20 text-orange-600 dark:text-orange-400',
|
||||
red: 'bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400',
|
||||
yellow: 'bg-yellow-50 dark:bg-yellow-900/20 text-yellow-600 dark:text-yellow-400'
|
||||
});
|
||||
|
||||
const SvelteComponent = $derived(icon);
|
||||
</script>
|
||||
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-6 dark:border-neutral-700 dark:bg-neutral-800 {className}">
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="flex h-12 w-12 items-center justify-center rounded-lg {colorClasses[iconColor]}">
|
||||
<SvelteComponent className={colorClasses[iconColor]} />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">{title}</p>
|
||||
<p class="text-2xl font-semibold text-gray-900 dark:text-white">
|
||||
{value}
|
||||
</p>
|
||||
{#if subtitle}
|
||||
<p class="text-xs text-gray-500 capitalize dark:text-gray-500">
|
||||
{subtitle}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
+56
-52
@@ -1,5 +1,9 @@
|
||||
<script lang="ts">
|
||||
import * as Chart from '@cio/ui/base/chart';
|
||||
import { Spinner } from '@cio/ui/base/spinner';
|
||||
import { Empty } from '@cio/ui/custom/empty';
|
||||
import BarChartIcon from '@lucide/svelte/icons/bar-chart-3';
|
||||
import { AnalyticsPanelCard } from '$features/analytics';
|
||||
import type { CourseAnalytics } from '$features/course/utils/types';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
@@ -29,10 +33,9 @@
|
||||
}
|
||||
} satisfies Chart.ChartConfig);
|
||||
|
||||
let hasStudentData = $derived(Boolean(courseAnalytics?.students?.length));
|
||||
const hasStudentData = $derived(Boolean(courseAnalytics?.students?.length));
|
||||
|
||||
// Transform data for progress distribution chart
|
||||
let progressChartData = $derived<DistributionChartData[]>(
|
||||
const progressChartData = $derived<DistributionChartData[]>(
|
||||
courseAnalytics?.students
|
||||
? [
|
||||
{
|
||||
@@ -55,8 +58,7 @@
|
||||
: []
|
||||
);
|
||||
|
||||
// Transform data for grade distribution chart
|
||||
let gradeChartData = $derived<DistributionChartData[]>(
|
||||
const gradeChartData = $derived<DistributionChartData[]>(
|
||||
courseAnalytics?.students
|
||||
? [
|
||||
{
|
||||
@@ -83,7 +85,7 @@
|
||||
: []
|
||||
);
|
||||
|
||||
let progressChartProps = $derived({
|
||||
const progressChartProps = $derived({
|
||||
xAxis: {
|
||||
label: $t('analytics.progress_range')
|
||||
},
|
||||
@@ -92,7 +94,7 @@
|
||||
}
|
||||
});
|
||||
|
||||
let gradeChartProps = $derived({
|
||||
const gradeChartProps = $derived({
|
||||
xAxis: {
|
||||
label: $t('analytics.grade_range')
|
||||
},
|
||||
@@ -101,7 +103,7 @@
|
||||
}
|
||||
});
|
||||
|
||||
let progressSeries = $derived([
|
||||
const progressSeries = $derived([
|
||||
{
|
||||
key: 'students',
|
||||
value: 'value',
|
||||
@@ -110,7 +112,7 @@
|
||||
}
|
||||
]);
|
||||
|
||||
let gradeSeries = $derived([
|
||||
const gradeSeries = $derived([
|
||||
{
|
||||
key: 'students',
|
||||
value: 'value',
|
||||
@@ -118,50 +120,52 @@
|
||||
color: 'var(--color-students)'
|
||||
}
|
||||
]);
|
||||
|
||||
const chartsLoading = $derived(!courseAnalytics);
|
||||
</script>
|
||||
|
||||
<div class="grid grid-cols-1 gap-8 lg:grid-cols-2">
|
||||
<!-- Progress Distribution Chart -->
|
||||
<div class="">
|
||||
<h3 class="mb-6 text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{$t('analytics.student_progress_distribution')}
|
||||
</h3>
|
||||
{#if !courseAnalytics}
|
||||
<div class="flex h-[300px] items-center justify-center">
|
||||
<div class="animate-pulse text-gray-500 dark:text-gray-400">{$t('analytics.loading_chart')}</div>
|
||||
</div>
|
||||
{:else if hasStudentData}
|
||||
<Chart.ChartContainer class="h-[300px] w-full" config={progressChartConfig}>
|
||||
<Chart.BarChart
|
||||
data={progressChartData}
|
||||
x="group"
|
||||
axis="x"
|
||||
props={progressChartProps}
|
||||
series={progressSeries}
|
||||
/>
|
||||
</Chart.ChartContainer>
|
||||
{:else}
|
||||
<div class="flex h-[300px] items-center justify-center text-gray-500 dark:text-gray-400">
|
||||
{$t('analytics.no_progress_data')}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<AnalyticsPanelCard
|
||||
title={$t('analytics.student_progress_distribution')}
|
||||
description={$t('analytics.course_metrics.progress_distribution_card_description')}
|
||||
>
|
||||
{#snippet children()}
|
||||
{#if chartsLoading}
|
||||
<div class="flex h-[280px] items-center justify-center">
|
||||
<Spinner class="ui:text-muted-foreground size-6" />
|
||||
</div>
|
||||
{:else if hasStudentData}
|
||||
<Chart.ChartContainer class="h-[280px] w-full" config={progressChartConfig}>
|
||||
<Chart.BarChart
|
||||
data={progressChartData}
|
||||
x="group"
|
||||
axis="x"
|
||||
props={progressChartProps}
|
||||
series={progressSeries}
|
||||
/>
|
||||
</Chart.ChartContainer>
|
||||
{:else}
|
||||
<Empty icon={BarChartIcon} title={$t('analytics.no_progress_data')} class="h-[280px]" />
|
||||
{/if}
|
||||
{/snippet}
|
||||
</AnalyticsPanelCard>
|
||||
|
||||
<!-- Grade Distribution Chart -->
|
||||
<div class="">
|
||||
<h3 class="mb-6 text-lg font-semibold text-gray-900 dark:text-white">{$t('analytics.grade_distribution')}</h3>
|
||||
{#if !courseAnalytics}
|
||||
<div class="flex h-[300px] items-center justify-center">
|
||||
<div class="animate-pulse text-gray-500 dark:text-gray-400">{$t('analytics.loading_chart')}</div>
|
||||
</div>
|
||||
{:else if hasStudentData}
|
||||
<Chart.ChartContainer class="h-[300px] w-full" config={gradeChartConfig}>
|
||||
<Chart.BarChart data={gradeChartData} x="group" axis="x" props={gradeChartProps} series={gradeSeries} />
|
||||
</Chart.ChartContainer>
|
||||
{:else}
|
||||
<div class="flex h-[300px] items-center justify-center text-gray-500 dark:text-gray-400">
|
||||
{$t('analytics.no_grade_data')}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<AnalyticsPanelCard
|
||||
title={$t('analytics.grade_distribution')}
|
||||
description={$t('analytics.course_metrics.grade_distribution_card_description')}
|
||||
>
|
||||
{#snippet children()}
|
||||
{#if chartsLoading}
|
||||
<div class="flex h-[280px] items-center justify-center">
|
||||
<Spinner class="ui:text-muted-foreground size-6" />
|
||||
</div>
|
||||
{:else if hasStudentData}
|
||||
<Chart.ChartContainer class="h-[280px] w-full" config={gradeChartConfig}>
|
||||
<Chart.BarChart data={gradeChartData} x="group" axis="x" props={gradeChartProps} series={gradeSeries} />
|
||||
</Chart.ChartContainer>
|
||||
{:else}
|
||||
<Empty icon={BarChartIcon} title={$t('analytics.no_grade_data')} class="h-[280px]" />
|
||||
{/if}
|
||||
{/snippet}
|
||||
</AnalyticsPanelCard>
|
||||
</div>
|
||||
|
||||
+46
-53
@@ -2,61 +2,54 @@
|
||||
import { Skeleton } from '@cio/ui/base/skeleton';
|
||||
</script>
|
||||
|
||||
<div class="min-h-screen">
|
||||
<div class="px-4 py-8">
|
||||
<!-- Course Overview Cards Skeleton -->
|
||||
<div class="mb-12">
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
{#each Array(4) as _, i (i)}
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-6 dark:border-neutral-700 dark:bg-neutral-800">
|
||||
<div class="flex items-center space-x-4">
|
||||
<Skeleton class="h-[48px] w-[48px]" />
|
||||
<div class="flex-1">
|
||||
<Skeleton class="h-[48px] w-[48px]" />
|
||||
<Skeleton class="mb-2 h-[16px] w-[80px]" />
|
||||
<Skeleton class="mb-1 h-[24px] w-[40px]" />
|
||||
<Skeleton class="h-[12px] w-[60px]" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-6">
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
{#each Array(4) as _, i (i)}
|
||||
<div class="ui:bg-card ui:border-border overflow-hidden rounded-xl border shadow-sm">
|
||||
<div class="ui:border-border ui:space-y-2 border-b px-6 py-5">
|
||||
<Skeleton class="h-3 w-24" />
|
||||
</div>
|
||||
<div class="px-6 py-6">
|
||||
<Skeleton class="h-8 w-20" />
|
||||
<Skeleton class="mt-4 h-3 w-full max-w-[180px]" />
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
{#each Array(2) as _, chartIndex (chartIndex)}
|
||||
<div class="ui:bg-card ui:border-border overflow-hidden rounded-xl border shadow-sm">
|
||||
<div class="ui:border-border ui:space-y-2 border-b px-6 py-5">
|
||||
<Skeleton class="h-5 w-48" />
|
||||
<Skeleton class="h-4 w-full max-w-md" />
|
||||
</div>
|
||||
<div class="px-6 py-6">
|
||||
<Skeleton class="h-[280px] w-full" />
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="ui:bg-card ui:border-border overflow-hidden rounded-xl border shadow-sm">
|
||||
<div class="ui:border-border border-b px-6 py-5">
|
||||
<Skeleton class="h-5 w-56" />
|
||||
<Skeleton class="mt-2 h-4 max-w-xl" />
|
||||
</div>
|
||||
<div class="ui:divide-border divide-y px-6 py-2">
|
||||
{#each Array(5) as _, i (i)}
|
||||
<div class="flex flex-wrap items-center gap-4 py-4">
|
||||
<Skeleton class="size-8 rounded-full" />
|
||||
<div class="min-w-0 flex-1 space-y-2">
|
||||
<Skeleton class="h-4 w-[150px]" />
|
||||
<Skeleton class="h-3 w-[200px]" />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Charts Section Skeleton -->
|
||||
<div class="mb-12">
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-6 dark:border-neutral-700 dark:bg-neutral-800">
|
||||
<Skeleton class="mb-6 h-[24px] w-[200px]" />
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<Skeleton class="h-[300px] w-full" />
|
||||
<Skeleton class="h-[300px] w-full" />
|
||||
<Skeleton class="h-4 w-[80px]" />
|
||||
<Skeleton class="h-4 w-[100px]" />
|
||||
<Skeleton class="h-6 w-[48px]" />
|
||||
<Skeleton class="h-8 w-[90px]" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Students Table Skeleton -->
|
||||
<div class="rounded-lg border border-gray-200 bg-white dark:border-neutral-700 dark:bg-neutral-800">
|
||||
<div class="border-b border-gray-200 px-6 py-4 dark:border-neutral-700">
|
||||
<Skeleton class="mb-6 h-[24px] w-[200px]" />
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<div class="space-y-4">
|
||||
{#each Array(5) as _, i (i)}
|
||||
<div class="flex items-center space-x-4">
|
||||
<Skeleton class="h-[32px] w-[32px]" />
|
||||
<div class="flex-1">
|
||||
<Skeleton class="mb-1 h-[16px] w-[150px]" />
|
||||
<Skeleton class="h-[12px] w-[200px]" />
|
||||
</div>
|
||||
<Skeleton class="h-[16px] w-[80px]" />
|
||||
<Skeleton class="h-[16px] w-[60px]" />
|
||||
<Skeleton class="h-[20px] w-[40px]" />
|
||||
<Skeleton class="h-[12px] w-[80px]" />
|
||||
<Skeleton class="h-[32px] w-[100px]" />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
<script lang="ts">
|
||||
import BookOpenIcon from '@lucide/svelte/icons/book-open';
|
||||
import ListChecksIcon from '@lucide/svelte/icons/list-checks';
|
||||
import PercentIcon from '@lucide/svelte/icons/percent';
|
||||
import UsersIcon from '@lucide/svelte/icons/users';
|
||||
import { KpiCard } from '$features/analytics';
|
||||
import type { CourseAnalytics } from '$features/course/utils/types';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
interface Props {
|
||||
data: CourseAnalytics;
|
||||
}
|
||||
|
||||
let { data }: Props = $props();
|
||||
|
||||
const tutorsCount = $derived(data.totalTutors ?? 0);
|
||||
const exerciseTotal = $derived(data.totalExercises ?? 0);
|
||||
const avgGrade = $derived(data.averageGrade ?? 0);
|
||||
const lessonRate = $derived(data.lessonCompletionRate ?? 0);
|
||||
const exerciseRate = $derived(data.exerciseCompletionRate ?? 0);
|
||||
</script>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<KpiCard
|
||||
title={$t('analytics.students')}
|
||||
value={(data.totalStudents ?? 0).toLocaleString()}
|
||||
description={$t('analytics.course_metrics.kpi_team_tutors', { count: tutorsCount })}
|
||||
icon={UsersIcon}
|
||||
accent="primary"
|
||||
/>
|
||||
<KpiCard
|
||||
title={$t('analytics.lessons')}
|
||||
value={(data.totalLessons ?? 0).toLocaleString()}
|
||||
description={$t('analytics.course_metrics.kpi_lessons_exercises', { count: exerciseTotal })}
|
||||
icon={BookOpenIcon}
|
||||
accent="warning"
|
||||
/>
|
||||
<KpiCard
|
||||
title={$t('analytics.progress')}
|
||||
value={`${lessonRate}%`}
|
||||
description={$t('analytics.course_metrics.kpi_progress_grade', { grade: avgGrade })}
|
||||
icon={PercentIcon}
|
||||
accent="success"
|
||||
/>
|
||||
<KpiCard
|
||||
title={$t('analytics.exercises')}
|
||||
value={`${exerciseRate}%`}
|
||||
description={$t('analytics.completion_rate')}
|
||||
icon={ListChecksIcon}
|
||||
accent="primary"
|
||||
/>
|
||||
</div>
|
||||
-22
@@ -1,22 +0,0 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
let { className = '' }: Props = $props();
|
||||
</script>
|
||||
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
class="h-6 w-6 {className}"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M3 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
@@ -1,23 +0,0 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
let { className = '' }: Props = $props();
|
||||
</script>
|
||||
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
class="h-6 w-6 {className}"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M9 2a1 1 0 000 2h2a1 1 0 100-2H9z" />
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M4 5a2 2 0 012-2h8a2 2 0 012 2v6a2 2 0 01-2 2H6a2 2 0 01-2-2V5zm3 1a1 1 0 000 2h.01a1 1 0 100-2H7zm3 0a1 1 0 000 2h3a1 1 0 100-2h-3zm-3 4a1 1 0 100 2h.01a1 1 0 100-2H7zm3 0a1 1 0 100 2h3a1 1 0 100-2h-3z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
-22
@@ -1,22 +0,0 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
let { className = '' }: Props = $props();
|
||||
</script>
|
||||
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
class="h-6 w-6 {className}"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M6.267 3.455a3.066 3.066 0 001.745-.723 3.066 3.066 0 013.976 0 3.066 3.066 0 001.745.723 3.066 3.066 0 012.812 2.812c.051.643.304 1.254.723 1.745a3.066 3.066 0 010 3.976 3.066 3.066 0 00-.723 1.745 3.066 3.066 0 01-2.812 2.812 3.066 3.066 0 00-1.745.723 3.066 3.066 0 01-3.976 0 3.066 3.066 0 00-1.745-.723 3.066 3.066 0 01-2.812-2.812 3.066 3.066 0 00-.723-1.745 3.066 3.066 0 010-3.976 3.066 3.066 0 00.723-1.745 3.066 3.066 0 012.812-2.812zm7.44 5.252a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
-18
@@ -1,18 +0,0 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
let { className = '' }: Props = $props();
|
||||
</script>
|
||||
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
class="h-6 w-6 {className}"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M13 6a3 3 0 11-6 0 3 3 0 016 0zM18 8a2 2 0 11-4 0 2 2 0 014 0zM14 15a4 4 0 00-8 0v3h8v-3z" />
|
||||
</svg>
|
||||
@@ -59,11 +59,11 @@
|
||||
/>
|
||||
{:else}
|
||||
<!-- Responsive Table -->
|
||||
<div class="relative h-[300px] overflow-auto rounded-md border">
|
||||
<div class="ui:border-border relative max-h-[min(480px,70vh)] overflow-auto rounded-md border">
|
||||
<Table.Root class="min-w-[800px]">
|
||||
<Table.Header class="sticky top-0 z-10 bg-white dark:bg-neutral-800">
|
||||
<Table.Header class="ui:bg-muted/80 sticky top-0 z-10 backdrop-blur-sm">
|
||||
<Table.Row>
|
||||
<Table.Head class="sticky top-0 left-0 z-20 min-w-[200px] bg-white px-4 py-3 dark:bg-neutral-800">
|
||||
<Table.Head class="ui:bg-muted/95 sticky top-0 left-0 z-20 min-w-[200px] px-4 py-3 backdrop-blur-sm">
|
||||
{$t('analytics.student_name')}
|
||||
</Table.Head>
|
||||
<Table.Head class="min-w-[150px] px-4 py-3">{$t('analytics.lessons_completed')}</Table.Head>
|
||||
@@ -77,7 +77,7 @@
|
||||
{#each paginatedStudents as student (student.id)}
|
||||
<Table.Row class="group h-[100px]">
|
||||
<Table.Cell
|
||||
class="sticky left-0 z-10 min-w-[200px] bg-white px-4 py-3 transition-colors group-hover:bg-gray-50 dark:bg-neutral-800 dark:group-hover:bg-gray-700"
|
||||
class="ui:bg-card group-hover:ui:bg-muted/50 sticky left-0 z-10 min-w-[200px] px-4 py-3 transition-colors"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<UserAvatar
|
||||
@@ -86,10 +86,10 @@
|
||||
class="size-8"
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate font-medium text-gray-900 dark:text-white">
|
||||
<p class="ui:text-foreground truncate font-medium">
|
||||
{student.profile.fullname}
|
||||
</p>
|
||||
<p class="truncate text-sm text-gray-500 dark:text-gray-400">
|
||||
<p class="ui:text-muted-foreground truncate text-sm">
|
||||
{student.profile.email}
|
||||
</p>
|
||||
</div>
|
||||
@@ -97,18 +97,18 @@
|
||||
</Table.Cell>
|
||||
<Table.Cell class="min-w-[150px] px-4 py-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
<span class="ui:text-foreground text-sm font-medium">
|
||||
{student.lessonsCompleted}/{student.totalLessons}
|
||||
</span>
|
||||
<div class="w-20 flex-shrink-0">
|
||||
<div class="h-2 rounded-full bg-gray-200 dark:bg-neutral-700">
|
||||
<div class="ui:bg-muted h-2 rounded-full">
|
||||
<div class="h-2 rounded-full bg-blue-500" style="width: {student.progressPercentage}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Table.Cell>
|
||||
<Table.Cell class="min-w-[140px] px-4 py-3">
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
<span class="ui:text-foreground text-sm font-medium">
|
||||
{student.exercisesSubmitted} out of {student.totalExercises}
|
||||
</span>
|
||||
</Table.Cell>
|
||||
@@ -124,7 +124,7 @@
|
||||
</Badge>
|
||||
</Table.Cell>
|
||||
<Table.Cell class="min-w-[120px] px-4 py-3">
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">
|
||||
<span class="ui:text-muted-foreground text-sm">
|
||||
{student.lastSeen}
|
||||
</span>
|
||||
</Table.Cell>
|
||||
@@ -141,8 +141,8 @@
|
||||
|
||||
<!-- Pagination Controls -->
|
||||
{#if students.length > pageSize}
|
||||
<div class="mt-4 flex items-center justify-between border-t border-gray-200 px-4 py-3">
|
||||
<div class="text-sm text-gray-700 dark:text-gray-300">
|
||||
<div class="ui:border-border mt-4 flex items-center justify-between border-t px-4 py-3">
|
||||
<div class="ui:text-muted-foreground text-sm">
|
||||
{$t('analytics.showing_students', {
|
||||
start: startItem,
|
||||
end: endItem,
|
||||
|
||||
+98
-114
@@ -1,134 +1,118 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import { Label } from '@cio/ui/base/label';
|
||||
import * as RadioGroup from '@cio/ui/base/radio-group';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { Textarea } from '@cio/ui/base/textarea';
|
||||
import * as Field from '@cio/ui/base/field';
|
||||
import { Badge } from '@cio/ui/base/badge';
|
||||
import { Certificate } from '@cio/ui';
|
||||
import * as Card from '@cio/ui/base/card';
|
||||
import ArrowRightIcon from '@lucide/svelte/icons/arrow-right';
|
||||
import ZapIcon from '@lucide/svelte/icons/zap';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
import { courseApi } from '$features/course/api';
|
||||
import { CERTIFICATE_THEME_IDS, type CertificateThemeId } from '$features/course/utils/constants';
|
||||
import { parseCertificateThemeId } from '$features/course/utils/certificate-utils';
|
||||
import { currentOrg, isFreePlan } from '$lib/utils/store/org';
|
||||
import Plain from './templates/plain.svelte';
|
||||
import Professional from './templates/professional.svelte';
|
||||
import BlueBadgePattern from './templates/blue-badge-pattern.svelte';
|
||||
import PurpleBadgePattern from './templates/purple-badge-pattern.svelte';
|
||||
import BlueProfessionalBadge from './templates/blue-professional-badge.svelte';
|
||||
import PurpleProfessionalBadge from './templates/purple-professional-badge.svelte';
|
||||
import {
|
||||
CERTIFICATE_TEMPLATES,
|
||||
DEFAULT_CERTIFICATE_DESIGN,
|
||||
type CertificateDesign,
|
||||
resolveTemplateId
|
||||
} from '@cio/certificates';
|
||||
|
||||
type Props = {
|
||||
errors: Record<string, string>;
|
||||
errors?: Record<string, string>;
|
||||
};
|
||||
|
||||
let { errors }: Props = $props();
|
||||
let { errors: _errors }: Props = $props();
|
||||
|
||||
const studentNamePlaceholder = 'Name of student';
|
||||
const design: CertificateDesign = $derived.by(() => {
|
||||
const certificate = courseApi.course?.certificate;
|
||||
const stored = certificate?.design as Partial<CertificateDesign> | undefined;
|
||||
const legacyTheme = certificate?.theme as string | undefined;
|
||||
|
||||
const certificateTheme = $derived(parseCertificateThemeId(courseApi.course?.certificate?.theme));
|
||||
return {
|
||||
templateId: resolveTemplateId(stored?.templateId ?? legacyTheme),
|
||||
accentColor: stored?.accentColor ?? DEFAULT_CERTIFICATE_DESIGN.accentColor,
|
||||
subtitle: stored?.subtitle ?? DEFAULT_CERTIFICATE_DESIGN.subtitle,
|
||||
descriptionOverride: stored?.descriptionOverride,
|
||||
signatories: [
|
||||
{
|
||||
name: stored?.signatories?.[0]?.name ?? DEFAULT_CERTIFICATE_DESIGN.signatories[0].name,
|
||||
role: stored?.signatories?.[0]?.role ?? DEFAULT_CERTIFICATE_DESIGN.signatories[0].role
|
||||
},
|
||||
{
|
||||
name: stored?.signatories?.[1]?.name ?? DEFAULT_CERTIFICATE_DESIGN.signatories[1].name,
|
||||
role: stored?.signatories?.[1]?.role ?? DEFAULT_CERTIFICATE_DESIGN.signatories[1].role
|
||||
}
|
||||
],
|
||||
idFormat: stored?.idFormat ?? DEFAULT_CERTIFICATE_DESIGN.idFormat
|
||||
};
|
||||
});
|
||||
|
||||
const helperText = $derived(
|
||||
`${courseApi.course?.description?.length || 0}/200 ${$t('course.navItem.certificates.characters')}`
|
||||
const previewData = $derived({
|
||||
recipientName: 'Eleanor Vance',
|
||||
courseName: courseApi.course?.title ?? 'Course Title',
|
||||
courseDescription: design.descriptionOverride || courseApi.course?.description || '',
|
||||
orgName: $currentOrg.name || 'Organization',
|
||||
orgLogoUrl: $currentOrg.avatarUrl || undefined,
|
||||
date: new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: '2-digit' }),
|
||||
certificateId: (design.idFormat ?? 'N° {seq}').replace('{seq}', '0247')
|
||||
});
|
||||
|
||||
const templateLabel = $derived(
|
||||
CERTIFICATE_TEMPLATES.find((tpl) => tpl.id === design.templateId)?.label ?? design.templateId
|
||||
);
|
||||
|
||||
function onDescriptionInput(e: Event) {
|
||||
if (!courseApi.course) return;
|
||||
courseApi.course.description = (e.currentTarget as HTMLTextAreaElement).value;
|
||||
}
|
||||
|
||||
function goToOrgSettings() {
|
||||
goto(
|
||||
resolve('/org/[slug]/settings', {
|
||||
slug: $currentOrg.siteName || ''
|
||||
})
|
||||
);
|
||||
}
|
||||
const courseId = $derived(courseApi.course?.id ?? '');
|
||||
const editorHref = $derived(courseId ? resolve('/courses/[id]/certificates/editor', { id: courseId }) : '#');
|
||||
</script>
|
||||
|
||||
<div class="mb-3 flex h-4/5 w-full flex-1 flex-col justify-between gap-3 lg:flex-row">
|
||||
<section class="h-full w-full lg:w-2/5">
|
||||
<Field.Group class="w-full max-w-md! px-2">
|
||||
<Field.Set>
|
||||
<Field.Legend>{$t('course.navItem.certificates.certificate_settings')}</Field.Legend>
|
||||
<div class="grid w-full gap-4 lg:grid-cols-[minmax(0,1fr)_320px]">
|
||||
<div class="aspect-[1.4/1] w-full">
|
||||
<Certificate.Preview {design} data={previewData} zoom="fit" />
|
||||
</div>
|
||||
|
||||
<Field.Group>
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('course.navItem.certificates.theme')}</Field.Label>
|
||||
<RadioGroup.Root
|
||||
value={certificateTheme}
|
||||
onValueChange={(value) => {
|
||||
if (!courseApi.course) return;
|
||||
|
||||
courseApi.course.certificate = {
|
||||
...(courseApi.course.certificate ?? {}),
|
||||
theme: value as CertificateThemeId
|
||||
};
|
||||
}}
|
||||
disabled={$isFreePlan}
|
||||
class="mb-6"
|
||||
>
|
||||
<div class="flex flex-wrap justify-between gap-y-5">
|
||||
{#each CERTIFICATE_THEME_IDS as theme (theme)}
|
||||
<div class="mr-3 flex items-start space-x-2">
|
||||
<RadioGroup.Item value={theme} id={theme} />
|
||||
<Label for={theme} class="cursor-pointer">
|
||||
<img src={`/images/certificate_theme_${theme}.png`} alt="" class="h-[82px] w-[110px]" />
|
||||
</Label>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</RadioGroup.Root>
|
||||
</Field.Field>
|
||||
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('course.navItem.certificates.logo')}</Field.Label>
|
||||
<Field.Description>
|
||||
{$t('course.navItem.certificates.to_update')}
|
||||
<strong class="font-semibold">{$t('course.navItem.certificates.settings')}</strong>
|
||||
{$t('course.navItem.certificates.and_upload')}
|
||||
</Field.Description>
|
||||
<Button variant="outline" class="mt-3" onclick={goToOrgSettings}>
|
||||
{$t('course.navItem.certificates.goto_settings')}
|
||||
</Button>
|
||||
</Field.Field>
|
||||
|
||||
<Field.Field>
|
||||
<Field.Label for="cert-design-description">{$t('course.navItem.certificates.description')}</Field.Label>
|
||||
<Textarea
|
||||
id="cert-design-description"
|
||||
rows={6}
|
||||
class="w-full"
|
||||
placeholder={$t('course.navItem.certificates.placeholder')}
|
||||
value={courseApi.course?.description ?? ''}
|
||||
oninput={onDescriptionInput}
|
||||
disabled={$isFreePlan}
|
||||
/>
|
||||
<Field.Description>{helperText}</Field.Description>
|
||||
{#if errors.description}
|
||||
<Field.Error>{errors.description}</Field.Error>
|
||||
{/if}
|
||||
</Field.Field>
|
||||
</Field.Group>
|
||||
</Field.Set>
|
||||
</Field.Group>
|
||||
</section>
|
||||
<section class="flex w-full rounded-md lg:w-3/5">
|
||||
<div class="w-[95%]">
|
||||
{#if certificateTheme === 'professional'}
|
||||
<Professional studentName={studentNamePlaceholder} />
|
||||
{:else if certificateTheme === 'plain'}
|
||||
<Plain studentName={studentNamePlaceholder} />
|
||||
{:else if certificateTheme === 'purpleProfessionalBadge'}
|
||||
<PurpleProfessionalBadge studentName={studentNamePlaceholder} />
|
||||
{:else if certificateTheme === 'blueProfessionalBadge'}
|
||||
<BlueProfessionalBadge studentName={studentNamePlaceholder} />
|
||||
{:else if certificateTheme === 'purpleBadgePattern'}
|
||||
<PurpleBadgePattern studentName={studentNamePlaceholder} />
|
||||
{:else if certificateTheme === 'blueBadgePattern'}
|
||||
<BlueBadgePattern studentName={studentNamePlaceholder} />
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
<div class="flex flex-col gap-3">
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title class="text-base">{$t('course.navItem.certificates.editor.summary_title')}</Card.Title>
|
||||
<Card.Description>{$t('course.navItem.certificates.editor.summary_subtitle')}</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content class="space-y-3 pb-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="ui:text-muted-foreground text-xs tracking-wider uppercase">
|
||||
{$t('course.navItem.certificates.editor.field_template')}
|
||||
</span>
|
||||
<Badge variant="secondary" class="text-[10px] uppercase">{templateLabel}</Badge>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="ui:text-muted-foreground text-xs tracking-wider uppercase">
|
||||
{$t('course.navItem.certificates.editor.field_accent')}
|
||||
</span>
|
||||
<span class="ui:border-border inline-flex items-center gap-2 rounded-full border px-2 py-0.5 text-xs">
|
||||
<span class="size-3.5 rounded-full" style:background-color={design.accentColor} aria-hidden="true"></span>
|
||||
{design.accentColor}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<span class="ui:text-muted-foreground text-xs tracking-wider uppercase">
|
||||
{$t('course.navItem.certificates.editor.field_signatories')}
|
||||
</span>
|
||||
<div class="text-right text-xs">
|
||||
<div class="font-medium">{design.signatories[0].name}</div>
|
||||
<div class="ui:text-muted-foreground">{design.signatories[0].role}</div>
|
||||
<div class="mt-1 font-medium">{design.signatories[1].name}</div>
|
||||
<div class="ui:text-muted-foreground">{design.signatories[1].role}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card.Content>
|
||||
<Card.Footer>
|
||||
<Button class="w-full justify-center" disabled={$isFreePlan || !courseId} href={editorHref}>
|
||||
{#if $isFreePlan}
|
||||
<ZapIcon class="size-4" />
|
||||
{/if}
|
||||
{$t('course.navItem.certificates.editor.customize_design')}
|
||||
<ArrowRightIcon class="size-4" />
|
||||
</Button>
|
||||
</Card.Footer>
|
||||
</Card.Root>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { Badge } from '@cio/ui/base/badge';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import ArrowLeftIcon from '@lucide/svelte/icons/arrow-left';
|
||||
|
||||
interface Props {
|
||||
courseId: string;
|
||||
courseTitle: string;
|
||||
templateLabel: string;
|
||||
isDirty: boolean;
|
||||
isSaving: boolean;
|
||||
isFreePlan: boolean;
|
||||
onSave: () => void;
|
||||
onDiscard: () => void;
|
||||
}
|
||||
|
||||
let { courseId, courseTitle, templateLabel, isDirty, isSaving, isFreePlan, onSave, onDiscard }: Props = $props();
|
||||
</script>
|
||||
|
||||
<header
|
||||
class="ui:border-border ui:bg-background ui:text-foreground flex shrink-0 items-center justify-between gap-3 border-b px-3 py-3"
|
||||
>
|
||||
<div class="flex min-w-0 flex-1 items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
href={resolve('/courses/[id]/certificates', { id: courseId })}
|
||||
class="ui:size-8 shrink-0"
|
||||
aria-label={$t('course.navItem.certificates.editor.back')}
|
||||
>
|
||||
<ArrowLeftIcon class="size-4" />
|
||||
</Button>
|
||||
|
||||
<div class="flex min-w-0 flex-col gap-1 sm:flex-row sm:items-center sm:gap-2">
|
||||
<h1 class="truncate text-sm tracking-tight sm:text-base">
|
||||
{courseTitle}
|
||||
</h1>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-1">
|
||||
<Badge variant="secondary" class="text-[10px] uppercase">
|
||||
{templateLabel}
|
||||
</Badge>
|
||||
{#if isDirty}
|
||||
<Badge variant="outline" class="text-[10px]">
|
||||
{$t('course.navItem.certificates.editor.unsaved')}
|
||||
</Badge>
|
||||
{/if}
|
||||
{#if isFreePlan}
|
||||
<Badge variant="outline" class="text-[10px]">
|
||||
{$t('course.navItem.certificates.editor.free_plan')}
|
||||
</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex shrink-0 items-center gap-1.5">
|
||||
{#if isDirty}
|
||||
<Button variant="ghost" size="sm" type="button" onclick={onDiscard} class="hidden sm:inline-flex">
|
||||
{$t('course.navItem.certificates.editor.discard')}
|
||||
</Button>
|
||||
{/if}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
type="button"
|
||||
disabled={!isDirty || isSaving}
|
||||
loading={isSaving}
|
||||
onclick={onSave}
|
||||
>
|
||||
{$t('course.navItem.certificates.editor.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
+181
@@ -0,0 +1,181 @@
|
||||
<script lang="ts">
|
||||
import { CERTIFICATE_TEMPLATES, type CertificateTemplateId } from '@cio/certificates';
|
||||
import { Certificate } from '@cio/ui';
|
||||
import { IconButton } from '@cio/ui/custom/icon-button';
|
||||
import LayersIcon from '@lucide/svelte/icons/layers';
|
||||
import TypeIcon from '@lucide/svelte/icons/type';
|
||||
import PaletteIcon from '@lucide/svelte/icons/palette';
|
||||
import DownloadIcon from '@lucide/svelte/icons/download';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
import { courseApi } from '$features/course/api';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
import { isFreePlan } from '$lib/utils/store/org';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import CertificateEditorHeader from './certificate-editor-header.svelte';
|
||||
import TemplatesPanel from './panels/templates-panel.svelte';
|
||||
import ContentPanel from './panels/content-panel.svelte';
|
||||
import ColorsPanel from './panels/colors-panel.svelte';
|
||||
import ExportPanel from './panels/export-panel.svelte';
|
||||
import { certificateEditorStore, type CertificateEditorPanel } from './store/certificate-editor.store.svelte';
|
||||
|
||||
let { courseId }: { courseId: string } = $props();
|
||||
|
||||
const store = certificateEditorStore;
|
||||
|
||||
$effect(() => {
|
||||
if (!courseApi.course?.id || courseApi.course.id !== courseId) return;
|
||||
store.syncFromCourse(courseId);
|
||||
});
|
||||
|
||||
const courseTitle = $derived(courseApi.course?.title ?? '');
|
||||
|
||||
const activeTemplateMeta = $derived(
|
||||
CERTIFICATE_TEMPLATES.find((tpl) => tpl.id === store.draft.templateId) ?? CERTIFICATE_TEMPLATES[0]
|
||||
);
|
||||
|
||||
const previewDesign = $derived(store.toDesign());
|
||||
|
||||
const sampleRenderData = $derived({
|
||||
recipientName: $profile.fullname || $t('course.navItem.certificates.editor.sample_recipient'),
|
||||
courseName: courseTitle || $t('course.navItem.certificates.editor.sample_course'),
|
||||
courseDescription:
|
||||
store.draft.descriptionOverride ||
|
||||
courseApi.course?.description ||
|
||||
$t('course.navItem.certificates.editor.sample_description'),
|
||||
orgName: $currentOrg.name || $t('course.navItem.certificates.editor.sample_org'),
|
||||
orgLogoUrl: $currentOrg.avatarUrl || undefined,
|
||||
date: new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: '2-digit' }),
|
||||
certificateId: (store.draft.idFormat || 'N° {seq}').replace('{seq}', '0247')
|
||||
});
|
||||
|
||||
function navVariant(panel: CertificateEditorPanel) {
|
||||
return store.activePanel === panel ? ('default' as const) : ('ghost' as const);
|
||||
}
|
||||
|
||||
function setActive(panel: CertificateEditorPanel) {
|
||||
store.activePanel = panel;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if courseApi.course?.id === courseId}
|
||||
<div class="ui:bg-background ui:text-foreground flex h-dvh flex-col">
|
||||
<CertificateEditorHeader
|
||||
{courseId}
|
||||
{courseTitle}
|
||||
templateLabel={activeTemplateMeta.label}
|
||||
isDirty={store.isDirty}
|
||||
isSaving={store.isSaving}
|
||||
isFreePlan={$isFreePlan}
|
||||
onSave={() => store.save()}
|
||||
onDiscard={() => store.reset()}
|
||||
/>
|
||||
|
||||
<div class="flex min-h-0 flex-1">
|
||||
<nav
|
||||
class="ui:border-border ui:bg-secondary flex w-14 shrink-0 flex-col items-center gap-1.5 border-r py-3"
|
||||
aria-label={$t('course.navItem.certificates.editor.sections')}
|
||||
>
|
||||
<IconButton
|
||||
type="button"
|
||||
variant={navVariant('templates')}
|
||||
tooltip={$t('course.navItem.certificates.editor.panel_templates')}
|
||||
tooltipSide="right"
|
||||
aria-label={$t('course.navItem.certificates.editor.panel_templates')}
|
||||
aria-current={store.activePanel === 'templates' ? 'page' : undefined}
|
||||
onclick={() => setActive('templates')}
|
||||
>
|
||||
<LayersIcon class="size-4" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
type="button"
|
||||
variant={navVariant('content')}
|
||||
tooltip={$t('course.navItem.certificates.editor.panel_content')}
|
||||
tooltipSide="right"
|
||||
aria-label={$t('course.navItem.certificates.editor.panel_content')}
|
||||
aria-current={store.activePanel === 'content' ? 'page' : undefined}
|
||||
onclick={() => setActive('content')}
|
||||
>
|
||||
<TypeIcon class="size-4" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
type="button"
|
||||
variant={navVariant('colors')}
|
||||
tooltip={$t('course.navItem.certificates.editor.panel_colors')}
|
||||
tooltipSide="right"
|
||||
aria-label={$t('course.navItem.certificates.editor.panel_colors')}
|
||||
aria-current={store.activePanel === 'colors' ? 'page' : undefined}
|
||||
onclick={() => setActive('colors')}
|
||||
>
|
||||
<PaletteIcon class="size-4" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
type="button"
|
||||
variant={navVariant('export')}
|
||||
tooltip={$t('course.navItem.certificates.editor.panel_export')}
|
||||
tooltipSide="right"
|
||||
aria-label={$t('course.navItem.certificates.editor.panel_export')}
|
||||
aria-current={store.activePanel === 'export' ? 'page' : undefined}
|
||||
onclick={() => setActive('export')}
|
||||
>
|
||||
<DownloadIcon class="size-4" />
|
||||
</IconButton>
|
||||
</nav>
|
||||
|
||||
<aside class="ui:border-border ui:bg-card flex w-[min(100%,380px)] shrink-0 flex-col border-r">
|
||||
<div class="ui:border-border border-b px-5 py-4">
|
||||
{#if store.activePanel === 'templates'}
|
||||
<h2 class="text-sm font-semibold">{$t('course.navItem.certificates.editor.panel_templates')}</h2>
|
||||
<p class="ui:text-muted-foreground mt-1 text-xs">
|
||||
{$t('course.navItem.certificates.editor.panel_templates_subtitle')}
|
||||
</p>
|
||||
{:else if store.activePanel === 'content'}
|
||||
<h2 class="text-sm font-semibold">{$t('course.navItem.certificates.editor.panel_content')}</h2>
|
||||
<p class="ui:text-muted-foreground mt-1 text-xs">
|
||||
{$t('course.navItem.certificates.editor.panel_content_subtitle')}
|
||||
</p>
|
||||
{:else if store.activePanel === 'colors'}
|
||||
<h2 class="text-sm font-semibold">{$t('course.navItem.certificates.editor.panel_colors')}</h2>
|
||||
<p class="ui:text-muted-foreground mt-1 text-xs">
|
||||
{$t('course.navItem.certificates.editor.panel_colors_subtitle')}
|
||||
</p>
|
||||
{:else}
|
||||
<h2 class="text-sm font-semibold">{$t('course.navItem.certificates.editor.panel_export')}</h2>
|
||||
<p class="ui:text-muted-foreground mt-1 text-xs">
|
||||
{$t('course.navItem.certificates.editor.panel_export_subtitle')}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="min-h-0 flex-1 overflow-y-auto px-5 py-4">
|
||||
{#if store.activePanel === 'templates'}
|
||||
<TemplatesPanel
|
||||
value={store.draft.templateId}
|
||||
disabled={$isFreePlan}
|
||||
onSelect={(id: CertificateTemplateId) => store.setTemplate(id)}
|
||||
/>
|
||||
{:else if store.activePanel === 'content'}
|
||||
<ContentPanel disabled={$isFreePlan} />
|
||||
{:else if store.activePanel === 'colors'}
|
||||
<ColorsPanel
|
||||
value={store.draft.accentColor}
|
||||
disabled={$isFreePlan}
|
||||
onSelect={(color) => store.setAccent(color)}
|
||||
/>
|
||||
{:else}
|
||||
<ExportPanel {courseId} {courseTitle} disabled={$isFreePlan} />
|
||||
{/if}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section
|
||||
class="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-auto bg-zinc-100 bg-[radial-gradient(circle,#d4d4d8_1px,transparent_1px)] [background-size:18px_18px] dark:bg-zinc-950 dark:bg-[radial-gradient(circle,rgba(113,113,122,0.4)_1px,transparent_1px)]"
|
||||
aria-label={$t('course.navItem.certificates.editor.preview')}
|
||||
>
|
||||
<div class="flex min-h-0 flex-1 items-center justify-center p-6 sm:p-10 lg:p-14">
|
||||
<Certificate.Preview design={previewDesign} data={sampleRenderData} showControls />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
<script lang="ts">
|
||||
import { ACCENT_COLORS } from '@cio/certificates';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { cn } from '@cio/ui/tools';
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
onSelect: (color: string) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
let { value, onSelect, disabled = false }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="grid grid-cols-6 gap-2">
|
||||
{#each ACCENT_COLORS as color (color)}
|
||||
{@const isActive = color.toLowerCase() === value.toLowerCase()}
|
||||
<button
|
||||
type="button"
|
||||
class={cn(
|
||||
'ui:border-border relative aspect-square rounded-md border transition-transform',
|
||||
'hover:-translate-y-0.5',
|
||||
isActive && 'ui:ring-foreground ring-2 ring-offset-2',
|
||||
disabled && 'cursor-not-allowed opacity-60 hover:translate-y-0'
|
||||
)}
|
||||
style:background-color={color}
|
||||
aria-label={color}
|
||||
aria-pressed={isActive}
|
||||
{disabled}
|
||||
onclick={() => onSelect(color)}
|
||||
></button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<p class="ui:text-muted-foreground text-xs">
|
||||
{$t('course.navItem.certificates.editor.accent_hint')}
|
||||
</p>
|
||||
</div>
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
<script lang="ts">
|
||||
import * as Field from '@cio/ui/base/field';
|
||||
import { InputField } from '@cio/ui/custom/input-field';
|
||||
import { TextareaField } from '@cio/ui/custom/textarea-field';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { certificateEditorStore } from '../store/certificate-editor.store.svelte';
|
||||
|
||||
interface Props {
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
let { disabled = false }: Props = $props();
|
||||
</script>
|
||||
|
||||
<Field.Group>
|
||||
<Field.Set>
|
||||
<Field.Legend>{$t('course.navItem.certificates.editor.section_header')}</Field.Legend>
|
||||
<Field.Group>
|
||||
<Field.Field>
|
||||
<InputField
|
||||
label={$t('course.navItem.certificates.editor.subtitle')}
|
||||
bind:value={certificateEditorStore.draft.subtitle}
|
||||
placeholder={$t('course.navItem.certificates.editor.subtitle_placeholder')}
|
||||
isDisabled={disabled}
|
||||
/>
|
||||
</Field.Field>
|
||||
<Field.Field>
|
||||
<TextareaField
|
||||
label={$t('course.navItem.certificates.editor.description_override')}
|
||||
rows={4}
|
||||
bind:value={certificateEditorStore.draft.descriptionOverride}
|
||||
placeholder={$t('course.navItem.certificates.editor.description_override_placeholder')}
|
||||
{disabled}
|
||||
/>
|
||||
<Field.Description>
|
||||
{$t('course.navItem.certificates.editor.description_override_hint')}
|
||||
</Field.Description>
|
||||
</Field.Field>
|
||||
</Field.Group>
|
||||
</Field.Set>
|
||||
|
||||
<Field.Separator />
|
||||
|
||||
<Field.Set>
|
||||
<Field.Legend>{$t('course.navItem.certificates.editor.section_signatories')}</Field.Legend>
|
||||
<Field.Group>
|
||||
<Field.Field>
|
||||
<InputField
|
||||
label={$t('course.navItem.certificates.editor.signatory_one_name')}
|
||||
bind:value={certificateEditorStore.draft.signatories[0].name}
|
||||
isDisabled={disabled}
|
||||
/>
|
||||
</Field.Field>
|
||||
<Field.Field>
|
||||
<InputField
|
||||
label={$t('course.navItem.certificates.editor.signatory_one_role')}
|
||||
bind:value={certificateEditorStore.draft.signatories[0].role}
|
||||
isDisabled={disabled}
|
||||
/>
|
||||
</Field.Field>
|
||||
<Field.Field>
|
||||
<InputField
|
||||
label={$t('course.navItem.certificates.editor.signatory_two_name')}
|
||||
bind:value={certificateEditorStore.draft.signatories[1].name}
|
||||
isDisabled={disabled}
|
||||
/>
|
||||
</Field.Field>
|
||||
<Field.Field>
|
||||
<InputField
|
||||
label={$t('course.navItem.certificates.editor.signatory_two_role')}
|
||||
bind:value={certificateEditorStore.draft.signatories[1].role}
|
||||
isDisabled={disabled}
|
||||
/>
|
||||
</Field.Field>
|
||||
</Field.Group>
|
||||
</Field.Set>
|
||||
|
||||
<Field.Separator />
|
||||
|
||||
<Field.Set>
|
||||
<Field.Legend>{$t('course.navItem.certificates.editor.section_reference')}</Field.Legend>
|
||||
<Field.Field>
|
||||
<InputField
|
||||
label={$t('course.navItem.certificates.editor.id_format')}
|
||||
bind:value={certificateEditorStore.draft.idFormat}
|
||||
placeholder={'N° {seq}'}
|
||||
isDisabled={disabled}
|
||||
/>
|
||||
<Field.Description>
|
||||
{$t('course.navItem.certificates.editor.id_format_hint')}
|
||||
</Field.Description>
|
||||
</Field.Field>
|
||||
</Field.Set>
|
||||
</Field.Group>
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import DownloadIcon from '@lucide/svelte/icons/download';
|
||||
import ImageIcon from '@lucide/svelte/icons/image';
|
||||
import PrinterIcon from '@lucide/svelte/icons/printer';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { classroomio } from '$lib/utils/services/api';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
|
||||
interface Props {
|
||||
courseId: string;
|
||||
courseTitle: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
let { courseId, courseTitle, disabled = false }: Props = $props();
|
||||
|
||||
let isPdfLoading = $state(false);
|
||||
let isPngLoading = $state(false);
|
||||
let isPrintLoading = $state(false);
|
||||
|
||||
const downloadName = $derived((courseTitle || 'certificate').replace(/\s+/g, '-').toLowerCase());
|
||||
|
||||
async function buildBody() {
|
||||
return {
|
||||
studentName: $profile.fullname || 'Preview Recipient',
|
||||
studentId: $profile.id || undefined,
|
||||
issuedAt: new Date().toISOString(),
|
||||
previewMode: true
|
||||
} as const;
|
||||
}
|
||||
|
||||
async function downloadPdf() {
|
||||
if (disabled || isPdfLoading) return;
|
||||
isPdfLoading = true;
|
||||
|
||||
try {
|
||||
const body = await buildBody();
|
||||
const response = await classroomio.course[':courseId']['download']['certificate']['$post']({
|
||||
param: { courseId },
|
||||
json: body
|
||||
});
|
||||
const blob = await response.blob();
|
||||
triggerDownload(new Blob([blob], { type: 'application/pdf' }), `${downloadName}.pdf`);
|
||||
} catch (error) {
|
||||
console.error('Preview PDF error', error);
|
||||
snackbar.error('course.navItem.certificates.editor.preview_failed');
|
||||
} finally {
|
||||
isPdfLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadPng() {
|
||||
if (disabled || isPngLoading) return;
|
||||
isPngLoading = true;
|
||||
|
||||
try {
|
||||
const body = await buildBody();
|
||||
const response = await classroomio.course[':courseId']['download']['certificate']['png']['$post']({
|
||||
param: { courseId },
|
||||
json: body
|
||||
});
|
||||
const blob = await response.blob();
|
||||
triggerDownload(new Blob([blob], { type: 'image/png' }), `${downloadName}.png`);
|
||||
} catch (error) {
|
||||
console.error('Preview PNG error', error);
|
||||
snackbar.error('course.navItem.certificates.editor.preview_failed');
|
||||
} finally {
|
||||
isPngLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function print() {
|
||||
if (disabled || isPrintLoading) return;
|
||||
isPrintLoading = true;
|
||||
|
||||
try {
|
||||
const body = await buildBody();
|
||||
const response = await classroomio.course[':courseId']['download']['certificate']['png']['$post']({
|
||||
param: { courseId },
|
||||
json: body
|
||||
});
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const printWindow = window.open('', '_blank');
|
||||
if (printWindow) {
|
||||
printWindow.document.write(
|
||||
`<!doctype html><html><head><title>${courseTitle}</title>` +
|
||||
`<style>@page{size:A4 landscape;margin:0}body{margin:0;display:flex;align-items:center;justify-content:center;background:#fff}img{width:100vw;max-width:1100px;height:auto}
|
||||
</style>` +
|
||||
`</head><body><img alt="" src="${url}" onload="setTimeout(()=>window.print(),300)"></body></html>`
|
||||
);
|
||||
printWindow.document.close();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Preview print error', error);
|
||||
snackbar.error('course.navItem.certificates.editor.preview_failed');
|
||||
} finally {
|
||||
isPrintLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function triggerDownload(blob: Blob, filename: string) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.append(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Button class="w-full justify-start" {disabled} loading={isPdfLoading} onclick={downloadPdf}>
|
||||
<DownloadIcon class="size-4" />
|
||||
{$t('course.navItem.certificates.editor.download_pdf')}
|
||||
</Button>
|
||||
<Button variant="outline" class="w-full justify-start" {disabled} loading={isPngLoading} onclick={downloadPng}>
|
||||
<ImageIcon class="size-4" />
|
||||
{$t('course.navItem.certificates.editor.download_png')}
|
||||
</Button>
|
||||
<Button variant="outline" class="w-full justify-start" {disabled} loading={isPrintLoading} onclick={print}>
|
||||
<PrinterIcon class="size-4" />
|
||||
{$t('course.navItem.certificates.editor.print')}
|
||||
</Button>
|
||||
|
||||
<p class="ui:text-muted-foreground mt-3 text-xs">
|
||||
{$t('course.navItem.certificates.editor.export_hint')}
|
||||
</p>
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
<script lang="ts">
|
||||
import type { CertificateTemplateId } from '@cio/certificates';
|
||||
|
||||
interface Props {
|
||||
id: CertificateTemplateId;
|
||||
}
|
||||
|
||||
let { id }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if id === 'classique'}
|
||||
<div class="flex h-full w-full flex-col items-center justify-center bg-[#faf6ec] text-[#6b4513]">
|
||||
<span class="text-[18px] italic" style="font-family:'Bodoni Moda',serif">Classique</span>
|
||||
<span class="mt-1 text-[6px] tracking-[0.3em]" style="font-family:'Cinzel',serif">— EST 1892 —</span>
|
||||
</div>
|
||||
{:else if id === 'brutalist'}
|
||||
<div class="flex h-full w-full items-end justify-start bg-[#f0ede4] p-2">
|
||||
<div
|
||||
class="w-full text-left leading-[0.85] text-black"
|
||||
style="font-family:'Archivo Black',sans-serif;font-size:18px"
|
||||
>
|
||||
RAW<br />
|
||||
<span class="text-[#ff4500] italic" style="font-family:'Playfair Display',serif;font-weight:400">form</span>
|
||||
</div>
|
||||
</div>
|
||||
{:else if id === 'noir'}
|
||||
<div class="flex h-full w-full flex-col items-center justify-center bg-[#0e0e0e] text-[#d4af37]">
|
||||
<span class="text-[16px] text-[#f5f1e8] italic" style="font-family:'Playfair Display',serif">Noir</span>
|
||||
<span class="mt-1 text-[6px] tracking-[0.3em]" style="font-family:'Cinzel',serif">★ ATELIER ★</span>
|
||||
</div>
|
||||
{:else if id === 'poster'}
|
||||
<div class="relative h-full w-full overflow-hidden bg-[#fef2dc]">
|
||||
<span class="absolute -top-3 -right-4 h-10 w-10 rounded-full bg-[#ff5722]"></span>
|
||||
<span class="absolute -bottom-3 -left-3 h-9 w-9 rounded-full bg-[#1e3a8a]"></span>
|
||||
<div class="relative z-10 flex h-full w-full items-center justify-center">
|
||||
<span class="font-black italic" style="font-family:'Playfair Display',serif;font-size:18px">Poster</span>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex h-full w-full flex-col items-center justify-center bg-white text-black">
|
||||
<span class="text-[18px] font-light italic" style="font-family:'Cormorant Garamond',serif">Minimal</span>
|
||||
<span class="mt-1 block h-px w-7 bg-black"></span>
|
||||
<span class="mt-1 text-[6px] tracking-[0.3em]" style="font-family:'JetBrains Mono',monospace">N°001</span>
|
||||
</div>
|
||||
{/if}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
<script lang="ts">
|
||||
import { CERTIFICATE_TEMPLATES, type CertificateTemplateId } from '@cio/certificates';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { cn } from '@cio/ui/tools';
|
||||
import TemplateThumb from './template-thumb.svelte';
|
||||
|
||||
interface Props {
|
||||
value: CertificateTemplateId;
|
||||
onSelect: (id: CertificateTemplateId) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
let { value, onSelect, disabled = false }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
{#each CERTIFICATE_TEMPLATES as template (template.id)}
|
||||
{@const isActive = template.id === value}
|
||||
<button
|
||||
type="button"
|
||||
class={cn(
|
||||
'group ui:border-border relative aspect-[1.4/1] overflow-hidden rounded-md border bg-white text-left transition-transform',
|
||||
'hover:-translate-y-0.5 hover:shadow-md',
|
||||
isActive && 'ui:border-primary ui:ring-primary -translate-y-0.5 shadow-md ring-2',
|
||||
disabled && 'cursor-not-allowed opacity-60 hover:translate-y-0 hover:shadow-none'
|
||||
)}
|
||||
{disabled}
|
||||
aria-pressed={isActive}
|
||||
onclick={() => onSelect(template.id)}
|
||||
>
|
||||
<TemplateThumb id={template.id} />
|
||||
<span
|
||||
class={cn(
|
||||
'absolute right-0 bottom-0 left-0 px-2 py-1 text-center text-[9px] font-medium tracking-[0.18em] uppercase',
|
||||
isActive ? 'ui:bg-primary ui:text-primary-foreground' : 'ui:bg-foreground ui:text-background'
|
||||
)}
|
||||
>
|
||||
{template.label}
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<p class="ui:text-muted-foreground mt-4 text-xs">
|
||||
{$t('course.navItem.certificates.editor.templates_hint')}
|
||||
</p>
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
import {
|
||||
DEFAULT_CERTIFICATE_DESIGN,
|
||||
resolveTemplateId,
|
||||
type CertificateDesign,
|
||||
type CertificateTemplateId
|
||||
} from '@cio/certificates';
|
||||
|
||||
import { courseApi } from '$features/course/api';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
export type CertificateEditorPanel = 'templates' | 'content' | 'colors' | 'export';
|
||||
|
||||
/**
|
||||
* The store keeps optional fields as concrete strings so two-way bindings to
|
||||
* inputs are simple — we collapse empty strings to `undefined` only when
|
||||
* shipping a payload back to the API.
|
||||
*/
|
||||
export interface CertificateEditorDraft {
|
||||
templateId: CertificateTemplateId;
|
||||
accentColor: string;
|
||||
subtitle: string;
|
||||
descriptionOverride: string;
|
||||
idFormat: string;
|
||||
signatories: [{ name: string; role: string }, { name: string; role: string }];
|
||||
}
|
||||
|
||||
function toDraft(design: CertificateDesign): CertificateEditorDraft {
|
||||
return {
|
||||
templateId: design.templateId,
|
||||
accentColor: design.accentColor,
|
||||
subtitle: design.subtitle ?? '',
|
||||
descriptionOverride: design.descriptionOverride ?? '',
|
||||
idFormat: design.idFormat ?? '',
|
||||
signatories: [
|
||||
{ name: design.signatories[0]?.name ?? '', role: design.signatories[0]?.role ?? '' },
|
||||
{ name: design.signatories[1]?.name ?? '', role: design.signatories[1]?.role ?? '' }
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
function fromDraft(draft: CertificateEditorDraft): CertificateDesign {
|
||||
return {
|
||||
templateId: draft.templateId,
|
||||
accentColor: draft.accentColor,
|
||||
subtitle: draft.subtitle.trim() || undefined,
|
||||
descriptionOverride: draft.descriptionOverride.trim() || undefined,
|
||||
idFormat: draft.idFormat.trim() || undefined,
|
||||
signatories: [
|
||||
{ name: draft.signatories[0].name, role: draft.signatories[0].role },
|
||||
{ name: draft.signatories[1].name, role: draft.signatories[1].role }
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
function readStoredDesign(): CertificateDesign {
|
||||
const certificate = courseApi.course?.certificate;
|
||||
const stored = certificate?.design as Partial<CertificateDesign> | undefined;
|
||||
const legacyTheme = certificate?.theme;
|
||||
|
||||
return {
|
||||
templateId: resolveTemplateId(stored?.templateId ?? legacyTheme),
|
||||
accentColor: stored?.accentColor ?? DEFAULT_CERTIFICATE_DESIGN.accentColor,
|
||||
subtitle: stored?.subtitle ?? DEFAULT_CERTIFICATE_DESIGN.subtitle,
|
||||
descriptionOverride: stored?.descriptionOverride,
|
||||
signatories: [
|
||||
{
|
||||
name: stored?.signatories?.[0]?.name ?? DEFAULT_CERTIFICATE_DESIGN.signatories[0].name,
|
||||
role: stored?.signatories?.[0]?.role ?? DEFAULT_CERTIFICATE_DESIGN.signatories[0].role
|
||||
},
|
||||
{
|
||||
name: stored?.signatories?.[1]?.name ?? DEFAULT_CERTIFICATE_DESIGN.signatories[1].name,
|
||||
role: stored?.signatories?.[1]?.role ?? DEFAULT_CERTIFICATE_DESIGN.signatories[1].role
|
||||
}
|
||||
],
|
||||
idFormat: stored?.idFormat ?? DEFAULT_CERTIFICATE_DESIGN.idFormat
|
||||
};
|
||||
}
|
||||
|
||||
class CertificateEditorStore {
|
||||
activePanel = $state<CertificateEditorPanel>('templates');
|
||||
draft = $state<CertificateEditorDraft>(toDraft(DEFAULT_CERTIFICATE_DESIGN));
|
||||
initial = $state<CertificateEditorDraft>(toDraft(DEFAULT_CERTIFICATE_DESIGN));
|
||||
isSaving = $state(false);
|
||||
#initializedCourseId: string | null = null;
|
||||
|
||||
readonly isDirty = $derived(JSON.stringify(this.draft) !== JSON.stringify(this.initial));
|
||||
|
||||
syncFromCourse(courseId: string, force = false) {
|
||||
if (!force && this.#initializedCourseId === courseId) return;
|
||||
|
||||
const stored = readStoredDesign();
|
||||
this.initial = toDraft(stored);
|
||||
this.draft = toDraft(stored);
|
||||
this.#initializedCourseId = courseId;
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.draft = toDraft(fromDraft(this.initial));
|
||||
}
|
||||
|
||||
setTemplate(templateId: CertificateTemplateId) {
|
||||
this.draft.templateId = templateId;
|
||||
}
|
||||
|
||||
setAccent(color: string) {
|
||||
this.draft.accentColor = color;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a render-ready design with empty optional strings collapsed to
|
||||
* `undefined`, suitable for handing to `Certificate.Preview` / API payload.
|
||||
*/
|
||||
toDesign(): CertificateDesign {
|
||||
return fromDraft(this.draft);
|
||||
}
|
||||
|
||||
async save() {
|
||||
const course = courseApi.course;
|
||||
if (!course?.id) return;
|
||||
|
||||
this.isSaving = true;
|
||||
try {
|
||||
const certificate = {
|
||||
...(course.certificate ?? {}),
|
||||
design: fromDraft(this.draft),
|
||||
theme: this.draft.templateId
|
||||
};
|
||||
|
||||
const updated = await courseApi.update(course.id, { certificate }, { showSuccessToast: false });
|
||||
|
||||
if (updated) {
|
||||
this.initial = {
|
||||
...this.draft,
|
||||
signatories: [{ ...this.draft.signatories[0] }, { ...this.draft.signatories[1] }]
|
||||
};
|
||||
snackbar.success(t.get('course.navItem.certificates.editor.saved'));
|
||||
}
|
||||
} finally {
|
||||
this.isSaving = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const certificateEditorStore = new CertificateEditorStore();
|
||||
+58
-24
@@ -1,8 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import DownloadIcon from '@lucide/svelte/icons/download';
|
||||
import ImageIcon from '@lucide/svelte/icons/image';
|
||||
|
||||
import { currentOrg, currentOrgDomain } from '$lib/utils/store/org';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { Empty } from '@cio/ui/custom/empty';
|
||||
@@ -14,6 +14,7 @@
|
||||
import { openCourseCompletionModal } from '$features/course/store/course-completion-modal';
|
||||
|
||||
let isLoading = $state(false);
|
||||
let isPngLoading = $state(false);
|
||||
let isCourseComplete = $state(false);
|
||||
let evaluation = $state<CertificationEvaluationData | null>(null);
|
||||
|
||||
@@ -54,6 +55,25 @@
|
||||
}
|
||||
}
|
||||
|
||||
function buildBody() {
|
||||
return {
|
||||
studentName: $profile.fullname || 'Recipient',
|
||||
studentId: $profile.id || undefined,
|
||||
issuedAt: new Date().toISOString()
|
||||
} as const;
|
||||
}
|
||||
|
||||
async function triggerSave(blob: Blob, filename: string) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
document.body.append(link);
|
||||
link.download = filename;
|
||||
link.href = url;
|
||||
link.click();
|
||||
link.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
const downLoadCertificate = async () => {
|
||||
if (!isCourseComplete || !courseId) return;
|
||||
|
||||
@@ -61,27 +81,13 @@
|
||||
try {
|
||||
const response = await classroomio.course[':courseId']['download']['certificate']['$post']({
|
||||
param: { courseId },
|
||||
json: {
|
||||
theme: `${courseApi.course?.certificate?.theme ?? 'professional'}`,
|
||||
studentName: `${$profile.fullname}`,
|
||||
courseName: `${courseApi.course?.title}`,
|
||||
courseDescription: `${courseApi.course?.description}`,
|
||||
orgLogoUrl: $currentOrg.avatarUrl || `${$currentOrgDomain}/logo-512.png`,
|
||||
orgName: `${$currentOrg.name}`
|
||||
}
|
||||
json: buildBody()
|
||||
});
|
||||
|
||||
const blobResponse = await response.blob();
|
||||
|
||||
const file = new Blob([blobResponse], { type: 'application/pdf' });
|
||||
const fileURL = URL.createObjectURL(file);
|
||||
|
||||
let a = document.createElement('a');
|
||||
document.body.append(a);
|
||||
a.download = 'Certificate of Completion - ' + $currentOrg.name;
|
||||
a.href = fileURL;
|
||||
a.click();
|
||||
a.remove();
|
||||
await triggerSave(
|
||||
new Blob([blobResponse], { type: 'application/pdf' }),
|
||||
`certificate-${courseApi.course?.title ?? 'course'}.pdf`
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error downloading', error);
|
||||
snackbar.error($t('course.navItem.certificates.unexpected_error'));
|
||||
@@ -90,6 +96,28 @@
|
||||
isLoading = false;
|
||||
};
|
||||
|
||||
const downloadCertificateImage = async () => {
|
||||
if (!isCourseComplete || !courseId) return;
|
||||
|
||||
isPngLoading = true;
|
||||
try {
|
||||
const response = await classroomio.course[':courseId']['download']['certificate']['png']['$post']({
|
||||
param: { courseId },
|
||||
json: buildBody()
|
||||
});
|
||||
const blobResponse = await response.blob();
|
||||
await triggerSave(
|
||||
new Blob([blobResponse], { type: 'image/png' }),
|
||||
`certificate-${courseApi.course?.title ?? 'course'}.png`
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error downloading image', error);
|
||||
snackbar.error($t('course.navItem.certificates.unexpected_error'));
|
||||
}
|
||||
|
||||
isPngLoading = false;
|
||||
};
|
||||
|
||||
const loadEvaluation = async () => {
|
||||
if (!courseId) return;
|
||||
isLoading = true;
|
||||
@@ -149,9 +177,15 @@
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
<Button onclick={downLoadCertificate} disabled={!isCourseComplete} loading={isLoading}>
|
||||
<DownloadIcon size={16} />
|
||||
{$t('course.navItem.certificates.download_certificate')}
|
||||
</Button>
|
||||
<div class="flex flex-wrap items-center justify-center gap-2">
|
||||
<Button onclick={downLoadCertificate} disabled={!isCourseComplete} loading={isLoading}>
|
||||
<DownloadIcon size={16} />
|
||||
{$t('course.navItem.certificates.download_certificate')}
|
||||
</Button>
|
||||
<Button variant="outline" onclick={downloadCertificateImage} disabled={!isCourseComplete} loading={isPngLoading}>
|
||||
<ImageIcon size={16} />
|
||||
{$t('course.navItem.certificates.download_image')}
|
||||
</Button>
|
||||
</div>
|
||||
</Empty>
|
||||
</div>
|
||||
|
||||
-56
@@ -1,56 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { courseApi } from '$features/course/api';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
|
||||
interface Props {
|
||||
studentName?: string;
|
||||
}
|
||||
|
||||
let { studentName = '' }: Props = $props();
|
||||
const logo = '/logo-512.png';
|
||||
|
||||
const dateIssued = new Date().toLocaleDateString();
|
||||
</script>
|
||||
|
||||
<div class="relative w-full dark:text-black">
|
||||
<img src="/images/blue-badge-pattern-background.svg" alt="" class="absolute z-0 h-full w-full object-cover" />
|
||||
<div class="relative z-20 m-2 flex h-full justify-between bg-white md:h-68">
|
||||
<div class="w-[70%] pl-3">
|
||||
<header class="absolute flex w-full items-center justify-center gap-1 text-sm">
|
||||
<img src={$currentOrg.avatarUrl ? $currentOrg.avatarUrl : logo} alt="logo" class="w-5" />
|
||||
<h1 class="text-sm">{$currentOrg.name}</h1>
|
||||
</header>
|
||||
|
||||
<div class="mt-16">
|
||||
<h2 class="m-0 text-[7px]">This certificate is awarded to</h2>
|
||||
<h1 class="m-0 mt-0.5 text-lg leading-3 text-[#2D499F]">{studentName}</h1>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 w-[90%]">
|
||||
<h1 class="text-[7px]">has successfully completed training on</h1>
|
||||
<h2 class="m-0 text-lg">
|
||||
{courseApi.course?.title}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="my-8 flex items-center justify-between text-center">
|
||||
<div>
|
||||
<h1 class="m-0 h-2/4 text-[7px]">DATE ISSUED:</h1>
|
||||
<p class="border-b border-black text-[7px]">{dateIssued}</p>
|
||||
</div>
|
||||
<div class="w-[20%] text-[7px]">
|
||||
<div></div>
|
||||
<p class="border-t border-black text-[7px]">Signature</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="desc w-[90%] text-[8px]">
|
||||
{courseApi.course?.description}
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-center overflow-hidden">
|
||||
<img src="/images/blue-badge-pattern-badge.svg" alt="A badge" class="w-32 max-w-none md:w-40" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
-55
@@ -1,55 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { courseApi } from '$features/course/api';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
|
||||
interface Props {
|
||||
studentName?: string;
|
||||
}
|
||||
|
||||
let { studentName = '' }: Props = $props();
|
||||
const logo = '/logo-512.png';
|
||||
|
||||
const dateIssued = new Date().toLocaleDateString();
|
||||
</script>
|
||||
|
||||
<div class="w-full bg-[#2D499F] p-3 dark:text-black">
|
||||
<div class="flex w-full bg-white pb-5">
|
||||
<div class="w-[80%] pl-3">
|
||||
<header class="flex items-center justify-start gap-1 text-sm">
|
||||
<img src={$currentOrg.avatarUrl ? $currentOrg.avatarUrl : logo} alt="logo" class="w-5" />
|
||||
<h1 class="text-sm">{$currentOrg.name}</h1>
|
||||
</header>
|
||||
|
||||
<div class="mt-7">
|
||||
<h2 class="m-0 text-[7px]">This certificate is awarded to</h2>
|
||||
<h1 class="m-0 mt-0.5 text-lg leading-3 text-[#2D499F]">{studentName}</h1>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 w-[90%]">
|
||||
<h1 class="text-[7px]">has succesfully completed training on</h1>
|
||||
<h2 class="m-0 text-lg">
|
||||
{courseApi.course?.title}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 mb-5 flex items-center justify-between text-center">
|
||||
<div class="">
|
||||
<h1 class="m-0 h-2/4 text-[7px]">DATE ISSUED:</h1>
|
||||
<p class="border-b border-black text-[7px]">{dateIssued}</p>
|
||||
</div>
|
||||
<div class="w-[20%] text-[7px]">
|
||||
<div class=""></div>
|
||||
<p class="border-t border-black text-[7px]">Signature</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="desc w-[90%] text-[8px]">
|
||||
{courseApi.course?.description}
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<div class="w-[20%]">
|
||||
<img src="/images/blue-professional-badge.svg" alt="A badge" class="w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,53 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { courseApi } from '$features/course/api';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
|
||||
interface Props {
|
||||
studentName?: string;
|
||||
}
|
||||
|
||||
let { studentName = '' }: Props = $props();
|
||||
const borderwidth = 'border-4';
|
||||
const bordercolor = 'border-pink-700';
|
||||
const logo = '/logo-512.png';
|
||||
</script>
|
||||
|
||||
<div class="certificate-bg flex w-full flex-col items-center justify-center {borderwidth} {bordercolor} py-3">
|
||||
<div class="my-2 flex items-center gap-1">
|
||||
<img src={$currentOrg.avatarUrl ? $currentOrg.avatarUrl : logo} alt="logo" class=" h-10 w-10 rounded-md" />
|
||||
<p class="font-semibold capitalize dark:text-black">{$currentOrg.name}</p>
|
||||
</div>
|
||||
<div class="w-full px-5 py-3">
|
||||
<p class="my-2 w-full text-center text-sm font-normal tracking-widest text-gray-500 uppercase">
|
||||
Certificate of completion
|
||||
</p>
|
||||
<div class="mb-4 border-b border-gray-500">
|
||||
<p class="text-xs font-medium text-black">This is to certify that</p>
|
||||
<p class="student-name text-center text-5xl dark:text-black">{studentName}</p>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<p class="text-xs font-medium text-black">has successfully completed the course</p>
|
||||
<div>
|
||||
<p class="clamp bg-transparent text-center text-base font-semibold text-pink-700 uppercase">
|
||||
{courseApi.course?.title}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="desc text-center dark:text-black">{courseApi.course?.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.student-name {
|
||||
font-family: 'Qwitcher Grypen', cursive;
|
||||
}
|
||||
.desc {
|
||||
font-size: 10px;
|
||||
}
|
||||
.clamp {
|
||||
font-size: clamp(14px, 3vw, 16px);
|
||||
}
|
||||
.certificate-bg {
|
||||
background-color: #f5f8fe;
|
||||
}
|
||||
</style>
|
||||
-55
@@ -1,55 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { courseApi } from '$features/course/api';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
|
||||
interface Props {
|
||||
studentName?: string;
|
||||
}
|
||||
|
||||
let { studentName = '' }: Props = $props();
|
||||
const borderwidth = 'border-l-8';
|
||||
const bordercolor = 'border-primary-800';
|
||||
const logo = '/logo-512.png';
|
||||
</script>
|
||||
|
||||
<div class="certificate-bg border-primary-400 flex w-full items-center justify-center border border-dashed">
|
||||
<div class="{borderwidth} {bordercolor} w-full">
|
||||
<div class="w-full px-5 py-3">
|
||||
<p class="my-2 text-sm font-normal text-gray-500 uppercase">
|
||||
Certificate<br /> of completion
|
||||
</p>
|
||||
<div class="mb-4 border-b border-gray-500">
|
||||
<p class="text-xs font-medium text-black">This is to certify that</p>
|
||||
<p class="student-name text-center text-5xl dark:text-black">{studentName}</p>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<p class="text-xs font-medium text-black">has successfully completed introductory courses in</p>
|
||||
<div>
|
||||
<p class="clamp text-primary-800 my-2 bg-transparent text-center text-base font-semibold uppercase">
|
||||
{courseApi.course?.title}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="desc mb-4 text-justify dark:text-black">{courseApi.course?.description}</p>
|
||||
<div class="my-2 flex items-center gap-1">
|
||||
<img src={$currentOrg.avatarUrl ? $currentOrg.avatarUrl : logo} alt="logo" class="h-10 w-10 rounded-md" />
|
||||
<p class="font-semibold capitalize dark:text-black">{$currentOrg.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.student-name {
|
||||
font-family: 'Qwitcher Grypen', cursive;
|
||||
}
|
||||
.desc {
|
||||
font-size: 10px;
|
||||
}
|
||||
.clamp {
|
||||
font-size: clamp(14px, 3vw, 16px);
|
||||
}
|
||||
.certificate-bg {
|
||||
background-color: #f5f8fe;
|
||||
}
|
||||
</style>
|
||||
-56
@@ -1,56 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { courseApi } from '$features/course/api';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
|
||||
interface Props {
|
||||
studentName?: string;
|
||||
}
|
||||
|
||||
let { studentName = '' }: Props = $props();
|
||||
const logo = '/logo-512.png';
|
||||
|
||||
const dateIssued = new Date().toLocaleDateString();
|
||||
</script>
|
||||
|
||||
<div class="relative w-full dark:text-black">
|
||||
<img src="/images/purple-badge-pattern-background.svg" alt="" class="absolute z-0 h-full w-full object-cover" />
|
||||
<div class="relative z-20 m-2 flex h-full justify-between bg-white md:h-68">
|
||||
<div class="w-[70%] pl-3">
|
||||
<header class="absolute flex w-full items-center justify-center gap-1 text-sm">
|
||||
<img src={$currentOrg.avatarUrl ? $currentOrg.avatarUrl : logo} alt="logo" class="w-5" />
|
||||
<h1 class="text-sm">{$currentOrg.name}</h1>
|
||||
</header>
|
||||
|
||||
<div class="mt-16">
|
||||
<h2 class="m-0 text-[7px]">This certificate is awarded to</h2>
|
||||
<h1 class="m-0 mt-0.5 text-lg leading-3 text-[#914397]">{studentName}</h1>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 w-[90%]">
|
||||
<h1 class="text-[7px]">has successfully completed training on</h1>
|
||||
<h2 class="m-0 text-lg">
|
||||
{courseApi.course?.title}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="my-8 flex items-center justify-between text-center">
|
||||
<div>
|
||||
<h1 class="m-0 h-2/4 text-[7px]">DATE ISSUED:</h1>
|
||||
<p class="border-b border-black text-[7px]">{dateIssued}</p>
|
||||
</div>
|
||||
<div class="w-[20%] text-[7px]">
|
||||
<div></div>
|
||||
<p class="border-t border-black text-[7px]">Signature</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="desc w-[90%] text-[8px]">
|
||||
{courseApi.course?.description}
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-center overflow-hidden">
|
||||
<img src="/images/purple-badge-pattern-badge.svg" alt="A badge" class="w-[8rem] max-w-none md:w-[10rem]" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
-55
@@ -1,55 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { courseApi } from '$features/course/api';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
|
||||
interface Props {
|
||||
studentName?: string;
|
||||
}
|
||||
|
||||
let { studentName = '' }: Props = $props();
|
||||
const logo = '/logo-512.png';
|
||||
|
||||
const dateIssued = new Date().toLocaleDateString();
|
||||
</script>
|
||||
|
||||
<div class="w-full bg-[#95449A] p-3 dark:text-black">
|
||||
<div class="flex w-full bg-white pb-5">
|
||||
<div class="w-[80%] pl-3">
|
||||
<header class="flex items-center justify-start gap-1 text-sm">
|
||||
<img src={$currentOrg.avatarUrl ? $currentOrg.avatarUrl : logo} alt="logo" class="w-5" />
|
||||
<h1 class="text-sm">{$currentOrg.name}</h1>
|
||||
</header>
|
||||
|
||||
<div class="mt-7">
|
||||
<h2 class="m-0 text-[7px]">This certificate is awarded to</h2>
|
||||
<h1 class="m-0 mt-0.5 text-lg leading-3 text-[#95449A]">{studentName}</h1>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 w-[90%]">
|
||||
<h1 class="text-[7px]">has succesfully completed training on</h1>
|
||||
<h2 class="m-0 text-lg">
|
||||
{courseApi.course?.title}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 mb-5 flex items-center justify-between text-center">
|
||||
<div class="">
|
||||
<h1 class="m-0 h-2/4 text-[7px]">DATE ISSUED:</h1>
|
||||
<p class="border-b border-black text-[7px]">{dateIssued}</p>
|
||||
</div>
|
||||
<div class="w-[20%] text-[7px]">
|
||||
<div class=""></div>
|
||||
<p class="border-t border-black text-[7px]">Signature</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="desc w-[90%] text-[8px]">
|
||||
{courseApi.course?.description}
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<div class="w-[20%]">
|
||||
<img src="/images/purple-professional-badge.svg" alt="A badge" class="w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -8,12 +8,14 @@
|
||||
import { HoverableItem, PreviewIcon } from '@cio/ui/custom/moving-icons';
|
||||
import RefreshCcwIcon from '@lucide/svelte/icons/refresh-ccw';
|
||||
import * as Empty from '@cio/ui/base/empty';
|
||||
import { page } from '$app/state';
|
||||
import { currentOrg, currentOrgDomain } from '$lib/utils/store/org';
|
||||
import { isStudentExperience } from '$lib/utils/store/app';
|
||||
import SparklesIcon from '@lucide/svelte/icons/sparkles';
|
||||
import { setupProgressApi } from '$features/setup/api/setup-progress.svelte';
|
||||
import { courseApi } from '$features/course/api';
|
||||
import { openCoursePreview } from '$features/course/utils/course-preview';
|
||||
import { getActiveCourseNavKey } from '$features/course/utils/functions';
|
||||
import { toggleAiAssistant } from '$features/ai-assistant/utils/store';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import CoursePublishBadge from './course-publish-badge.svelte';
|
||||
@@ -22,6 +24,7 @@
|
||||
const siteName = $derived($currentOrg.siteName);
|
||||
const showCoursePublishBadge = $derived(!$isStudentExperience);
|
||||
const isPublicCourse = $derived(courseApi.course?.type === 'PUBLIC');
|
||||
const activeNavKey = $derived(getActiveCourseNavKey(page.url.pathname, courseApi.course?.id ?? ''));
|
||||
|
||||
$effect(() => {
|
||||
if (!siteName) return;
|
||||
@@ -39,9 +42,7 @@
|
||||
</script>
|
||||
|
||||
<header
|
||||
class="border-border ui:bg-background sticky top-0 z-100 flex w-full shrink-0 items-center gap-2 border-b backdrop-blur transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-8 {isPublicCourse
|
||||
? 'h-auto min-h-14 py-1.5'
|
||||
: 'h-12'}"
|
||||
class="border-border ui:bg-background sticky top-0 z-100 flex h-12 w-full shrink-0 items-center gap-2 border-b backdrop-blur transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-8"
|
||||
>
|
||||
<div class="flex w-full items-center gap-2 px-4">
|
||||
<Sidebar.Trigger />
|
||||
@@ -53,7 +54,7 @@
|
||||
<div class="flex w-[60%] min-w-0 flex-1 flex-col justify-center gap-0.5">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<p class="max-w-xs truncate text-sm font-medium">
|
||||
{courseApi.course?.title || ''}
|
||||
{activeNavKey ? $t(activeNavKey) : ''}
|
||||
</p>
|
||||
|
||||
{#if isPublicCourse}
|
||||
@@ -64,12 +65,6 @@
|
||||
<CoursePublishBadge isPublished={courseApi.course?.isPublished ?? false} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if isPublicCourse}
|
||||
<p class="ui:text-muted-foreground max-w-xl truncate text-xs">
|
||||
{$t('course.header.public_course_helper')}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<span class="grow"></span>
|
||||
|
||||
@@ -94,7 +94,9 @@
|
||||
moveUp: t.get('course.navItem.lessons.exercises.all_exercises.section.move_up'),
|
||||
moveDown: t.get('course.navItem.lessons.exercises.all_exercises.section.move_down'),
|
||||
color: t.get('course.navItem.lessons.exercises.all_exercises.section.color'),
|
||||
descriptionPlaceholder: t.get('course.navItem.lessons.exercises.all_exercises.section.description_placeholder')
|
||||
descriptionPlaceholder: t.get('course.navItem.lessons.exercises.all_exercises.section.description_placeholder'),
|
||||
collapseSection: t.get('course.navItem.lessons.exercises.all_exercises.section.collapse_section'),
|
||||
expandSection: t.get('course.navItem.lessons.exercises.all_exercises.section.expand_section')
|
||||
});
|
||||
const sectionAfterBehaviorLabels = $derived({
|
||||
afterSectionPrefix: t.get('course.navItem.lessons.exercises.all_exercises.section.after_section'),
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
import XIcon from '@lucide/svelte/icons/x';
|
||||
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up';
|
||||
import ArrowDownIcon from '@lucide/svelte/icons/arrow-down';
|
||||
import ChevronUpIcon from '@lucide/svelte/icons/chevron-up';
|
||||
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
|
||||
|
||||
const COLOR_THEMES: ExerciseSectionColorTheme[] = ['blue', 'green', 'amber', 'rose', 'violet', 'slate'];
|
||||
|
||||
@@ -82,6 +84,8 @@
|
||||
let localTitle = $derived(title);
|
||||
let localDescription = $derived(description ?? '');
|
||||
|
||||
let sectionCollapsed = $state(false);
|
||||
|
||||
function handleTitleInput() {
|
||||
onTitleChange?.(localTitle);
|
||||
}
|
||||
@@ -94,12 +98,49 @@
|
||||
<section
|
||||
id={`exercise-section-${exerciseSectionId}`}
|
||||
class={`overflow-hidden rounded-lg border bg-white shadow-xs dark:bg-zinc-950 ${BORDER_CLASSES[colorTheme]}`}
|
||||
aria-expanded={!sectionCollapsed}
|
||||
>
|
||||
<div class="flex items-center gap-3 border-b bg-zinc-50 px-4 py-3 dark:bg-zinc-900">
|
||||
<div class="group/sectionhead flex items-center gap-3 border-b bg-zinc-50 px-4 py-3 dark:bg-zinc-900">
|
||||
{#if showReorderHandles}
|
||||
<GripVerticalIcon class="ui:text-muted-foreground h-4 w-4 shrink-0" aria-hidden="true" />
|
||||
{/if}
|
||||
<span class={`h-2.5 w-2.5 shrink-0 rounded-full ${COLOR_CLASSES[colorTheme]}`}></span>
|
||||
<div class="relative h-7 w-7 shrink-0">
|
||||
{#if !sectionCollapsed}
|
||||
<span
|
||||
class={`pointer-events-none absolute top-1/2 left-1/2 h-2.5 w-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full opacity-100 transition-opacity ${COLOR_CLASSES[colorTheme]} group-hover/sectionhead:opacity-0`}
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<div
|
||||
class="absolute inset-0 flex items-center justify-center opacity-0 transition-opacity group-hover/sectionhead:opacity-100"
|
||||
>
|
||||
<IconButton
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onclick={() => (sectionCollapsed = true)}
|
||||
tooltip={labels.collapseSection ?? 'Collapse section'}
|
||||
tooltipSide="bottom"
|
||||
class="shrink-0"
|
||||
>
|
||||
<ChevronUpIcon class="h-4 w-4" aria-hidden="true" />
|
||||
</IconButton>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex h-full w-full items-center justify-center">
|
||||
<IconButton
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onclick={() => (sectionCollapsed = false)}
|
||||
tooltip={labels.expandSection ?? 'Expand section'}
|
||||
tooltipSide="bottom"
|
||||
class="shrink-0"
|
||||
>
|
||||
<ChevronDownIcon class="h-4 w-4" aria-hidden="true" />
|
||||
</IconButton>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<Input
|
||||
bind:value={localTitle}
|
||||
oninput={handleTitleInput}
|
||||
@@ -137,48 +178,50 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="border-b bg-white px-4 py-3 dark:bg-zinc-950">
|
||||
<TextareaField
|
||||
bind:value={localDescription}
|
||||
rows={2}
|
||||
oninput={handleDescriptionInput}
|
||||
placeholder={labels.descriptionPlaceholder ?? 'Describe this section'}
|
||||
errorMessage={descriptionError}
|
||||
className="m-0"
|
||||
bgColor="resize-none border-dashed bg-transparent text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4 bg-white px-4 py-4 dark:bg-zinc-950">
|
||||
{@render children?.()}
|
||||
|
||||
<div class="flex justify-center">
|
||||
<Button variant="outline" onclick={onAddQuestion}>+ {labels.addQuestion ?? 'Add question'}</Button>
|
||||
{#if !sectionCollapsed}
|
||||
<div class="border-b bg-white px-4 py-3 dark:bg-zinc-950">
|
||||
<TextareaField
|
||||
bind:value={localDescription}
|
||||
rows={2}
|
||||
oninput={handleDescriptionInput}
|
||||
placeholder={labels.descriptionPlaceholder ?? 'Describe this section'}
|
||||
errorMessage={descriptionError}
|
||||
className="m-0"
|
||||
bgColor="resize-none border-dashed bg-transparent text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between border-t bg-zinc-50 px-4 py-3 dark:bg-zinc-900">
|
||||
<p class="ui:text-muted-foreground text-sm">
|
||||
{requiredQuestionCount}
|
||||
{labels.required ?? 'required'}
|
||||
</p>
|
||||
<div class="flex items-center gap-1">
|
||||
<IconButton
|
||||
onclick={onMoveUp}
|
||||
disabled={!canMoveUp}
|
||||
tooltip={labels.moveUp ?? 'Move section up'}
|
||||
tooltipSide="bottom"
|
||||
>
|
||||
<ArrowUpIcon size={16} />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
onclick={onMoveDown}
|
||||
disabled={!canMoveDown}
|
||||
tooltip={labels.moveDown ?? 'Move section down'}
|
||||
tooltipSide="bottom"
|
||||
>
|
||||
<ArrowDownIcon size={16} />
|
||||
</IconButton>
|
||||
<div class="space-y-4 bg-white px-4 py-4 dark:bg-zinc-950">
|
||||
{@render children?.()}
|
||||
|
||||
<div class="flex justify-center">
|
||||
<Button variant="outline" onclick={onAddQuestion}>+ {labels.addQuestion ?? 'Add question'}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between border-t bg-zinc-50 px-4 py-3 dark:bg-zinc-900">
|
||||
<p class="ui:text-muted-foreground text-sm">
|
||||
{requiredQuestionCount}
|
||||
{labels.required ?? 'required'}
|
||||
</p>
|
||||
<div class="flex items-center gap-1">
|
||||
<IconButton
|
||||
onclick={onMoveUp}
|
||||
disabled={!canMoveUp}
|
||||
tooltip={labels.moveUp ?? 'Move section up'}
|
||||
tooltipSide="bottom"
|
||||
>
|
||||
<ArrowUpIcon size={16} />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
onclick={onMoveDown}
|
||||
disabled={!canMoveDown}
|
||||
tooltip={labels.moveDown ?? 'Move section down'}
|
||||
tooltipSide="bottom"
|
||||
>
|
||||
<ArrowDownIcon size={16} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
+13
-1
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { questionnaire } from './store';
|
||||
import { hasSections, questionnaire } from './store';
|
||||
import { SafeHtmlContent } from '@cio/ui/custom/safe-html-content';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
@@ -19,6 +19,11 @@
|
||||
return acc;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
const activeExerciseSectionsCount = $derived(
|
||||
[...($questionnaire.sections ?? [])].filter((section) => !section.deletedAt).length
|
||||
);
|
||||
const showSectionsInPreviewSummary = $derived(hasSections($questionnaire.sections ?? []));
|
||||
</script>
|
||||
|
||||
<div class="mb-5 {!preview ? 'px-6' : 'px-2'}">
|
||||
@@ -31,6 +36,13 @@
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.view_mode.questions')}
|
||||
</p>
|
||||
|
|
||||
{#if showSectionsInPreviewSummary}
|
||||
<p class="mx-2 dark:text-white">
|
||||
<strong>{activeExerciseSectionsCount}</strong>
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.view_mode.sections')}
|
||||
</p>
|
||||
|
|
||||
{/if}
|
||||
<p class="mx-2 dark:text-white">
|
||||
<strong>{getTotalPossibleGrade($questionnaire.questions)}</strong>
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.view_mode.points')}.
|
||||
|
||||
@@ -761,6 +761,13 @@
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.view_mode.questions')}
|
||||
</p>
|
||||
|
|
||||
{#if hasSectionedExercise}
|
||||
<p class="mx-2 dark:text-white">
|
||||
<strong>{activeSections.length}</strong>
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.view_mode.sections')}
|
||||
</p>
|
||||
|
|
||||
{/if}
|
||||
<p class="mx-2 dark:text-white">
|
||||
<strong>{getTotalPossibleGrade($questionnaire.questions)}</strong>
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.view_mode.points')}.
|
||||
|
||||
@@ -13,6 +13,12 @@ interface MaterialTab {
|
||||
}
|
||||
|
||||
export const tabs: MaterialTab[] = [
|
||||
{
|
||||
label: 'course.navItem.lessons.materials.tabs.video.title',
|
||||
icon: VideoIcon,
|
||||
value: 3,
|
||||
badgeValue: 0
|
||||
},
|
||||
{
|
||||
label: 'course.navItem.lessons.materials.tabs.note.title',
|
||||
icon: BookOpenIcon,
|
||||
@@ -25,12 +31,6 @@ export const tabs: MaterialTab[] = [
|
||||
value: 2,
|
||||
badgeValue: 0
|
||||
},
|
||||
{
|
||||
label: 'course.navItem.lessons.materials.tabs.video.title',
|
||||
icon: VideoIcon,
|
||||
value: 3,
|
||||
badgeValue: 0
|
||||
},
|
||||
{
|
||||
label: 'course.navItem.lessons.materials.tabs.document.title',
|
||||
icon: FileTextIcon,
|
||||
|
||||
@@ -5,9 +5,10 @@
|
||||
import { lessonApi } from '$features/course/api';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import MODES from '$lib/utils/constants/mode';
|
||||
import type { Content } from '@cio/ui/custom/editor';
|
||||
import type { Content, TiptapEditor } from '@cio/ui/custom/editor';
|
||||
import type { TLocale } from '@cio/db/types';
|
||||
import AIButton from '$features/course/components/lesson/ai-button.svelte';
|
||||
import QuoteSelection from '$features/course/components/lesson/note/quote-selection.svelte';
|
||||
import type { Writable } from 'svelte/store';
|
||||
import { saveDraft } from '$features/course/utils/lesson-draft';
|
||||
|
||||
@@ -20,6 +21,19 @@
|
||||
|
||||
let { mode = MODES.view, lessonId = '', isLoading, callAI = () => {} }: Props = $props();
|
||||
|
||||
let noteRoot: HTMLElement | undefined = $state();
|
||||
let editRoot: HTMLElement | undefined = $state();
|
||||
|
||||
$effect(() => {
|
||||
if (mode !== MODES.edit) {
|
||||
editRoot = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
function bindEditorRoot(editor: TiptapEditor) {
|
||||
editRoot = editor.view.dom as HTMLElement;
|
||||
}
|
||||
|
||||
let hasAtLeastOneTranslation = $derived(
|
||||
Object.values(lessonApi.translations[lessonId] || {}).some((content) => {
|
||||
return content && !!content.length;
|
||||
@@ -52,15 +66,20 @@
|
||||
<TextEditor
|
||||
{content}
|
||||
onChange={(content) => onEditorChange(content)}
|
||||
onReady={bindEditorRoot}
|
||||
placeholder={$t('course.navItem.lessons.materials.tabs.note.placeholder')}
|
||||
/>
|
||||
</div>
|
||||
<QuoteSelection root={editRoot} enabled />
|
||||
{:else}
|
||||
<!-- View Mode -->
|
||||
{#if !isHtmlValueEmpty(content)}
|
||||
<HTMLRender>
|
||||
<SafeHtmlContent {content} />
|
||||
</HTMLRender>
|
||||
<div class="relative mx-auto w-full max-w-2xl" bind:this={noteRoot}>
|
||||
<HTMLRender>
|
||||
<SafeHtmlContent {content} />
|
||||
</HTMLRender>
|
||||
<QuoteSelection root={noteRoot} enabled />
|
||||
</div>
|
||||
{:else if hasAtLeastOneTranslation}
|
||||
<p class="text-md py-2 font-normal italic dark:text-white">
|
||||
{$t('course.navItem.lessons.materials.no_translation')}
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
<script lang="ts">
|
||||
import { browser } from '$app/environment';
|
||||
import { onMount } from 'svelte';
|
||||
import QuoteIcon from '@lucide/svelte/icons/quote';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { quoteInChat } from '$features/ai-assistant/utils/store';
|
||||
|
||||
interface Props {
|
||||
root: HTMLElement | undefined;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
let { root = undefined, enabled = false }: Props = $props();
|
||||
|
||||
let top = $state(0);
|
||||
let left = $state(0);
|
||||
let visible = $state(false);
|
||||
let picked = $state('');
|
||||
let dragging = false;
|
||||
let buttonEl: HTMLButtonElement | undefined = $state();
|
||||
|
||||
function reset() {
|
||||
visible = false;
|
||||
picked = '';
|
||||
}
|
||||
|
||||
function endRect(range: Range): DOMRect | null {
|
||||
const rects = range.getClientRects();
|
||||
|
||||
if (rects.length > 0) {
|
||||
return rects[rects.length - 1];
|
||||
}
|
||||
|
||||
const box = range.getBoundingClientRect();
|
||||
|
||||
return box.width > 0 || box.height > 0 ? box : null;
|
||||
}
|
||||
|
||||
function inRoot(rootEl: HTMLElement, node: Node | null): boolean {
|
||||
if (!node) return false;
|
||||
|
||||
const el = node.nodeType === Node.TEXT_NODE ? node.parentElement : (node as HTMLElement);
|
||||
|
||||
return el ? rootEl.contains(el) : false;
|
||||
}
|
||||
|
||||
/** Re-evaluate selection and either reposition / show the chip or hide it. */
|
||||
function evaluate() {
|
||||
if (!browser || !enabled || !root) {
|
||||
reset();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const sel = window.getSelection();
|
||||
|
||||
if (!sel || sel.rangeCount === 0 || sel.isCollapsed) {
|
||||
reset();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const range = sel.getRangeAt(0);
|
||||
|
||||
if (!inRoot(root, range.commonAncestorContainer) && !inRoot(root, sel.anchorNode) && !inRoot(root, sel.focusNode)) {
|
||||
reset();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const raw = sel.toString();
|
||||
|
||||
if (!raw.trim()) {
|
||||
reset();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = endRect(range);
|
||||
|
||||
if (!rect) {
|
||||
reset();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
picked = raw;
|
||||
visible = true;
|
||||
|
||||
const btn = 28;
|
||||
const pad = 6;
|
||||
const gap = 4;
|
||||
const desiredLeft = rect.right + gap;
|
||||
const desiredTop = rect.top + (rect.height - btn) / 2;
|
||||
|
||||
left = Math.min(Math.max(pad, desiredLeft), window.innerWidth - btn - pad);
|
||||
top = Math.max(pad, Math.min(desiredTop, window.innerHeight - btn - pad));
|
||||
}
|
||||
|
||||
/**
|
||||
* Selection-change handler: only used to *hide* the chip if the selection
|
||||
* collapses or moves out of the note. We never *show* the chip from here so
|
||||
* the chip can't appear (and thrash the DOM) mid-drag.
|
||||
*/
|
||||
function onSelectionChange() {
|
||||
if (!visible) return;
|
||||
|
||||
const sel = window.getSelection();
|
||||
|
||||
if (!sel || sel.isCollapsed || !sel.toString().trim()) {
|
||||
reset();
|
||||
}
|
||||
}
|
||||
|
||||
function onPointerDown(e: PointerEvent) {
|
||||
if (e.button !== 0) return;
|
||||
|
||||
const target = e.target as Node | null;
|
||||
|
||||
if (buttonEl && target && buttonEl.contains(target)) {
|
||||
return;
|
||||
}
|
||||
|
||||
dragging = true;
|
||||
|
||||
if (visible) reset();
|
||||
}
|
||||
|
||||
function onPointerUp() {
|
||||
if (!dragging) return;
|
||||
|
||||
dragging = false;
|
||||
evaluate();
|
||||
}
|
||||
|
||||
function onKeyUp(e: KeyboardEvent) {
|
||||
if (!e.shiftKey) return;
|
||||
|
||||
evaluate();
|
||||
}
|
||||
|
||||
function onScrollOrResize() {
|
||||
if (!visible) return;
|
||||
|
||||
evaluate();
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (!browser) return;
|
||||
|
||||
document.addEventListener('pointerdown', onPointerDown, true);
|
||||
document.addEventListener('pointerup', onPointerUp, true);
|
||||
document.addEventListener('keyup', onKeyUp);
|
||||
document.addEventListener('selectionchange', onSelectionChange);
|
||||
window.addEventListener('resize', onScrollOrResize);
|
||||
document.addEventListener('scroll', onScrollOrResize, true);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', onPointerDown, true);
|
||||
document.removeEventListener('pointerup', onPointerUp, true);
|
||||
document.removeEventListener('keyup', onKeyUp);
|
||||
document.removeEventListener('selectionchange', onSelectionChange);
|
||||
window.removeEventListener('resize', onScrollOrResize);
|
||||
document.removeEventListener('scroll', onScrollOrResize, true);
|
||||
};
|
||||
});
|
||||
|
||||
function submit(e: MouseEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const text = picked;
|
||||
reset();
|
||||
window.getSelection()?.removeAllRanges();
|
||||
quoteInChat(text);
|
||||
}
|
||||
|
||||
function portal(node: HTMLElement) {
|
||||
if (!browser) return;
|
||||
|
||||
document.body.appendChild(node);
|
||||
|
||||
return {
|
||||
destroy() {
|
||||
node.remove();
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
bind:this={buttonEl}
|
||||
use:portal
|
||||
title={t.get('ai_assistant.quote_lesson_note')}
|
||||
aria-label={t.get('ai_assistant.quote_lesson_note')}
|
||||
aria-hidden={!visible}
|
||||
tabindex={visible ? 0 : -1}
|
||||
onpointerdown={(e) => e.preventDefault()}
|
||||
onmousedown={(e) => e.preventDefault()}
|
||||
onclick={submit}
|
||||
style:position="fixed"
|
||||
style:top="{top}px"
|
||||
style:left="{left}px"
|
||||
style:z-index="2147483000"
|
||||
style:opacity={visible ? '1' : '0'}
|
||||
style:visibility={visible ? 'visible' : 'hidden'}
|
||||
style:pointer-events={visible ? 'auto' : 'none'}
|
||||
class="ui:bg-secondary ui:text-secondary-foreground ui:border-border ui:hover:bg-accent inline-flex h-7 w-7 cursor-pointer items-center justify-center rounded-full border shadow-md transition-opacity"
|
||||
>
|
||||
<QuoteIcon size={14} />
|
||||
</button>
|
||||
+9
-3
@@ -24,11 +24,17 @@
|
||||
|
||||
<Dialog.Root
|
||||
bind:open={$lessonVideoUpload.isModalOpen}
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen) onClose();
|
||||
onOpenChange={(details) => {
|
||||
if (!details.open && $lessonVideoUpload.isUploading) return;
|
||||
if (!details.open) onClose();
|
||||
}}
|
||||
>
|
||||
<Dialog.Content class="flex max-h-[680px] max-w-2xl! flex-col overflow-hidden">
|
||||
<Dialog.Content
|
||||
class="flex max-h-[680px] max-w-2xl! flex-col overflow-hidden"
|
||||
onCloseAutoFocus={(e) => {
|
||||
if ($lessonVideoUpload.isUploading) e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{$t('course.navItem.lessons.materials.tabs.video.add_video.title')}</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
import type { AssetTranscriptPayload } from '$features/media/utils/types';
|
||||
|
||||
type SeekFn = (seconds: number) => void;
|
||||
|
||||
class LessonVideoBus {
|
||||
/** Asset id of the currently mounted upload-video player (if any). */
|
||||
assetId = $state<string | null>(null);
|
||||
/** Last reported playback time in seconds. */
|
||||
currentTimeSeconds = $state(0);
|
||||
/** True after the user (or autoplay) has started playback at least once for the current asset. */
|
||||
hasPlayed = $state(false);
|
||||
/** Latest transcript payload for the active asset; null if none / still loading. */
|
||||
transcript = $state<AssetTranscriptPayload | null>(null);
|
||||
/** True while the transcript fetch is in flight. */
|
||||
transcriptLoading = $state(false);
|
||||
|
||||
private seekFn: SeekFn = () => {};
|
||||
|
||||
setSeekFn(seekFn: SeekFn) {
|
||||
this.seekFn = seekFn;
|
||||
}
|
||||
|
||||
seek(seconds: number) {
|
||||
this.seekFn(seconds);
|
||||
}
|
||||
|
||||
/** Reset all per-lesson state (called when the upload asset changes or the player unmounts). */
|
||||
reset(opts?: { keepAssetId?: boolean }) {
|
||||
if (!opts?.keepAssetId) {
|
||||
this.assetId = null;
|
||||
}
|
||||
|
||||
this.currentTimeSeconds = 0;
|
||||
this.hasPlayed = false;
|
||||
this.transcript = null;
|
||||
this.transcriptLoading = false;
|
||||
this.seekFn = () => {};
|
||||
}
|
||||
}
|
||||
|
||||
export const lessonVideoBus = new LessonVideoBus();
|
||||
+216
@@ -0,0 +1,216 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { MediaPlayer } from '@cio/ui/custom/media-player';
|
||||
import { mediaApi } from '$features/media/api';
|
||||
import { jobsApi, JobPoller, type MediaJobEnvelope } from '$features/jobs';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { sidePanel } from '$features/side-panel';
|
||||
import type { LessonVideo } from './video-card-utils';
|
||||
import { lessonVideoBus } from './lesson-video-bus.svelte';
|
||||
import { TRANSCRIPT_PANEL_ID } from './transcript-panel-definition';
|
||||
|
||||
interface Props {
|
||||
video: LessonVideo;
|
||||
}
|
||||
|
||||
let { video }: Props = $props();
|
||||
|
||||
// The parent keys this component on the upload assetId / link, so each
|
||||
// mount maps to a single asset; lifecycle is plain onMount/onDestroy.
|
||||
const uploadAssetId =
|
||||
video.type === 'upload' ? ((video as LessonVideo & { assetId?: string }).assetId ?? null) : null;
|
||||
|
||||
let isMounted = true;
|
||||
let vttRefetchTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let jobPoller: JobPoller<MediaJobEnvelope> | null = null;
|
||||
let hasAutoOpenedTranscript = false;
|
||||
|
||||
function clearVttRefetchTimer() {
|
||||
if (vttRefetchTimer) {
|
||||
clearTimeout(vttRefetchTimer);
|
||||
vttRefetchTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleVttRefetch(expiresAtIso: string | undefined) {
|
||||
clearVttRefetchTimer();
|
||||
if (!expiresAtIso) return;
|
||||
|
||||
const deadlineMs = new Date(expiresAtIso).getTime() - 60_000;
|
||||
const delayMs = deadlineMs - Date.now();
|
||||
|
||||
if (delayMs <= 0) {
|
||||
void loadTranscript();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
vttRefetchTimer = setTimeout(() => {
|
||||
void loadTranscript();
|
||||
}, delayMs);
|
||||
}
|
||||
|
||||
function stopJobPoller() {
|
||||
jobPoller?.stop();
|
||||
jobPoller = null;
|
||||
}
|
||||
|
||||
async function loadTranscript(): Promise<void> {
|
||||
if (!uploadAssetId) {
|
||||
if (isMounted) {
|
||||
lessonVideoBus.transcript = null;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isMounted) return;
|
||||
|
||||
lessonVideoBus.transcriptLoading = true;
|
||||
|
||||
try {
|
||||
const data = await mediaApi.getAssetTranscript(uploadAssetId);
|
||||
|
||||
if (!isMounted) return;
|
||||
|
||||
lessonVideoBus.transcript = data;
|
||||
scheduleVttRefetch(data?.vttUrlExpiresAt);
|
||||
} finally {
|
||||
if (isMounted) {
|
||||
lessonVideoBus.transcriptLoading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function watchJobsUntilTerminalThenReload(): Promise<void> {
|
||||
if (!uploadAssetId || lessonVideoBus.transcript || !isMounted) return;
|
||||
|
||||
stopJobPoller();
|
||||
|
||||
const runs = await jobsApi.getMediaJobsForAsset(uploadAssetId);
|
||||
|
||||
if (!isMounted) return;
|
||||
|
||||
const latest = runs?.[0];
|
||||
|
||||
if (!latest) return;
|
||||
|
||||
if (latest.job.status === 'completed') {
|
||||
void loadTranscript();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (latest.job.status === 'failed' || latest.job.status === 'canceled') {
|
||||
return;
|
||||
}
|
||||
|
||||
jobPoller = new JobPoller<MediaJobEnvelope>({
|
||||
fetch: () => jobsApi.getMediaJob(latest.job.id),
|
||||
onUpdate: (envelope) => {
|
||||
if (envelope.job.status === 'completed') {
|
||||
stopJobPoller();
|
||||
void loadTranscript();
|
||||
}
|
||||
|
||||
if (envelope.job.status === 'failed' || envelope.job.status === 'canceled') {
|
||||
stopJobPoller();
|
||||
}
|
||||
}
|
||||
});
|
||||
jobPoller.start();
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
lessonVideoBus.reset();
|
||||
lessonVideoBus.assetId = uploadAssetId;
|
||||
|
||||
void (async () => {
|
||||
await loadTranscript();
|
||||
if (!isMounted) return;
|
||||
|
||||
await watchJobsUntilTerminalThenReload();
|
||||
})();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
isMounted = false;
|
||||
clearVttRefetchTimer();
|
||||
stopJobPoller();
|
||||
lessonVideoBus.reset();
|
||||
sidePanel.closeIfScope('lesson');
|
||||
});
|
||||
|
||||
const tracks = $derived(
|
||||
lessonVideoBus.transcript?.vttUrl
|
||||
? [
|
||||
{
|
||||
kind: 'captions' as const,
|
||||
src: lessonVideoBus.transcript.vttUrl,
|
||||
srclang: lessonVideoBus.transcript.language,
|
||||
label: t.get('course.navItem.lessons.materials.tabs.video.transcript.captions_label'),
|
||||
default: false
|
||||
}
|
||||
]
|
||||
: []
|
||||
);
|
||||
|
||||
// Plyr can't add a <track> after construction, so when the captions URL
|
||||
// appears asynchronously we remount the player once. Include a stable key when
|
||||
// a transcript exists but VTT is not ready yet so optional controls (e.g. transcript panel) attach.
|
||||
const playerRemountKey = $derived.by(() => {
|
||||
if (!lessonVideoBus.transcript) return 'no-transcript';
|
||||
|
||||
return lessonVideoBus.transcript.vttUrl ?? 'transcript-awaiting-vtt';
|
||||
});
|
||||
|
||||
function handleFirstPlay() {
|
||||
lessonVideoBus.hasPlayed = true;
|
||||
|
||||
if (hasAutoOpenedTranscript) return;
|
||||
if (!lessonVideoBus.transcript?.segments?.length) return;
|
||||
|
||||
hasAutoOpenedTranscript = true;
|
||||
sidePanel.open(TRANSCRIPT_PANEL_ID);
|
||||
}
|
||||
|
||||
const transcriptPanelControl = $derived(
|
||||
lessonVideoBus.transcript
|
||||
? {
|
||||
label: t.get('course.navItem.lessons.materials.tabs.video.transcript.open_side_panel'),
|
||||
onClick: () => {
|
||||
sidePanel.open(TRANSCRIPT_PANEL_ID);
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="w-full">
|
||||
{#key playerRemountKey}
|
||||
<MediaPlayer
|
||||
source={{
|
||||
type: video.type,
|
||||
url: video.link,
|
||||
metadata: video.metadata as { thumbnailUrl?: string; title?: string } | undefined,
|
||||
tracks
|
||||
}}
|
||||
options={{
|
||||
maxHeight: '569px',
|
||||
width: '100%',
|
||||
controls: true,
|
||||
playsinline: true,
|
||||
onTimeUpdate: (seconds) => {
|
||||
lessonVideoBus.currentTimeSeconds = seconds;
|
||||
},
|
||||
onPlayerReady: (player) => {
|
||||
lessonVideoBus.setSeekFn((seconds) => {
|
||||
player.currentTime = seconds;
|
||||
});
|
||||
},
|
||||
onFirstPlay: handleFirstPlay,
|
||||
transcriptPanelControl
|
||||
}}
|
||||
/>
|
||||
{/key}
|
||||
</div>
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
<script lang="ts">
|
||||
import * as Avatar from '@cio/ui/base/avatar';
|
||||
import { Image } from '$features/ui';
|
||||
import VideoIcon from '@lucide/svelte/icons/video';
|
||||
import { ExternalLinkIcon, HoverableItem } from '@cio/ui/custom/moving-icons';
|
||||
import { courseApi } from '$features/course/api';
|
||||
import VideoCardDropdown from './video-card-dropdown.svelte';
|
||||
import {
|
||||
getVideoThumbnailUrl,
|
||||
getVideoTitle,
|
||||
getVideoDurationSeconds,
|
||||
formatVideoDuration,
|
||||
getVideoCreatedAt,
|
||||
formatVideoCreatedAt,
|
||||
type LessonVideo
|
||||
} from './video-card-utils';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
interface Props {
|
||||
video: LessonVideo;
|
||||
index: number;
|
||||
isEditMode: boolean;
|
||||
onRemove: () => void;
|
||||
}
|
||||
|
||||
let { video, index, isEditMode, onRemove }: Props = $props();
|
||||
|
||||
const thumbnailUrl = $derived(getVideoThumbnailUrl(video));
|
||||
const title = $derived(getVideoTitle(video, index));
|
||||
const durationSeconds = $derived(getVideoDurationSeconds(video));
|
||||
const durationFormatted = $derived(formatVideoDuration(durationSeconds ?? undefined));
|
||||
const createdAtIso = $derived(getVideoCreatedAt(video));
|
||||
const createdAtFormatted = $derived(formatVideoCreatedAt(createdAtIso ?? undefined));
|
||||
|
||||
const isYoutubeWithLink = $derived(video.type === 'youtube' && !!video.link);
|
||||
|
||||
const courseLogoSrc = $derived.by(() => {
|
||||
const raw = courseApi.course?.logo;
|
||||
|
||||
if (typeof raw === 'string' && raw.trim().length > 0) {
|
||||
return raw.trim();
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
const channelLine = $derived.by(() => {
|
||||
const courseTitle = courseApi.course?.title?.trim();
|
||||
|
||||
if (courseTitle) {
|
||||
return courseTitle;
|
||||
}
|
||||
|
||||
return t.get('course.navItem.lessons.materials.tabs.video.simple_card.course_fallback');
|
||||
});
|
||||
|
||||
const sourceKindLabel = $derived.by(() => {
|
||||
const key =
|
||||
video.type === 'youtube'
|
||||
? 'kind_youtube'
|
||||
: video.type === 'upload'
|
||||
? 'kind_upload'
|
||||
: video.type === 'google_drive'
|
||||
? 'kind_google_drive'
|
||||
: 'kind_generic';
|
||||
|
||||
return t.get(`course.navItem.lessons.materials.tabs.video.simple_card.${key}`);
|
||||
});
|
||||
|
||||
const metaLine = $derived.by(() => {
|
||||
if (createdAtFormatted) {
|
||||
return `${sourceKindLabel} · ${createdAtFormatted}`;
|
||||
}
|
||||
|
||||
return sourceKindLabel;
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- YouTube-like: thumbnail on top; avatar + stacked text + overflow menu -->
|
||||
<div class="group w-full max-w-full min-w-0 {isEditMode ? 'rounded-lg border' : ''}">
|
||||
<div class="ui:bg-muted relative aspect-video w-full min-w-0 overflow-hidden rounded-xl">
|
||||
{#if thumbnailUrl}
|
||||
<Image src={thumbnailUrl} alt={title} className="absolute inset-0 block h-full w-full object-cover" />
|
||||
{:else}
|
||||
<div
|
||||
class="flex h-full min-h-0 w-full flex-col items-center justify-center gap-2 px-3 py-4"
|
||||
role="img"
|
||||
aria-label={title}
|
||||
>
|
||||
<VideoIcon class="ui:text-muted-foreground size-12 shrink-0" />
|
||||
<span class="ui:text-muted-foreground line-clamp-2 max-w-full text-center text-xs font-medium">
|
||||
{title}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if durationFormatted}
|
||||
<div class="absolute right-2 bottom-2 rounded bg-black/80 px-1.5 py-0.5 text-xs font-medium text-white">
|
||||
{durationFormatted}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="mt-3 flex min-w-0 gap-3 px-3 py-3">
|
||||
<Avatar.Root class="mt-0.5 size-9 shrink-0">
|
||||
{#if courseLogoSrc}
|
||||
<Avatar.Image src={courseLogoSrc} alt={channelLine} />
|
||||
{/if}
|
||||
<Avatar.Fallback>
|
||||
<VideoIcon class="ui:text-muted-foreground size-4" />
|
||||
</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-start gap-1">
|
||||
<div class="ui:text-foreground min-w-0 flex-1 text-base leading-snug font-semibold" {title}>
|
||||
{#if isYoutubeWithLink}
|
||||
<HoverableItem class="block min-w-0">
|
||||
{#snippet children(isHovered)}
|
||||
<a
|
||||
href={video.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="hover:ui:underline focus:ui:underline focus:ui:outline-none flex min-w-0 items-start gap-1.5"
|
||||
>
|
||||
<span class="line-clamp-2 min-w-0 flex-1 break-words">{title}</span>
|
||||
<ExternalLinkIcon
|
||||
{isHovered}
|
||||
size={14}
|
||||
class="ui:text-muted-foreground mt-0.5 shrink-0"
|
||||
ariaHidden={true}
|
||||
/>
|
||||
</a>
|
||||
{/snippet}
|
||||
</HoverableItem>
|
||||
{:else}
|
||||
<p class="line-clamp-2 min-w-0 break-words">{title}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{#if isEditMode}
|
||||
<VideoCardDropdown {video} {onRemove} menuPlacement="inline" />
|
||||
{/if}
|
||||
</div>
|
||||
<p class="ui:text-muted-foreground mt-0.5 line-clamp-1 text-sm leading-snug">
|
||||
{channelLine}
|
||||
</p>
|
||||
<p class="ui:text-muted-foreground mt-0.5 line-clamp-1 text-sm leading-snug">{metaLine}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
import type { Component } from 'svelte';
|
||||
|
||||
import type { SidePanelDefinition } from '$features/side-panel';
|
||||
import TranscriptSidePanel from './transcript-side-panel.svelte';
|
||||
|
||||
export const TRANSCRIPT_PANEL_ID = 'transcript';
|
||||
|
||||
export const transcriptPanelDefinition: SidePanelDefinition = {
|
||||
id: TRANSCRIPT_PANEL_ID,
|
||||
titleKey: 'course.navItem.lessons.materials.tabs.video.transcript.title',
|
||||
scope: 'lesson',
|
||||
component: TranscriptSidePanel as Component<Record<string, unknown>>,
|
||||
defaultWidth: 380,
|
||||
minWidth: 320,
|
||||
maxWidth: 560,
|
||||
widthStorageKey: 'transcript-panel-width'
|
||||
};
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
<script lang="ts">
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import type { AssetTranscriptPayload } from '$features/media/utils/types';
|
||||
|
||||
type Segment = AssetTranscriptPayload['segments'][number];
|
||||
|
||||
interface Props {
|
||||
segments: Segment[];
|
||||
currentTimeSeconds: number;
|
||||
language: string;
|
||||
onSeek: (seconds: number) => void;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { segments, currentTimeSeconds, language, onSeek, class: className = '' }: Props = $props();
|
||||
|
||||
const transcriptTitleKey = 'course.navItem.lessons.materials.tabs.video.transcript.title';
|
||||
const transcriptLanguageKey = 'course.navItem.lessons.materials.tabs.video.transcript.language';
|
||||
|
||||
function isActiveSegment(segment: Segment, index: number): boolean {
|
||||
const isLast = index === segments.length - 1;
|
||||
|
||||
if (isLast) {
|
||||
return currentTimeSeconds >= segment.start && currentTimeSeconds <= segment.end;
|
||||
}
|
||||
|
||||
return currentTimeSeconds >= segment.start && currentTimeSeconds < segment.end;
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent, start: number) {
|
||||
if (event.key !== 'Enter' && event.key !== ' ') return;
|
||||
|
||||
event.preventDefault();
|
||||
onSeek(start);
|
||||
}
|
||||
</script>
|
||||
|
||||
<aside
|
||||
class="ui:border-border flex min-h-[200px] flex-col rounded-md border p-3 {className}"
|
||||
aria-label={t.get(transcriptTitleKey)}
|
||||
>
|
||||
<div class="ui:text-muted-foreground mb-2 shrink-0 text-xs font-medium">
|
||||
{t.get(transcriptLanguageKey, { language })}
|
||||
</div>
|
||||
|
||||
<div class="min-h-0 flex-1 overflow-y-auto pr-1">
|
||||
{#each segments as segment, index (segment.start + segment.end + index)}
|
||||
<button
|
||||
type="button"
|
||||
class="ui:text-foreground hover:ui:bg-muted/80 focus:ui:outline-none focus-visible:ui:ring-2 focus-visible:ui:ring-ring mb-1 w-full rounded-md px-2 py-1.5 text-left text-sm leading-snug transition-colors last:mb-0 {isActiveSegment(
|
||||
segment,
|
||||
index
|
||||
)
|
||||
? 'ui:bg-primary/10 ui:font-medium'
|
||||
: ''}"
|
||||
data-active={isActiveSegment(segment, index) ? 'true' : undefined}
|
||||
onclick={() => onSeek(segment.start)}
|
||||
onkeydown={(e) => handleKeydown(e, segment.start)}
|
||||
>
|
||||
<span class="ui:text-muted-foreground mr-1.5 text-xs tabular-nums">
|
||||
{Math.floor(segment.start / 60)}:{Math.floor(segment.start % 60)
|
||||
.toString()
|
||||
.padStart(2, '0')}
|
||||
</span>
|
||||
{segment.text}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</aside>
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
<script lang="ts">
|
||||
import XIcon from '@lucide/svelte/icons/x';
|
||||
import CaptionsIcon from '@lucide/svelte/icons/captions';
|
||||
import { IconButton } from '@cio/ui/custom/icon-button';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { sidePanel } from '$features/side-panel';
|
||||
import { lessonVideoBus } from './lesson-video-bus.svelte';
|
||||
import type { AssetTranscriptPayload } from '$features/media/utils/types';
|
||||
|
||||
type Segment = AssetTranscriptPayload['segments'][number];
|
||||
|
||||
const transcript = $derived(lessonVideoBus.transcript);
|
||||
const transcriptLoading = $derived(lessonVideoBus.transcriptLoading);
|
||||
const segments = $derived<Segment[]>(transcript?.segments ?? []);
|
||||
const language = $derived(transcript?.language ?? '');
|
||||
const currentTimeSeconds = $derived(lessonVideoBus.currentTimeSeconds);
|
||||
|
||||
function isActiveSegment(segment: Segment, index: number): boolean {
|
||||
const isLast = index === segments.length - 1;
|
||||
|
||||
if (isLast) {
|
||||
return currentTimeSeconds >= segment.start && currentTimeSeconds <= segment.end;
|
||||
}
|
||||
|
||||
return currentTimeSeconds >= segment.start && currentTimeSeconds < segment.end;
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent, start: number) {
|
||||
if (event.key !== 'Enter' && event.key !== ' ') return;
|
||||
|
||||
event.preventDefault();
|
||||
lessonVideoBus.seek(start);
|
||||
}
|
||||
|
||||
function formatTimestamp(seconds: number): string {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = Math.floor(seconds % 60)
|
||||
.toString()
|
||||
.padStart(2, '0');
|
||||
|
||||
return `${m}:${s}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex min-h-0 flex-1 flex-col">
|
||||
<header class="border-border flex items-center justify-between gap-2 border-b px-4 py-3">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<CaptionsIcon size={18} class="ui:text-primary shrink-0" />
|
||||
<div class="min-w-0">
|
||||
<h2 class="text-sm font-semibold">
|
||||
{$t('course.navItem.lessons.materials.tabs.video.transcript.title')}
|
||||
</h2>
|
||||
{#if language}
|
||||
<p class="ui:text-muted-foreground truncate text-xs">
|
||||
{$t('course.navItem.lessons.materials.tabs.video.transcript.language', { language })}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<IconButton
|
||||
onclick={() => sidePanel.close()}
|
||||
variant="outline"
|
||||
size="icon-xs"
|
||||
tooltip={$t('side_panel.close_aria')}
|
||||
>
|
||||
<XIcon size={16} />
|
||||
</IconButton>
|
||||
</header>
|
||||
|
||||
<div class="min-h-0 flex-1 overflow-y-auto p-3">
|
||||
{#if segments.length}
|
||||
{#each segments as segment, index (segment.start + segment.end + index)}
|
||||
{@const active = isActiveSegment(segment, index)}
|
||||
<button
|
||||
type="button"
|
||||
class="ui:text-foreground hover:ui:bg-muted/80 focus-visible:ui:ring-ring mb-1 w-full rounded-md px-2 py-1.5 text-left text-sm leading-snug transition-colors last:mb-0 focus:outline-none focus-visible:ring-2 {active
|
||||
? 'ui:bg-primary/10 ui:font-medium'
|
||||
: ''}"
|
||||
data-active={active ? 'true' : undefined}
|
||||
onclick={() => lessonVideoBus.seek(segment.start)}
|
||||
onkeydown={(e) => handleKeydown(e, segment.start)}
|
||||
>
|
||||
<span class="ui:text-muted-foreground mr-1.5 text-xs tabular-nums">
|
||||
{formatTimestamp(segment.start)}
|
||||
</span>
|
||||
{segment.text}
|
||||
</button>
|
||||
{/each}
|
||||
{:else if transcriptLoading}
|
||||
<p class="ui:text-muted-foreground py-8 text-center text-sm">
|
||||
{$t('course.navItem.lessons.materials.tabs.video.transcript.loading')}
|
||||
</p>
|
||||
{:else}
|
||||
<p class="ui:text-muted-foreground py-8 text-center text-sm">
|
||||
{$t('course.navItem.lessons.materials.tabs.video.transcript.empty')}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -10,8 +10,11 @@
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import * as FileDropZone from '@cio/ui/custom/file-drop-zone';
|
||||
import { mediaApi } from '$features/media/api';
|
||||
import { JobPoller, jobsApi, type MediaJobEnvelope } from '$features/jobs';
|
||||
import { onDestroy } from 'svelte';
|
||||
|
||||
const ADD_VIDEO = 'course.navItem.lessons.materials.tabs.video.add_video';
|
||||
const PROCESSING_BASE = `${ADD_VIDEO}.processing`;
|
||||
|
||||
interface Props {
|
||||
lessonId?: string;
|
||||
@@ -31,12 +34,64 @@
|
||||
} | null = $state(null);
|
||||
let isLoaded = $state(false);
|
||||
let fileName = $state('');
|
||||
let processingJob: MediaJobEnvelope['job'] | null = $state(null);
|
||||
let activePoller: JobPoller<MediaJobEnvelope> | null = null;
|
||||
|
||||
const isDisabled = $derived($lessonVideoUpload.isUploading || $isFreePlan);
|
||||
|
||||
const videoUploader = new VideoUploader();
|
||||
const mediaUploader = new MediaUploader();
|
||||
|
||||
function processingLabel(stage: string | null, status: string): string | null {
|
||||
if (status === 'failed') return t.get(`${PROCESSING_BASE}.failed`);
|
||||
if (status === 'canceled') return t.get(`${PROCESSING_BASE}.canceled`);
|
||||
if (status === 'completed') return t.get(`${PROCESSING_BASE}.completed`);
|
||||
|
||||
switch (stage) {
|
||||
case 'probing':
|
||||
return t.get(`${PROCESSING_BASE}.probing`);
|
||||
case 'thumbnailing':
|
||||
return t.get(`${PROCESSING_BASE}.thumbnailing`);
|
||||
case 'extracting-audio':
|
||||
return t.get(`${PROCESSING_BASE}.extracting_audio`);
|
||||
case 'transcribing':
|
||||
return t.get(`${PROCESSING_BASE}.transcribing`);
|
||||
case 'compressing':
|
||||
return t.get(`${PROCESSING_BASE}.compressing`);
|
||||
default:
|
||||
return t.get(`${PROCESSING_BASE}.queued`);
|
||||
}
|
||||
}
|
||||
|
||||
function startProcessingPoll(assetId: string) {
|
||||
activePoller?.stop();
|
||||
activePoller = null;
|
||||
processingJob = null;
|
||||
|
||||
void (async () => {
|
||||
const runs = await jobsApi.getMediaJobsForAsset(assetId);
|
||||
const latest = runs?.[0];
|
||||
if (!latest) return;
|
||||
|
||||
processingJob = latest.job;
|
||||
if (latest.job.status === 'completed' || latest.job.status === 'failed' || latest.job.status === 'canceled') {
|
||||
return;
|
||||
}
|
||||
|
||||
activePoller = new JobPoller<MediaJobEnvelope>({
|
||||
fetch: () => jobsApi.getMediaJob(latest.job.id),
|
||||
onUpdate: (envelope) => {
|
||||
processingJob = envelope.job;
|
||||
}
|
||||
});
|
||||
activePoller.start();
|
||||
})();
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
activePoller?.stop();
|
||||
});
|
||||
|
||||
async function handleUpload(files: File[]) {
|
||||
const videoFile = files[0];
|
||||
if (!videoFile) return;
|
||||
@@ -149,6 +204,10 @@
|
||||
{ append: true }
|
||||
);
|
||||
|
||||
if (assetId) {
|
||||
startProcessingPoll(assetId);
|
||||
}
|
||||
|
||||
isLoaded = false;
|
||||
} catch (err: any) {
|
||||
console.error('Error uploading video', err, '\n\n', err.response);
|
||||
@@ -217,13 +276,13 @@
|
||||
<div
|
||||
class="border-primary-300 flex h-full w-full flex-col items-center justify-center rounded-xl border border-dashed"
|
||||
>
|
||||
<div class="flex w-[60%] max-w-[500px] flex-col justify-center gap-5">
|
||||
<p class="mt-5 text-center">
|
||||
<div class="flex w-full max-w-[500px] flex-col items-center gap-5 px-6 text-center">
|
||||
<p>
|
||||
{$t(`${ADD_VIDEO}.uploading`)}
|
||||
</p>
|
||||
<Progress class="w-full" value={$lessonVideoUpload.uploadProgress} max={100} />
|
||||
<p class="text-sm">{helperText}</p>
|
||||
<div class="mt-3">
|
||||
<div class="mt-3 flex justify-center">
|
||||
<Button variant="outline" onclick={cancelUpload}>
|
||||
{$t(`${ADD_VIDEO}.cancel_upload`)}
|
||||
</Button>
|
||||
@@ -236,7 +295,7 @@
|
||||
accept="video/*"
|
||||
maxFiles={1}
|
||||
fileCount={0}
|
||||
maxFileSize={500 * FileDropZone.MEGABYTE}
|
||||
maxFileSize={800 * FileDropZone.MEGABYTE}
|
||||
onUpload={handleUpload}
|
||||
{onFileRejected}
|
||||
>
|
||||
@@ -299,6 +358,15 @@
|
||||
</video>
|
||||
{/if}
|
||||
<p>{fileName}</p>
|
||||
{#if processingJob}
|
||||
{@const label = processingLabel(processingJob.stage, processingJob.status)}
|
||||
{#if label}
|
||||
<p class="ui:text-muted-foreground text-xs">{label}</p>
|
||||
{#if processingJob.status !== 'completed' && processingJob.status !== 'failed' && processingJob.status !== 'canceled'}
|
||||
<Progress class="w-[200px]" value={processingJob.progressPercent ?? 0} max={100} />
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
<Button onclick={() => ($lessonVideoUpload.isModalOpen = false)}>
|
||||
{$t('course.navItem.lessons.materials.button_done')}
|
||||
|
||||
+97
-3
@@ -2,25 +2,119 @@
|
||||
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
|
||||
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
|
||||
import { IconButton } from '@cio/ui/custom/icon-button';
|
||||
import { mediaApi } from '$features/media/api';
|
||||
import { JobPoller, jobsApi, type MediaJobEnvelope } from '$features/jobs';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { onDestroy } from 'svelte';
|
||||
import type { LessonVideo } from './video-card-utils';
|
||||
|
||||
interface Props {
|
||||
video: LessonVideo;
|
||||
onRemove: () => void;
|
||||
/** `inline` aligns with the title row (YouTube-style). `corner` is top-right overlay on the nearest `relative` ancestor. */
|
||||
menuPlacement?: 'inline' | 'corner';
|
||||
}
|
||||
|
||||
let { onRemove }: Props = $props();
|
||||
let { video, onRemove, menuPlacement = 'inline' }: Props = $props();
|
||||
|
||||
let isTranscribing = $state(false);
|
||||
let activePoller: JobPoller<MediaJobEnvelope> | null = null;
|
||||
|
||||
const assetId = $derived((video as LessonVideo & { assetId?: string }).assetId ?? null);
|
||||
|
||||
const canGenerateTranscript = $derived(video.type === 'upload' && !!assetId);
|
||||
|
||||
onDestroy(() => {
|
||||
activePoller?.stop();
|
||||
});
|
||||
|
||||
async function startTranscriptionPoll(afterAssetId: string) {
|
||||
activePoller?.stop();
|
||||
activePoller = null;
|
||||
|
||||
const runs = await jobsApi.getMediaJobsForAsset(afterAssetId);
|
||||
const latest = runs?.[0];
|
||||
|
||||
if (!latest) {
|
||||
isTranscribing = false;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (latest.job.status === 'completed') {
|
||||
isTranscribing = false;
|
||||
snackbar.success('snackbar.media_manager.transcription_completed');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (latest.job.status === 'failed' || latest.job.status === 'canceled') {
|
||||
isTranscribing = false;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
activePoller = new JobPoller<MediaJobEnvelope>({
|
||||
fetch: () => jobsApi.getMediaJob(latest.job.id),
|
||||
onUpdate: (envelope) => {
|
||||
if (envelope.job.status === 'completed') {
|
||||
activePoller?.stop();
|
||||
activePoller = null;
|
||||
isTranscribing = false;
|
||||
snackbar.success('snackbar.media_manager.transcription_completed');
|
||||
}
|
||||
|
||||
if (envelope.job.status === 'failed' || envelope.job.status === 'canceled') {
|
||||
activePoller?.stop();
|
||||
activePoller = null;
|
||||
isTranscribing = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
activePoller.start();
|
||||
}
|
||||
|
||||
async function handleGenerateTranscript() {
|
||||
if (!assetId) return;
|
||||
|
||||
const ok = await mediaApi.generateTranscript(assetId);
|
||||
if (!ok) return;
|
||||
|
||||
isTranscribing = true;
|
||||
await startTranscriptionPoll(assetId);
|
||||
}
|
||||
|
||||
const generateLabel = $derived(
|
||||
isTranscribing
|
||||
? t.get('course.navItem.lessons.materials.tabs.video.generate_transcript_in_progress')
|
||||
: t.get('course.navItem.lessons.materials.tabs.video.generate_transcript')
|
||||
);
|
||||
</script>
|
||||
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger
|
||||
class="ui:data-[state=open]:opacity-100 absolute top-2 right-2 z-40 flex items-center justify-center opacity-0 transition-all delay-150 duration-200 ease-in-out group-hover:opacity-100"
|
||||
class={menuPlacement === 'corner'
|
||||
? 'ui:data-[state=open]:opacity-100 absolute top-2 right-2 z-40 flex items-center justify-center opacity-0 transition-all delay-150 duration-200 ease-in-out group-hover:opacity-100'
|
||||
: 'ui:data-[state=open]:opacity-100 -mt-0.5 -mr-1 flex shrink-0 items-center justify-center opacity-100'}
|
||||
aria-label={$t('course.navItem.lessons.materials.tabs.video.simple_card.menu_aria')}
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<IconButton variant="outline">
|
||||
<IconButton variant="ghost">
|
||||
<EllipsisVerticalIcon size={16} />
|
||||
</IconButton>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
{#if canGenerateTranscript}
|
||||
<DropdownMenu.Item
|
||||
disabled={isTranscribing}
|
||||
onclick={() => {
|
||||
void handleGenerateTranscript();
|
||||
}}
|
||||
>
|
||||
{generateLabel}
|
||||
</DropdownMenu.Item>
|
||||
{/if}
|
||||
<DropdownMenu.Item class="ui:text-red-600" onclick={onRemove}>
|
||||
{$t('course.navItem.lessons.materials.tabs.video.remove_video')}
|
||||
</DropdownMenu.Item>
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
<script lang="ts">
|
||||
import * as Item from '@cio/ui/base/item';
|
||||
import { Image } from '$features/ui';
|
||||
import VideoIcon from '@lucide/svelte/icons/video';
|
||||
import { ExternalLinkIcon, HoverableItem } from '@cio/ui/custom/moving-icons';
|
||||
import VideoCardDropdown from './video-card-dropdown.svelte';
|
||||
import {
|
||||
getVideoThumbnailUrl,
|
||||
getVideoTitle,
|
||||
getVideoDurationSeconds,
|
||||
formatVideoDuration,
|
||||
getVideoCreatedAt,
|
||||
formatVideoCreatedAt,
|
||||
type LessonVideo
|
||||
} from './video-card-utils';
|
||||
|
||||
interface Props {
|
||||
video: LessonVideo;
|
||||
index: number;
|
||||
isEditMode: boolean;
|
||||
onRemove: () => void;
|
||||
}
|
||||
|
||||
let { video, index, isEditMode, onRemove }: Props = $props();
|
||||
|
||||
const thumbnailUrl = $derived(getVideoThumbnailUrl(video));
|
||||
const title = $derived(getVideoTitle(video, index));
|
||||
const durationSeconds = $derived(getVideoDurationSeconds(video));
|
||||
const durationFormatted = $derived(formatVideoDuration(durationSeconds ?? undefined));
|
||||
const createdAtIso = $derived(getVideoCreatedAt(video));
|
||||
const createdAtFormatted = $derived(formatVideoCreatedAt(createdAtIso ?? undefined));
|
||||
|
||||
const isYoutubeWithLink = $derived(video.type === 'youtube' && !!video.link);
|
||||
</script>
|
||||
|
||||
<Item.Root variant="outline" class="group relative max-w-[320px] cursor-default! p-3!">
|
||||
<div class="flex flex-col">
|
||||
{#if isEditMode}
|
||||
<VideoCardDropdown {onRemove} />
|
||||
{/if}
|
||||
|
||||
<Item.Header>
|
||||
<Item.Media variant="image" class="relative h-50! w-full! overflow-hidden rounded-sm">
|
||||
{#if thumbnailUrl}
|
||||
<Image src={thumbnailUrl} alt={title} className="h-full w-full object-cover" />
|
||||
{:else}
|
||||
<div
|
||||
class="ui:bg-muted flex h-full w-full flex-col items-center justify-center gap-2 px-3 py-4"
|
||||
role="img"
|
||||
aria-label={title}
|
||||
>
|
||||
<VideoIcon class="ui:text-muted-foreground size-10 shrink-0" />
|
||||
<span class="ui:text-muted-foreground line-clamp-2 max-w-full text-center text-xs font-medium">
|
||||
{title}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if durationFormatted}
|
||||
<div class="absolute right-2 bottom-2 rounded bg-black/70 px-1.5 py-0.5 text-xs font-medium text-white">
|
||||
{durationFormatted}
|
||||
</div>
|
||||
{/if}
|
||||
</Item.Media>
|
||||
</Item.Header>
|
||||
|
||||
<Item.Content>
|
||||
<Item.Title class="line-clamp-2 min-h-[44px] text-base!" {title}>
|
||||
{#if isYoutubeWithLink}
|
||||
<HoverableItem>
|
||||
{#snippet children(isHovered)}
|
||||
<a
|
||||
href={video.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="ui:text-foreground hover:ui:underline focus:ui:underline focus:ui:outline-none inline-flex items-center gap-1.5"
|
||||
>
|
||||
<span class="line-clamp-2">{title}</span>
|
||||
<ExternalLinkIcon {isHovered} size={14} class="ui:text-muted-foreground shrink-0" ariaHidden={true} />
|
||||
</a>
|
||||
{/snippet}
|
||||
</HoverableItem>
|
||||
{:else}
|
||||
{title}
|
||||
{/if}
|
||||
</Item.Title>
|
||||
<p class="ui:text-muted-foreground mt-1 text-xs">
|
||||
{#if createdAtFormatted && durationFormatted}
|
||||
{createdAtFormatted} · {durationFormatted}
|
||||
{:else if createdAtFormatted}
|
||||
{createdAtFormatted}
|
||||
{:else if durationFormatted}
|
||||
{durationFormatted}
|
||||
{:else}
|
||||
–
|
||||
{/if}
|
||||
</p>
|
||||
</Item.Content>
|
||||
</div>
|
||||
</Item.Root>
|
||||
@@ -1,6 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { lessonApi } from '$features/course/api';
|
||||
import { MediaPlayer } from '@cio/ui/custom/media-player';
|
||||
import { DeleteModal } from '$features/ui';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { Empty } from '@cio/ui/custom/empty';
|
||||
@@ -9,7 +8,8 @@
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import MODES from '$lib/utils/constants/mode';
|
||||
import VideoIcon from '@lucide/svelte/icons/video';
|
||||
import VideoCard from './video-card.svelte';
|
||||
import LessonVideoSimpleCard from './lesson-video-simple-card.svelte';
|
||||
import LessonVideoPlayer from './lesson-video-player.svelte';
|
||||
|
||||
interface Props {
|
||||
mode?: (typeof MODES)[keyof typeof MODES];
|
||||
@@ -41,20 +41,8 @@
|
||||
</script>
|
||||
|
||||
{#snippet content(video)}
|
||||
{#key video.link}
|
||||
<MediaPlayer
|
||||
source={{
|
||||
type: video.type,
|
||||
url: video.link,
|
||||
metadata: video.metadata
|
||||
}}
|
||||
options={{
|
||||
maxHeight: '569px',
|
||||
width: '100%',
|
||||
controls: true,
|
||||
playsinline: true
|
||||
}}
|
||||
/>
|
||||
{#key video.type === 'upload' ? ((video as typeof video & { assetId?: string }).assetId ?? video.link) : video.link}
|
||||
<LessonVideoPlayer {video} />
|
||||
{/key}
|
||||
{/snippet}
|
||||
|
||||
@@ -65,9 +53,9 @@
|
||||
</Button>
|
||||
|
||||
{#if videos.length}
|
||||
<Item.Group class="grid! w-full grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
<Item.Group class="grid! w-full grid-cols-1 gap-x-5 gap-y-8 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#each videos as video, index}
|
||||
<VideoCard {video} {index} isEditMode={true} onRemove={() => requestRemoveVideo(index)} />
|
||||
<LessonVideoSimpleCard {video} {index} isEditMode={true} onRemove={() => requestRemoveVideo(index)} />
|
||||
{/each}
|
||||
</Item.Group>
|
||||
{:else}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
<script lang="ts">
|
||||
import * as Avatar from '@cio/ui/base/avatar';
|
||||
import * as Sidebar from '@cio/ui/base/sidebar';
|
||||
import { Skeleton } from '@cio/ui/base/skeleton';
|
||||
import { resolve } from '$app/paths';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
import { shortenName } from '$lib/utils/functions/string';
|
||||
import { courseApi } from '$features/course/api';
|
||||
import { getNavItemRoute } from '$features/course/utils/functions';
|
||||
|
||||
const courseTitle = $derived(courseApi.course?.title);
|
||||
const courseId = $derived(courseApi.course?.id);
|
||||
</script>
|
||||
|
||||
<Sidebar.Menu>
|
||||
<Sidebar.MenuItem>
|
||||
<Sidebar.MenuButton
|
||||
size="sm"
|
||||
class="ui:data-[state=open]:bg-sidebar-accent ui:data-[state=open]:text-sidebar-accent-foreground"
|
||||
>
|
||||
{#snippet child({ props })}
|
||||
<a href={courseId ? resolve(getNavItemRoute(courseId), {}) : '#'} {...props}>
|
||||
{#if $currentOrg.name}
|
||||
<Avatar.Root class="flex size-6! items-center justify-center rounded-md!">
|
||||
<Avatar.Image src={$currentOrg.avatarUrl} alt={$currentOrg.name} />
|
||||
<Avatar.Fallback class="rounded-md! text-xs">{shortenName($currentOrg.name)}</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
{#if courseTitle}
|
||||
<span class="truncate text-sm font-medium">{courseTitle}</span>
|
||||
{:else}
|
||||
<Skeleton class="h-4 w-24" />
|
||||
{/if}
|
||||
{:else}
|
||||
<Skeleton class="h-4 w-24" />
|
||||
{/if}
|
||||
</a>
|
||||
{/snippet}
|
||||
</Sidebar.MenuButton>
|
||||
</Sidebar.MenuItem>
|
||||
</Sidebar.Menu>
|
||||
+13
@@ -8,6 +8,7 @@
|
||||
import { BackButton } from '@cio/ui';
|
||||
|
||||
import TableOfContentsIcon from '@lucide/svelte/icons/table-of-contents';
|
||||
import BotIcon from '@lucide/svelte/icons/bot';
|
||||
import {
|
||||
AnalyticsIcon,
|
||||
AttendanceIcon,
|
||||
@@ -176,6 +177,16 @@
|
||||
},
|
||||
icon: getNavIcon(NAV_IDS.PEOPLE)
|
||||
},
|
||||
{
|
||||
id: NAV_IDS.AI_ASSISTANT,
|
||||
title: $t('course.navItems.nav_ai_tutor'),
|
||||
url: getNavItemRoute(id, 'ai-tutor'),
|
||||
isActive: (path || page.url.pathname) === getNavItemRoute(id, 'ai-tutor'),
|
||||
show() {
|
||||
return !isStudent;
|
||||
},
|
||||
icon: getNavIcon(NAV_IDS.AI_ASSISTANT)
|
||||
},
|
||||
{
|
||||
id: NAV_IDS.SETTINGS,
|
||||
title: $t('course.navItems.nav_settings'),
|
||||
@@ -246,6 +257,8 @@
|
||||
return CertificateIcon;
|
||||
} else if (id === NAV_IDS.SETTINGS) {
|
||||
return SettingsIcon;
|
||||
} else if (id === NAV_IDS.AI_ASSISTANT) {
|
||||
return BotIcon;
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
@@ -6,12 +6,11 @@
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { orgs } from '$lib/utils/store/org';
|
||||
import { isOrgStudent } from '$lib/utils/store/app';
|
||||
import { isFreePlan } from '$lib/utils/store/org';
|
||||
|
||||
import OrgLogo from '$features/ui/sidebar/org-sidebar/org-logo.svelte';
|
||||
import CourseSidebarLogo from './course-sidebar-logo.svelte';
|
||||
import Navigation from './course-sidebar-navigation.svelte';
|
||||
import SidebarSkeleton from '$features/ui/sidebar/sidebar-skeleton.svelte';
|
||||
import UpgradePoweredBy from '$features/ui/upgrade-powered-by.svelte';
|
||||
import PoweredBy from '$features/ui/powered-by.svelte';
|
||||
import { courseApi } from '$features/course/api';
|
||||
import { useSidebar } from '@cio/ui/base/sidebar';
|
||||
import { startResizablePanelDrag } from '$lib/utils/functions/resizable-panel';
|
||||
import { COURSE_SIDEBAR_DEFAULT_WIDTH, COURSE_SIDEBAR_MAX_WIDTH, COURSE_SIDEBAR_MIN_WIDTH } from './constants';
|
||||
@@ -45,6 +44,8 @@
|
||||
let shouldIgnoreRailClick = $state(false);
|
||||
let stopSidebarResize: (() => void) | null = null;
|
||||
|
||||
const attributionCourseSlug = $derived(courseApi.course?.slug ?? null);
|
||||
|
||||
function clampSidebarWidth(width: number) {
|
||||
return Math.min(COURSE_SIDEBAR_MAX_WIDTH, Math.max(COURSE_SIDEBAR_MIN_WIDTH, width));
|
||||
}
|
||||
@@ -113,7 +114,7 @@
|
||||
{:else}
|
||||
<Sidebar.Root collapsible="icon">
|
||||
<Sidebar.Header>
|
||||
<OrgLogo />
|
||||
<CourseSidebarLogo />
|
||||
</Sidebar.Header>
|
||||
|
||||
<Sidebar.Content>
|
||||
@@ -141,14 +142,14 @@
|
||||
|
||||
<Sidebar.Rail onclick={handleRailClick} onpointerdown={handleRailPointerDown} />
|
||||
|
||||
{#if $isFreePlan}
|
||||
<Sidebar.Footer>
|
||||
<UpgradePoweredBy
|
||||
class="static [&_span]:font-normal [&_span]:shadow-none"
|
||||
showOnlyLogo={!sidebar.open || sidebar.isMobile}
|
||||
/>
|
||||
</Sidebar.Footer>
|
||||
{/if}
|
||||
<Sidebar.Footer>
|
||||
<PoweredBy
|
||||
variant="sidebar"
|
||||
sidebarUtmSource="lms-course-sidebar"
|
||||
courseSlug={attributionCourseSlug}
|
||||
showOnlyLogo={!sidebar.open || sidebar.isMobile}
|
||||
/>
|
||||
</Sidebar.Footer>
|
||||
</Sidebar.Root>
|
||||
{/if}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user