Enhance audience management features with new components and API integrations

- Added `AssignCoursesModal`, `AudienceMemberRow`, `AudienceTable`, and related components for improved audience management UI.
- Implemented audience invite actions including resend and revoke functionality.
- Updated audience API to support importing members and assigning courses.
- Refactored audience data handling to include status and invite management.
- Improved overall user experience with pagination and selection features in the audience table.
This commit is contained in:
Rotimi Best
2026-03-22 18:42:23 +01:00
parent e9c4082f89
commit 3919ff6c2f
157 changed files with 13183 additions and 872 deletions
@@ -21,6 +21,7 @@
import { DeleteModal } from '$features/ui';
import { t } from '$lib/utils/functions/translations';
import { courseApi } from '$features/course/api';
import { COURSE_SORT_OPTIONS, DEFAULT_COURSE_SORT, type CourseSortBy } from '../utils/constants';
import { deleteCourseModal, deleteCourseModalInitialState, courseMetaDeta } from '../utils/store';
import { browser } from '$app/environment';
import type { OrgCourses, UserEnrolledCourses } from '$features/course/types';
@@ -33,7 +34,7 @@
isExplore?: boolean;
isLMS?: boolean;
searchValue?: string;
selectedId?: string;
sortKey?: CourseSortBy;
isLoading?: boolean;
showSortSelect?: boolean;
filterControls?: Snippet;
@@ -46,22 +47,21 @@
isExplore = false,
isLMS = false,
searchValue = $bindable(''),
selectedId = $bindable('0'),
sortKey = $bindable(DEFAULT_COURSE_SORT),
isLoading = false,
showSortSelect = true,
filterControls
}: Props = $props();
const filterOptions = $derived([
{ value: '0', label: $t('courses.course_filter.date_created') },
{ value: '1', label: $t('courses.course_filter.published') },
{ value: '2', label: $t('courses.course_filter.lessons') }
]);
const selectedLabel = $derived(
filterOptions.find((opt) => opt.value === selectedId)?.label || filterOptions[0].label
const filterOptions = $derived(
COURSE_SORT_OPTIONS.map((option) => ({
value: option.value,
label: $t(option.label)
}))
);
const selectedLabel = $derived(filterOptions.find((opt) => opt.value === sortKey)?.label || filterOptions[0].label);
const setViewPreference = (preference: 'grid' | 'list') => {
$courseMetaDeta.view = preference;
if (browser) {
@@ -94,12 +94,12 @@
isLoading={$deleteCourseModal.isDeleting}
/>
<Page.BodyHeader align="right">
<Page.BodyHeader align="right" class="p-0!">
<Search placeholder={$t('courses.search_placeholder')} bind:value={searchValue} />
{#if showSortSelect}
<Select.Root type="single" bind:value={selectedId}>
<Select.Root type="single" bind:value={sortKey}>
<Select.Trigger class="min-w-[150px]">
<p>{selectedId ? selectedLabel : filterOptions[0].label}</p>
<p>{sortKey ? selectedLabel : filterOptions[0].label}</p>
</Select.Trigger>
<Select.Content>
{#each filterOptions as option (option.label)}
@@ -142,9 +142,9 @@
<Table.Header>
<Table.Row>
<Table.Head class="w-[20%]">{$t('courses.course_card.list_view.title')}</Table.Head>
<Table.Head class="w-[24%]">{$t('courses.course_card.list_view.description')}</Table.Head>
<Table.Head class="w-[20%]">{$t('courses.course_card.list_view.description')}</Table.Head>
<Table.Head class="w-[10%]">{$t('courses.course_card.list_view.type')}</Table.Head>
<Table.Head class="w-[14%]">{$t('courses.course_card.list_view.tags')}</Table.Head>
<Table.Head class="w-3/12 min-w-0">{$t('courses.course_card.list_view.tags')}</Table.Head>
<Table.Head class="w-[8%]">{$t('courses.course_card.list_view.lessons')}</Table.Head>
<Table.Head class="w-[8%]">{$t('courses.course_card.list_view.students')}</Table.Head>
<Table.Head class="w-[10%]">{$t('courses.course_card.list_view.published')}</Table.Head>
@@ -18,7 +18,7 @@
import { globalStore } from '$lib/utils/store/app';
import { currentOrg } from '$lib/utils/store/org';
import { snackbar } from '$features/ui/snackbar/store';
import { RefreshPageData } from '$features/ui';
import { RefreshPageData, UnsavedChanges } from '$features/ui';
import LessonVersionHistory from '$features/course/components/lesson/lesson-version-history.svelte';
import { courseApi, lessonApi } from '$features/course/api';
import { isHtmlValueEmpty } from '$lib/utils/functions/toHtml';
@@ -50,6 +50,7 @@
import type { TLocale } from '@cio/db/types';
import { orderedTabs, tabs as materialTabs } from '$features/course/components/lesson/constants';
import { getViewModeComponents } from '$features/course/components/lesson/utils';
import { loadDraft, clearDraft } from '$features/course/utils/lesson-draft';
interface Props {
courseId: string;
@@ -61,9 +62,14 @@
const mode = $derived($page.url.searchParams.get('mode') === 'edit' ? MODES.edit : MODES.view);
let prevModeParam = $state<string | null>(null);
let isSaving = $state(false);
let isDeletingLesson = $state(false);
let isVersionDrawerOpen = $state(false);
// eslint-disable-next-line svelte/prefer-writable-derived -- must be writable: cleared before intentional navigations and bound to UnsavedChanges
let hasUnsavedChanges = $state(false);
$effect(() => {
hasUnsavedChanges = lessonApi.isDirty && mode === MODES.edit;
});
const lessonTitle = $derived(lessonApi.lesson?.title || 'Lesson');
const isLessonUnlocked = $derived(lessonApi.lesson?.isUnlocked ?? false);
@@ -80,6 +86,12 @@
}
function toggleMode() {
if (mode === MODES.edit && lessonApi.isDirty) {
if (timeoutId) clearTimeout(timeoutId);
timeoutId = undefined;
saveLesson();
}
hasUnsavedChanges = false;
setModeQueryParam(mode === MODES.edit ? MODES.view : MODES.edit);
}
@@ -139,7 +151,7 @@
const isMaterialsEmpty = $derived(tabs.every((tab) => tab.badgeValue === 0));
let timeoutId: NodeJS.Timeout;
let timeoutId: NodeJS.Timeout | undefined;
let isLoading = writable(false);
@@ -184,9 +196,6 @@
async function saveLesson() {
if (!lessonApi.lesson) return false;
// Prevent autosave loops: we only set this back to `true` when the user edits again.
lessonApi.isDirty = false;
const [isLessonUpdated] = await Promise.all([
lessonApi.update(courseApi.course?.id || '', lessonId, {
title: lessonApi.lesson.title || undefined,
@@ -200,6 +209,8 @@
if (isLessonUpdated) {
patchLessonListItemLocally();
clearDraft(lessonId, lessonApi.currentLocale);
lessonApi.isDirty = false;
}
}
@@ -219,6 +230,7 @@
await lessonApi.delete(courseId, lessonId);
if (lessonApi.success) {
hasUnsavedChanges = false;
await goto(resolve(`/courses/${courseId}/lessons`, {}));
}
@@ -228,6 +240,8 @@
function handleSave(prevMode: string) {
untrack(() => {
if (prevMode === MODES.edit) {
if (timeoutId) clearTimeout(timeoutId);
timeoutId = undefined;
saveLesson();
}
});
@@ -240,11 +254,8 @@
if (timeoutId) clearTimeout(timeoutId);
untrack(() => {
isSaving = true;
timeoutId = setTimeout(async () => {
await saveLesson();
isSaving = false;
}, 2000);
});
}
@@ -255,13 +266,41 @@
autoSave();
};
// Check for an unsaved localStorage draft when the lesson first loads.
let draftCheckedForLesson = '';
$effect(() => {
if (!browser) return;
if (!lessonApi.lesson || !lessonId) return;
if (draftCheckedForLesson === lessonId) return;
draftCheckedForLesson = lessonId;
const draft = loadDraft(lessonId, lessonApi.currentLocale);
if (!draft) return;
const serverContent = lessonApi.translations[lessonId]?.[lessonApi.currentLocale] || '';
if (draft.content === serverContent) {
clearDraft(lessonId, lessonApi.currentLocale);
return;
}
const shouldRestore = window.confirm(t.get('course.navItem.lessons.draft_recovery'));
if (shouldRestore) {
if (!lessonApi.translations[lessonId]) {
lessonApi.translations[lessonId] = {} as Record<TLocale, string>;
}
lessonApi.translations[lessonId][lessonApi.currentLocale] = draft.content;
lessonApi.isDirty = true;
} else {
clearDraft(lessonId, lessonApi.currentLocale);
}
});
// Only autosave after real edits, not on every reactive update.
$effect(() => {
if (mode !== MODES.edit) return;
if (!lessonApi.lesson || !lessonId) return;
if (!lessonApi.isDirty) return;
if ($isLoading) return;
console.log('autoSave');
autoSave();
});
@@ -270,7 +309,6 @@
let didHandleExitEdit = false;
$effect(() => {
if (!lessonId) return;
console.log('didHandleExitEdit');
const currentParam = $page.url.searchParams.get('mode');
const prev = prevModeParam;
@@ -338,8 +376,17 @@
</IconButton>
{/if}
<div class="flex-row items-center lg:flex">
<IconButton onclick={toggleMode} disabled={isSaving}>
<div class="flex flex-row items-center gap-2 lg:flex">
{#if mode === MODES.edit}
<span class="ui:text-muted-foreground text-sm" aria-live="polite">
{#if lessonApi.isSaving}
{$t('course.navItem.lessons.saving')}
{:else if !lessonApi.isDirty}
{$t('course.navItem.lessons.saved')}
{/if}
</span>
{/if}
<IconButton onclick={toggleMode} disabled={lessonApi.isSaving}>
{#if mode === MODES.edit}
<Save size={20} />
{:else}
@@ -452,6 +499,8 @@
{/snippet}
</Page.Body>
<UnsavedChanges bind:hasUnsavedChanges />
{#if isVersionDrawerOpen && window.innerWidth >= 1024}
<LessonVersionHistory
open={isVersionDrawerOpen}
@@ -17,6 +17,7 @@
import type { CourseMembers, CourseMember } from '$features/course/utils/types';
import { courseApi } from '$features/course/api';
import { t } from '$lib/utils/functions/translations';
import UserIcon from '@lucide/svelte/icons/user';
import { shortenName } from '$lib/utils/functions/string';
import * as Select from '@cio/ui/base/select';
import { IconButton } from '@cio/ui/custom/icon-button';
@@ -138,11 +139,15 @@
{#if person.profile}
<div class="flex items-start lg:items-center">
<Avatar.Root class="mr-3">
<Avatar.Image
src={person.profile.avatar_url ? person.profile.avatar_url : '/logo-192.png'}
alt={person.profile.fullname ? person.profile.fullname : 'User'}
/>
<Avatar.Fallback>{shortenName(person.profile.fullname) || 'U'}</Avatar.Fallback>
{#if person.profile.avatar_url}
<Avatar.Image
src={person.profile.avatar_url}
alt={person.profile.fullname ? person.profile.fullname : 'User'}
/>
{/if}
<Avatar.Fallback>
<UserIcon class="ui:size-4 ui:text-muted-foreground" />
</Avatar.Fallback>
</Avatar.Root>
<div class="flex flex-col items-start lg:flex-row lg:items-center">
<div class="mr-2">
@@ -152,7 +152,7 @@
await submissionApi.updateGrades(courseId, subId, {
answers,
total,
feedback,
feedback: feedback || undefined,
statusId: STATUS.GRADED
});
@@ -195,6 +195,8 @@
}
};
handleModalClose();
isSaving = false;
}
</script>