feat: implement automation feature

This commit is contained in:
Rotimi Best
2026-03-18 23:39:12 +01:00
parent 576d0366de
commit ef4424e267
121 changed files with 4544 additions and 1055 deletions
@@ -20,6 +20,7 @@
import getCurrencyFormatter from '$lib/utils/functions/getCurrencyFormatter';
import { calcCourseProgress, calcProgressRate } from '$features/course/utils/functions';
import CardDropdown from './card-dropdown.svelte';
import CoursePublishBadge from './course-publish-badge.svelte';
import type { OrgCourses, UserEnrolledCourses } from '$features/course/types';
import type { OrgPublicCourses } from '$features/org/utils/types';
@@ -196,13 +197,7 @@
</div>
{/if}
{:else}
<Badge variant={isPublished ? 'default' : 'outline'}>
{#if isPublished}
{$t('courses.course_card.published')}
{:else}
{$t('courses.course_card.unpublished')}
{/if}
</Badge>
<CoursePublishBadge {isPublished} />
{/if}
</div>
</div>
@@ -1,6 +1,7 @@
<script lang="ts">
import { COURSE_SORT_OPTIONS } from '../utils/constants';
import * as Popover from '@cio/ui/base/popover';
import * as ToggleGroup from '@cio/ui/base/toggle-group';
import { Button } from '@cio/ui/base/button';
import { Spinner } from '@cio/ui/base/spinner';
import { Checkbox } from '@cio/ui/base/checkbox';
@@ -27,6 +28,7 @@
interface Props {
selectedId?: string;
selectedOrder?: 'asc' | 'desc';
selectedTags?: string[];
tagGroups?: FilterTagGroup[];
sortOptions?: readonly SortOption[];
@@ -37,6 +39,7 @@
let {
selectedId = $bindable('0'),
selectedOrder = $bindable('desc' as 'asc' | 'desc'),
selectedTags = [],
tagGroups = [],
sortOptions = COURSE_SORT_OPTIONS,
@@ -47,8 +50,7 @@
let isOpen = $state(false);
const hasActiveFilters = $derived(selectedId !== '0' || selectedTags.length > 0);
const hasActiveTagFilters = $derived(selectedTags.length > 0);
const hasActiveFilters = $derived(selectedId !== '0' || selectedOrder !== 'desc' || selectedTags.length > 0);
function isTagSelected(tagSlug: string) {
return selectedTags.includes(tagSlug);
@@ -66,7 +68,7 @@
<FilterIcon size={16} />
{/if}
</IconButton>
{#if hasActiveTagFilters}
{#if hasActiveFilters}
<span
class="ui:bg-primary absolute -top-0.5 -right-0.5 h-2.5 w-2.5 rounded-full border border-white"
aria-hidden="true"
@@ -103,6 +105,26 @@
</div>
</div>
<div class="space-y-2">
<p class="ui:text-muted-foreground text-xs font-semibold uppercase">
{$t('courses.tag_filters.order')}
</p>
<ToggleGroup.Root
type="single"
variant="outline"
size="sm"
value={selectedOrder}
onValueChange={(value) => {
if (value === 'asc' || value === 'desc') {
selectedOrder = value;
}
}}
>
<ToggleGroup.Item value="asc">{$t('courses.tag_filters.ascending')}</ToggleGroup.Item>
<ToggleGroup.Item value="desc">{$t('courses.tag_filters.descending')}</ToggleGroup.Item>
</ToggleGroup.Root>
</div>
<div class="space-y-3">
<p class="ui:text-muted-foreground text-xs font-semibold uppercase">{$t('courses.tag_filters.tags')}</p>
{#if tagGroups.length === 0}
@@ -3,21 +3,35 @@
import * as Sidebar from '@cio/ui/base/sidebar';
import BellIcon from '@lucide/svelte/icons/bell';
import { Button } from '@cio/ui/base/button';
import { IconButton } from '@cio/ui/custom/icon-button';
import * as Popover from '@cio/ui/base/popover';
import Search from '$features/ui/search.svelte';
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 { currentOrg } from '$lib/utils/store/org';
import { currentOrg, currentOrgDomain } from '$lib/utils/store/org';
import { isStudentExperience } from '$lib/utils/store/app';
import { setupProgressApi } from '$features/setup/api/setup-progress.svelte';
import { courseApi } from '$features/course/api';
import { openCoursePreview } from '$features/course/utils/course-preview';
import { t } from '$lib/utils/functions/translations';
import CoursePublishBadge from './course-publish-badge.svelte';
const siteName = $derived($currentOrg.siteName);
const showCoursePublishBadge = $derived(!$isStudentExperience);
$effect(() => {
if (!siteName) return;
setupProgressApi.fetchSetupProgress(siteName);
});
function handlePreview() {
openCoursePreview({
courseId: courseApi.course?.id ?? '',
courseSlug: courseApi.course?.slug,
currentOrgDomain: $currentOrgDomain
});
}
</script>
<header
@@ -30,14 +44,18 @@
<Separator orientation="vertical" />
</div>
<p class="max-w-2xs truncate text-sm font-medium">
{courseApi.course?.title || ''}
</p>
<div class="flex min-w-0 items-center gap-2">
<p class="max-w-2xs truncate text-sm font-medium">
{courseApi.course?.title || ''}
</p>
{#if showCoursePublishBadge}
<CoursePublishBadge isPublished={courseApi.course?.isPublished} />
{/if}
</div>
<span class="grow"></span>
<Search />
<Popover.Root>
<Popover.Trigger>
<Button variant="outline" size="icon">
@@ -56,11 +74,24 @@
<Empty.Content>
<Button variant="outline" size="sm">
<RefreshCcwIcon />
Refresh
{$t('common.refresh')}
</Button>
</Empty.Content>
</Empty.Root>
</Popover.Content>
</Popover.Root>
<HoverableItem>
{#snippet children(isHovered)}
<IconButton
onclick={handlePreview}
disabled={!courseApi.course?.id}
tooltip={$t('course.header.preview')}
aria-label={$t('course.header.preview')}
>
<PreviewIcon {isHovered} size={16} />
</IconButton>
{/snippet}
</HoverableItem>
</div>
</header>
@@ -0,0 +1,18 @@
<script lang="ts">
import { Badge } from '@cio/ui/base/badge';
import { t } from '$lib/utils/functions/translations';
interface Props {
isPublished?: boolean;
}
let { isPublished = false }: Props = $props();
</script>
<Badge variant={isPublished ? 'default' : 'outline'}>
{#if isPublished}
{$t('courses.course_card.published')}
{:else}
{$t('courses.course_card.unpublished')}
{/if}
</Badge>
@@ -1,6 +1,6 @@
<script lang="ts">
import { questionnaire } from './store';
import { sanitizeHtml } from '@cio/ui/tools/sanitize';
import { SafeHtmlContent } from '@cio/ui/custom/safe-html-content';
import { t } from '$lib/utils/functions/translations';
import { InputField } from '@cio/ui/custom/input-field';
@@ -49,9 +49,9 @@
</div>
<article class="preview prose prose-sm sm:prose mt-3 p-2">
{@html sanitizeHtml(
$questionnaire.description || $t('course.navItem.lessons.exercises.all_exercises.description.no')
)}
<SafeHtmlContent
content={$questionnaire.description || $t('course.navItem.lessons.exercises.all_exercises.description.no')}
/>
</article>
{:else}
<InputField
@@ -21,7 +21,7 @@
import { toApiPayload, type AnswerData } from '@cio/question-types';
import { exerciseApi, presignApi } from '$features/course/api';
import type { SubmissionListItem } from '$features/course/utils/types';
import { sanitizeHtml } from '@cio/ui/tools/sanitize';
import { SafeHtmlContent } from '@cio/ui/custom/safe-html-content';
import { t } from '$lib/utils/functions/translations';
import { snackbar } from '$features/ui/snackbar/store';
import { ContentType } from '@cio/utils/constants/content';
@@ -422,9 +422,10 @@
</div>
<article class="preview prose prose-sm sm:prose mt-3 p-2">
{@html sanitizeHtml(
$questionnaire.description || $t('course.navItem.lessons.exercises.all_exercises.view_mode.no_description')
)}
<SafeHtmlContent
content={$questionnaire.description ||
$t('course.navItem.lessons.exercises.all_exercises.view_mode.no_description')}
/>
</article>
<Button onclick={handleStart} type="button" class="float-right my-5">
@@ -8,7 +8,7 @@
import { lessonApi } from '$features/course/api';
import { diffLines } from 'diff';
import { courseApi } from '$features/course/api';
import { sanitizeHtml } from '@cio/ui/tools/sanitize';
import { SafeHtmlContent } from '@cio/ui/custom/safe-html-content';
import { t } from '$lib/utils/functions/translations';
import { snackbar } from '$features/ui/snackbar/store';
@@ -83,7 +83,7 @@
);
}
function fetchLessonHistory(lessonId: string, locale: string, endRange: number) {
function fetchLessonHistory(lessonId: string, locale: TLocale, endRange: number) {
untrack(async () => {
try {
isMoreHistoryLoading = true;
@@ -100,9 +100,18 @@
const data = response.data;
// Filter out duplicates based on timestamp
const toLessonHistory = (item: (typeof data)[number]): LessonHistory => ({
old_content: item.oldContent ?? '',
new_content: item.newContent ?? '',
timestamp: item.timestamp ? new Date(item.timestamp) : new Date(),
locale: (item.locale as TLocale) ?? 'en',
lesson_id: item.lessonId ?? ''
});
const existingTimestamps = new Set(lessonHistory.map((item) => new Date(item.timestamp).getMinutes()));
const newEntries = data.filter((item) => !existingTimestamps.has(new Date(item.timestamp!).getMinutes()));
const newEntries = data
.filter((item) => !existingTimestamps.has(new Date(item.timestamp ?? 0).getMinutes()))
.map(toLessonHistory);
lessonHistory = removeDuplicate([...lessonHistory, ...newEntries]);
if (lessonHistory.length > 0) {
@@ -206,7 +215,7 @@
{#key lessonId}
<HTMLRender id="display" className="m-auto">
<div class="amen">
{@html sanitizeHtml(content)}
<SafeHtmlContent {content} />
</div>
</HTMLRender>
{/key}
@@ -222,7 +231,7 @@
</p>
<div>
{#each lessonHistory as version, index}
{#each lessonHistory as version, index (index)}
<button
onclick={() => updateContentVersion(version, index)}
class="flex w-full cursor-pointer items-start p-4 px-10 hover:bg-gray-200 dark:hover:bg-neutral-700 {index ==
@@ -1,6 +1,6 @@
<script lang="ts">
import { HTMLRender, TextEditor } from '$features/ui';
import { sanitizeHtml } from '@cio/ui/tools/sanitize';
import { SafeHtmlContent } from '@cio/ui/custom/safe-html-content';
import { isHtmlValueEmpty } from '$lib/utils/functions/toHtml';
import { lessonApi } from '$features/course/api';
import { t } from '$lib/utils/functions/translations';
@@ -43,10 +43,6 @@
}
const content = $derived(lessonApi.translations[lessonId]?.[lessonApi.currentLocale] || '');
$effect(() => {
console.log('content', content);
});
</script>
{#if mode === MODES.edit}
@@ -67,7 +63,7 @@
<!-- View Mode -->
{#if !isHtmlValueEmpty(content)}
<HTMLRender>
{@html sanitizeHtml(content)}
<SafeHtmlContent {content} />
</HTMLRender>
{:else if hasAtLeastOneTranslation}
<p class="text-md py-2 font-normal italic dark:text-white">
@@ -1,12 +1,12 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { Badge } from '@cio/ui/base/badge';
import * as Table from '@cio/ui/base/table';
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
import { isMobileStore } from '@cio/ui/hooks/is-mobile.svelte';
import { t } from '$lib/utils/functions/translations';
import CoursePublishBadge from './course-publish-badge.svelte';
interface Props {
id?: string;
@@ -88,13 +88,7 @@
<Table.Cell>{totalLessons}</Table.Cell>
<Table.Cell>{totalStudents}</Table.Cell>
<Table.Cell>
<Badge variant={isPublished ? 'default' : 'outline'}>
{#if isPublished}
{$t('courses.course_card.published')}
{:else}
{$t('courses.course_card.unpublished')}
{/if}
</Badge>
<CoursePublishBadge {isPublished} />
</Table.Cell>
{/if}
<Table.Cell class="text-center">
@@ -4,7 +4,7 @@
import { calDateDiff } from '$lib/utils/functions/date';
import { isHtmlValueEmpty } from '$lib/utils/functions/toHtml';
import { sanitizeHtml } from '@cio/ui/tools/sanitize';
import { SafeHtmlContent } from '@cio/ui/custom/safe-html-content';
import { t } from '$lib/utils/functions/translations';
import type { Feed } from '$features/course/utils/types';
@@ -59,7 +59,7 @@
{#if !isHtmlValueEmpty(feed.content || '')}
<HTMLRender className="w-[80%]">
<div>
{@html sanitizeHtml(feed.content || '')}
<SafeHtmlContent content={feed.content || ''} />
</div>
</HTMLRender>
{/if}