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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user