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
@@ -13,7 +13,6 @@
import EmptyState from './empty-state.svelte';
import * as Avatar from '@cio/ui/base/avatar';
import type { CourseAnalytics } from '$features/course/utils/types';
import { shortenName } from '$lib/utils/functions/string';
interface Props {
students?: CourseAnalytics['students'];
@@ -37,7 +36,10 @@
if (!student) return;
goto(
resolve(`/courses/${courseApi.course?.id}/people/${student.id}?back=/courses/${courseApi.course?.id}/analytics`)
resolve(
`/courses/${courseApi.course?.id}/people/${student.id}?back=/courses/${courseApi.course?.id}/analytics`,
{}
)
);
};
@@ -79,11 +81,15 @@
>
<div class="flex items-center gap-3">
<Avatar.Root class="size-8">
<Avatar.Image
src={student.profile.avatar_url ? student.profile.avatar_url : '/logo-192.png'}
alt={student.profile.fullname ? student.profile.fullname : $t('analytics.student')}
/>
<Avatar.Fallback>{shortenName(student.profile.fullname) || 'S'}</Avatar.Fallback>
{#if student.profile.avatar_url}
<Avatar.Image
src={student.profile.avatar_url}
alt={student.profile.fullname ? student.profile.fullname : $t('analytics.student')}
/>
{/if}
<Avatar.Fallback>
<UserIcon class="ui:size-4 ui:text-muted-foreground" />
</Avatar.Fallback>
</Avatar.Root>
<div class="min-w-0 flex-1">
<p class="truncate font-medium text-gray-900 dark:text-white">
@@ -21,6 +21,7 @@
import { calcCourseProgress, calcProgressRate } from '$features/course/utils/functions';
import CardDropdown from './card-dropdown.svelte';
import CoursePublishBadge from './course-publish-badge.svelte';
import CourseTagsOverflow from './course-tags-overflow.svelte';
import type { OrgCourses, UserEnrolledCourses } from '$features/course/types';
import type { OrgPublicCourses } from '$features/org/utils/types';
@@ -57,7 +58,7 @@
title: course.title,
type: course.type,
description: course.description,
isPublished: course.isPublished,
isPublished: !!course.isPublished,
pricingData: {
cost: course.cost,
discount: course.metadata?.discount || 0,
@@ -147,25 +148,14 @@
</Item.Header>
<Item.Content>
<Item.Title class="line-clamp-1 min-h-[44px] text-base!">
<Item.Title class="line-clamp-2 min-h-14 text-base!">
{title}
</Item.Title>
<Item.Description class="min-h-[63px]">{description}</Item.Description>
<div class="ui:text-muted-foreground flex min-h-[12px] flex-wrap items-center gap-1">
{#if !isLMS && courseTags.length > 0}
{#each courseTags as tag (tag.id)}
<Item.SubDescription class="border-border inline-flex items-center gap-1.5 rounded-full border px-2">
<span
class="ui:bg-primary/60 inline-block h-1.5 w-1.5 rounded-full"
style={tag.color ? `background-color: ${tag.color}` : undefined}
aria-hidden="true"
></span>
<span>{tag.name}</span>
</Item.SubDescription>
{/each}
{/if}
</div>
{#if !isLMS}
<CourseTagsOverflow tags={courseTags} variant="card" />
{/if}
<Separator class="my-3" />
@@ -1,5 +1,11 @@
<script lang="ts">
import { COURSE_SORT_OPTIONS } from '../utils/constants';
import {
COURSE_SORT_OPTIONS,
CourseSortOrder,
DEFAULT_COURSE_SORT,
DEFAULT_SORT_ORDER,
type CourseSortBy
} 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';
@@ -27,8 +33,8 @@
type SortOption = (typeof COURSE_SORT_OPTIONS)[number];
interface Props {
selectedId?: string;
selectedOrder?: 'asc' | 'desc';
sortKey?: CourseSortBy;
selectedOrder?: CourseSortOrder;
selectedTags?: string[];
tagGroups?: FilterTagGroup[];
sortOptions?: readonly SortOption[];
@@ -38,8 +44,8 @@
}
let {
selectedId = $bindable('0'),
selectedOrder = $bindable('desc' as 'asc' | 'desc'),
sortKey = $bindable(DEFAULT_COURSE_SORT),
selectedOrder = $bindable(DEFAULT_SORT_ORDER),
selectedTags = [],
tagGroups = [],
sortOptions = COURSE_SORT_OPTIONS,
@@ -50,7 +56,9 @@
let isOpen = $state(false);
const hasActiveFilters = $derived(selectedId !== '0' || selectedOrder !== 'desc' || selectedTags.length > 0);
const hasActiveFilters = $derived(
sortKey !== DEFAULT_COURSE_SORT || selectedOrder !== DEFAULT_SORT_ORDER || selectedTags.length > 0
);
function isTagSelected(tagSlug: string) {
return selectedTags.includes(tagSlug);
@@ -96,8 +104,8 @@
<Button
type="button"
size="sm"
variant={selectedId === option.value ? 'secondary' : 'outline'}
onclick={() => (selectedId = option.value)}
variant={sortKey === option.value ? 'secondary' : 'outline'}
onclick={() => (sortKey = option.value)}
>
{$t(option.label)}
</Button>
@@ -115,13 +123,13 @@
size="sm"
value={selectedOrder}
onValueChange={(value) => {
if (value === 'asc' || value === 'desc') {
if (value === CourseSortOrder.Asc || value === CourseSortOrder.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.Item value={CourseSortOrder.Asc}>{$t('courses.tag_filters.ascending')}</ToggleGroup.Item>
<ToggleGroup.Item value={CourseSortOrder.Desc}>{$t('courses.tag_filters.descending')}</ToggleGroup.Item>
</ToggleGroup.Root>
</div>
@@ -0,0 +1,201 @@
<script lang="ts">
import { tick } from 'svelte';
import * as Tooltip from '@cio/ui/base/tooltip';
import * as Item from '@cio/ui/base/item';
import { t } from '$lib/utils/functions/translations';
interface Tag {
id: string;
name: string;
slug: string;
color?: string | null;
}
interface Props {
tags: Tag[];
variant?: 'card' | 'table';
}
let { tags, variant = 'card' }: Props = $props();
let tagsRowEl: HTMLDivElement | undefined = $state();
let tagsMeasureEl: HTMLDivElement | undefined = $state();
let overflowBadgeMeasureEl: HTMLSpanElement | undefined = $state();
/** -1 = not measured yet (show all tags until layout runs) */
let visibleTagCount = $state(-1);
const tagGapPx = $derived(variant === 'table' ? 8 : 4);
const gapClass = $derived(variant === 'table' ? 'gap-2' : 'gap-1');
function computeVisibleTagCount() {
if (tags.length === 0) {
visibleTagCount = 0;
return;
}
if (!tagsRowEl || !tagsMeasureEl) {
visibleTagCount = -1;
return;
}
const widths = Array.from(tagsMeasureEl.children).map((el) => (el as HTMLElement).offsetWidth);
const containerWidth = tagsRowEl.clientWidth;
const badgeMaxWidth = overflowBadgeMeasureEl?.offsetWidth ?? 40;
const n = widths.length;
const gap = tagGapPx;
if (containerWidth <= 0) {
visibleTagCount = n;
return;
}
let best = 0;
for (let k = 0; k <= n; k++) {
const rest = n - k;
let sum = 0;
for (let i = 0; i < k; i++) {
sum += widths[i] ?? 0;
if (i > 0) sum += gap;
}
const needBadge = rest > 0;
const total = sum + (needBadge ? gap + badgeMaxWidth : 0);
if (total <= containerWidth) {
best = k;
}
}
visibleTagCount = best;
}
const displayTagCount = $derived(visibleTagCount < 0 ? tags.length : visibleTagCount);
$effect(() => {
void tags;
void tagsRowEl;
void tagsMeasureEl;
void overflowBadgeMeasureEl;
void variant;
const schedule = () => {
requestAnimationFrame(() => {
computeVisibleTagCount();
});
};
tick().then(schedule);
if (!tagsRowEl) {
return;
}
const ro = new ResizeObserver(schedule);
ro.observe(tagsRowEl);
return () => ro.disconnect();
});
const hiddenTags = $derived(tags.slice(displayTagCount));
const hiddenTagsLabel = $derived(hiddenTags.map((t) => t.name).join(', '));
</script>
{#if tags.length > 0}
<div class="relative min-h-[12px] w-full min-w-0">
<div
class="pointer-events-none invisible absolute top-0 left-0 -z-10 flex {gapClass}"
bind:this={tagsMeasureEl}
aria-hidden="true"
>
{#each tags as tag (tag.id)}
{#if variant === 'card'}
<Item.SubDescription class="border-border inline-flex shrink-0 items-center gap-1.5 rounded-full border px-2">
<span
class="ui:bg-primary/60 inline-block h-1.5 w-1.5 shrink-0 rounded-full"
style={tag.color ? `background-color: ${tag.color}` : undefined}
aria-hidden="true"
></span>
<span>{tag.name}</span>
</Item.SubDescription>
{:else}
<span class="ui:text-muted-foreground text-2xs inline-flex shrink-0 items-center gap-1.5">
<span
class="ui:bg-primary/60 inline-block h-1.5 w-1.5 shrink-0 rounded-full"
style={tag.color ? `background-color: ${tag.color}` : undefined}
aria-hidden="true"
></span>
<span>{tag.name}</span>
</span>
{/if}
{/each}
</div>
<span
bind:this={overflowBadgeMeasureEl}
class="border-border ui:text-muted-foreground pointer-events-none invisible absolute top-0 left-0 -z-10 inline-flex shrink-0 items-center rounded-full border {variant ===
'table'
? 'text-2xs px-1.5 py-0.5'
: 'px-2 text-xs'}"
aria-hidden="true"
>
+{tags.length}
</span>
<div
bind:this={tagsRowEl}
class="ui:text-muted-foreground flex min-w-0 flex-nowrap items-center overflow-hidden {gapClass} {variant ===
'table'
? 'text-2xs'
: ''}"
>
{#each tags.slice(0, displayTagCount) as tag (tag.id)}
{#if variant === 'card'}
<Item.SubDescription class="border-border inline-flex shrink-0 items-center gap-1.5 rounded-full border px-2">
<span
class="ui:bg-primary/60 inline-block h-1.5 w-1.5 shrink-0 rounded-full"
style={tag.color ? `background-color: ${tag.color}` : undefined}
aria-hidden="true"
></span>
<span>{tag.name}</span>
</Item.SubDescription>
{:else}
<span class="inline-flex shrink-0 items-center gap-1.5">
<span
class="ui:bg-primary/60 inline-block h-1.5 w-1.5 shrink-0 rounded-full"
style={tag.color ? `background-color: ${tag.color}` : undefined}
aria-hidden="true"
></span>
<span>{tag.name}</span>
</span>
{/if}
{/each}
{#if hiddenTags.length > 0}
<Tooltip.Provider>
<Tooltip.Root>
<Tooltip.Trigger>
{#snippet child({ props })}
{#if variant === 'card'}
<Item.SubDescription
{...props}
class="border-border inline-flex shrink-0 cursor-default items-center gap-1.5 rounded-full border px-2"
aria-label={$t('courses.course_card.tags_overflow_aria', {
count: hiddenTags.length
})}
>
+{hiddenTags.length}
</Item.SubDescription>
{:else}
<span
{...props}
class="border-border ui:text-muted-foreground text-2xs inline-flex shrink-0 cursor-default items-center rounded-full border px-1.5 py-0.5"
aria-label={$t('courses.course_card.tags_overflow_aria', {
count: hiddenTags.length
})}
>
+{hiddenTags.length}
</span>
{/if}
{/snippet}
</Tooltip.Trigger>
<Tooltip.Content side="top" sideOffset={4} class="max-w-xs">
{hiddenTagsLabel}
</Tooltip.Content>
</Tooltip.Root>
</Tooltip.Provider>
{/if}
</div>
</div>
{/if}
@@ -1,6 +1,7 @@
<script lang="ts">
import { getExerciseQuestionContractKey } from '@cio/question-types';
import type { AnswerData } from '@cio/question-types';
import type { AnswerData, ExerciseQuestionModel } from '@cio/question-types';
import type { Question } from '$features/course/types';
import { Grade, ReasonBox } from '$features/ui/question';
import { toExerciseQuestionModel } from './question-type-utils';
import { getExerciseQuestionLabels } from './question-labels';
@@ -8,15 +9,15 @@
import type { QuestionnaireMetaData } from './store';
interface Props {
questions?: any;
questions?: Question[];
questionnaireMetaData?: Partial<QuestionnaireMetaData>;
/** Per-question scores (e.g. from `questionnaireMetaData.grades` or grading modal `questionAnswerByPoint`). */
grades?: Record<string, number>;
onSubmit?: any;
onPrevious?: any;
disableGrading?: boolean;
isGradeWithAI?: boolean;
isLoading?: boolean;
reasons?: any;
/** Optional AI grading copy keyed by question id. */
reasons?: Record<string, string>;
}
let {
@@ -33,12 +34,12 @@
isGradeWithAI = false;
}
function rejectGradeSuggestion(questionId) {
function rejectGradeSuggestion(questionId: Question['id']) {
isGradeWithAI = false;
grades[questionId] = 0;
grades[String(questionId)] = 0;
}
function getAnswerForQuestion(questionModel, fallbackIndex = 0) {
function getAnswerForQuestion(questionModel: ExerciseQuestionModel, fallbackIndex = 0): AnswerData | undefined {
const questionKey = getExerciseQuestionContractKey(questionModel, fallbackIndex);
return _questionnaireMetaData?.answers?.[questionKey];
}
@@ -59,11 +60,11 @@
{#if disableGrading && !Object.keys(grades || {}).length}
<ExerciseQuestion.QuestionList
contract={{ mode: 'view', questions: questionModels, answersByKey, labels: questionLabels, disabled: true }}
contract={{ mode: 'review', questions: questionModels, answersByKey, labels: questionLabels, disabled: true }}
itemClass="mb-4"
/>
{:else if disableGrading && Object.keys(grades || {}).length}
{#each questions as currentQuestion, index}
{#each questions as currentQuestion, index (currentQuestion.id)}
{@const questionModel = questionModels[index]}
<div class="mb-4 space-y-3">
<div class="flex items-center justify-between">
@@ -73,7 +74,7 @@
<ExerciseQuestion.QuestionRenderer
contract={{
mode: 'view',
mode: 'review',
question: questionModel,
answer: getAnswerForQuestion(questionModel, index),
labels: questionLabels,
@@ -83,7 +84,7 @@
</div>
{/each}
{:else}
{#each questions as currentQuestion, index}
{#each questions as currentQuestion, index (currentQuestion.id)}
{@const questionModel = questionModels[index]}
<div class="mb-4 space-y-3">
<div class="flex items-center justify-between">
@@ -93,7 +94,7 @@
<ExerciseQuestion.QuestionRenderer
contract={{
mode: 'view',
mode: 'review',
question: questionModel,
answer: getAnswerForQuestion(questionModel, index),
labels: questionLabels,
@@ -18,6 +18,9 @@ export function getExerciseQuestionLabels(): ExerciseQuestionLabels {
'common.correct_badge': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.common.correct_badge'
),
'common.incorrect_badge': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.common.incorrect_badge'
),
'common.not_set': t.get('course.navItem.lessons.exercises.all_exercises.shared_question.common.not_set'),
'navigation.previous': t.get('course.navItem.lessons.exercises.all_exercises.shared_question.navigation.previous'),
'navigation.next': t.get('course.navItem.lessons.exercises.all_exercises.shared_question.navigation.next'),
@@ -131,6 +134,12 @@ export function getExerciseQuestionLabels(): ExerciseQuestionLabels {
'short_answer.preview.helper': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.short_answer.preview.helper'
),
'short_answer.review.your_answer_label': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.short_answer.review.your_answer_label'
),
'short_answer.review.accepted_label': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.short_answer.review.accepted_label'
),
'numeric.edit.correct_value_label': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.numeric.edit.correct_value_label'
),
@@ -158,6 +167,15 @@ export function getExerciseQuestionLabels(): ExerciseQuestionLabels {
'numeric.preview.tolerance_label': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.numeric.preview.tolerance_label'
),
'numeric.review.your_answer_label': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.numeric.review.your_answer_label'
),
'numeric.review.in_range': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.numeric.review.in_range'
),
'numeric.review.out_of_range': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.numeric.review.out_of_range'
),
'fill_blank.edit.accepted_answers_placeholder': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.fill_blank.edit.accepted_answers_placeholder'
),
@@ -167,6 +185,9 @@ export function getExerciseQuestionLabels(): ExerciseQuestionLabels {
'fill_blank.preview.accepted_answers_label': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.fill_blank.preview.accepted_answers_label'
),
'fill_blank.review.your_answer_label': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.fill_blank.review.your_answer_label'
),
'file_upload.edit.accepted_types_placeholder': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.file_upload.edit.accepted_types_placeholder'
),
@@ -81,7 +81,7 @@
<div class="pb-4">
<ExerciseQuestion.QuestionRenderer
contract={{
mode: 'view',
mode: 'review',
question: { ...toExerciseQuestionModel(q), title: `${i + 1}. ${q.title}` },
answer: getStudentAnswerForQuestion($submissions[studentSelected], q),
labels: questionLabels,
@@ -7,6 +7,7 @@ export { default as CourseFilterPopover } from './course-filter-popover.svelte';
export { default as CourseTagPicker } from './course-tag-picker.svelte';
export { default as CreateCourseButton } from './create-course-button.svelte';
export { default as CourseList } from './list.svelte';
export { default as CourseTagsOverflow } from './course-tags-overflow.svelte';
export { default as CourseListLoader } from './list-loader.svelte';
export { default as NewCourseModal } from './new-course-modal.svelte';
export { default as QuestionContainer } from './question-container.svelte';
@@ -2,10 +2,10 @@
import { untrack } from 'svelte';
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
import UserIcon from '@lucide/svelte/icons/user';
import * as Avatar from '@cio/ui/base/avatar';
import { TextareaField } from '@cio/ui/custom/textarea-field';
import { shortenName } from '$lib/utils/functions/string';
import { DeleteModal } from '$features/ui';
import { Button } from '@cio/ui/base/button';
@@ -99,11 +99,12 @@
<div>
<div class="flex h-full items-start gap-3">
<Avatar.Root class="mt-2 size-6!">
<Avatar.Image
src={$profile.avatarUrl ? $profile.avatarUrl : '/logo-192.png'}
alt={$profile.fullname ? $profile.fullname : 'User'}
/>
<Avatar.Fallback>{shortenName($profile.fullname) || 'U'}</Avatar.Fallback>
{#if $profile.avatarUrl}
<Avatar.Image src={$profile.avatarUrl} alt={$profile.fullname ? $profile.fullname : 'User'} />
{/if}
<Avatar.Fallback>
<UserIcon class="ui:size-3 ui:text-muted-foreground" />
</Avatar.Fallback>
</Avatar.Root>
<div class="h-full w-full">
<TextareaField
@@ -125,11 +126,12 @@
{#each comments as commentItem (commentItem.id)}
<div class="mt-2 flex items-start gap-3 pb-2">
<Avatar.Root class="mt-2 size-6!">
<Avatar.Image
src={commentItem.avatar ? commentItem.avatar : '/logo-192.png'}
alt={commentItem.name ? commentItem.name : 'User'}
/>
<Avatar.Fallback>{shortenName(commentItem.name) || 'U'}</Avatar.Fallback>
{#if commentItem.avatar}
<Avatar.Image src={commentItem.avatar} alt={commentItem.name ? commentItem.name : 'User'} />
{/if}
<Avatar.Fallback>
<UserIcon class="ui:size-3 ui:text-muted-foreground" />
</Avatar.Fallback>
</Avatar.Root>
<div class="w-full rounded-md border px-3 py-2 dark:border-neutral-700">
@@ -5,6 +5,7 @@
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
import { CircleCheckIcon } from '$features/ui/icons';
import { Button } from '@cio/ui/base/button';
import * as Tooltip from '@cio/ui/base/tooltip';
import { globalStore } from '$lib/utils/store/app';
import { t } from '$lib/utils/functions/translations';
import { courseApi, lessonApi } from '$features/course/api';
@@ -115,8 +116,25 @@
}
};
}
const INTERACTIVE_TAGS = new Set(['INPUT', 'TEXTAREA', 'SELECT']);
function handleKeydown(event: KeyboardEvent) {
const target = event.target as HTMLElement;
if (INTERACTIVE_TAGS.has(target.tagName) || target.isContentEditable) return;
if (event.key === 'ArrowLeft' && !isPrevDisabled) {
event.preventDefault();
goToContent(prevNextContent.prev);
} else if (event.key === 'ArrowRight' && !isNextDisabled) {
event.preventDefault();
goToContent(prevNextContent.next);
}
}
</script>
<svelte:window onkeydown={handleKeydown} />
<div class="flex items-center gap-2">
{#if showMarkComplete && lessonId}
<Button
@@ -131,27 +149,41 @@
</Button>
{/if}
<div class="flex items-center gap-1">
<Button
size="icon-sm"
variant="outline"
onclick={() => goToContent(prevNextContent.prev)}
disabled={isPrevDisabled}
aria-label={$t('course.navItem.lessons.prev')}
title={$t('course.navItem.lessons.prev')}
>
<ChevronLeftIcon size={14} />
</Button>
<Tooltip.Provider>
<div class="flex items-center gap-1">
<Tooltip.Root>
<Tooltip.Trigger>
<Button
size="icon-sm"
variant="outline"
onclick={() => goToContent(prevNextContent.prev)}
disabled={isPrevDisabled}
aria-label={$t('course.navItem.lessons.prev')}
>
<ChevronLeftIcon size={14} />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side="bottom" sideOffset={4}>
{$t('course.navItem.lessons.prev_shortcut')}
</Tooltip.Content>
</Tooltip.Root>
<Button
size="icon-sm"
variant="outline"
onclick={() => goToContent(prevNextContent.next)}
disabled={isNextDisabled}
aria-label={$t('course.navItem.lessons.next')}
title={$t('course.navItem.lessons.next')}
>
<ChevronRightIcon size={14} />
</Button>
</div>
<Tooltip.Root>
<Tooltip.Trigger>
<Button
size="icon-sm"
variant="outline"
onclick={() => goToContent(prevNextContent.next)}
disabled={isNextDisabled}
aria-label={$t('course.navItem.lessons.next')}
>
<ChevronRightIcon size={14} />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side="bottom" sideOffset={4}>
{$t('course.navItem.lessons.next_shortcut')}
</Tooltip.Content>
</Tooltip.Root>
</div>
</Tooltip.Provider>
</div>
@@ -9,6 +9,7 @@
import type { TLocale } from '@cio/db/types';
import AIButton from '$features/course/components/lesson/ai-button.svelte';
import type { Writable } from 'svelte/store';
import { saveDraft } from '$features/course/utils/lesson-draft';
interface Props {
mode?: (typeof MODES)[keyof typeof MODES];
@@ -33,12 +34,7 @@
}
lessonApi.translations[lessonId][lessonApi.currentLocale] = `${content}`;
try {
localStorage.setItem(`lesson-${lessonId}-${lessonApi.currentLocale}`, `${content}`);
} catch (error) {
console.error('Error saving lesson note to localStorage', error);
}
saveDraft(lessonId, lessonApi.currentLocale, `${content}`);
lessonApi.isDirty = true;
}
@@ -1,4 +1,5 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { goto } from '$app/navigation';
import * as Table from '@cio/ui/base/table';
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
@@ -7,6 +8,7 @@
import { isMobileStore } from '@cio/ui/hooks/is-mobile.svelte';
import { t } from '$lib/utils/functions/translations';
import CoursePublishBadge from './course-publish-badge.svelte';
import CourseTagsOverflow from './course-tags-overflow.svelte';
interface Props {
id?: string;
@@ -60,29 +62,18 @@
}
</script>
<Table.Row class="cursor-pointer" onclick={() => goto(`/courses/${id}`)}>
<Table.Row class="cursor-pointer" onclick={() => goto(resolve(`/courses/[id]`, { id }))}>
<Table.Cell class="truncate"><p class="font-semibold">{title}</p></Table.Cell>
<Table.Cell class="truncate">
<p>{description}</p>
</Table.Cell>
{#if !isMobileStore.current}
<Table.Cell class="truncate">{type}</Table.Cell>
<Table.Cell>
<Table.Cell class="w-3/12 min-w-0">
{#if tags.length === 0}
<span class="ui:text-muted-foreground text-2xs">-</span>
{:else}
<div class="ui:text-muted-foreground text-2xs flex max-w-[220px] flex-wrap items-center gap-2">
{#each tags as tag (tag.id)}
<span class="inline-flex items-center gap-1.5">
<span
class="ui:bg-primary/60 inline-block h-1.5 w-1.5 rounded-full"
style={tag.color ? `background-color: ${tag.color}` : undefined}
aria-hidden="true"
></span>
<span>{tag.name}</span>
</span>
{/each}
</div>
<CourseTagsOverflow {tags} variant="table" />
{/if}
</Table.Cell>
<Table.Cell>{totalLessons}</Table.Cell>
@@ -3,10 +3,10 @@
import { TextareaField } from '@cio/ui/custom/textarea-field';
import { CheckboxField } from '@cio/ui/custom/checkbox-field';
import { t } from '$lib/utils/functions/translations';
import { inviteSettingsStore, studentInviteLinkStore } from './store';
import { getInvitePayload } from './invite-utils';
import { snackbar } from '$features/ui/snackbar/store';
import { peopleApi } from '$features/course/api';
import { orgApi } from '$features/org/api/org.svelte';
import { courseApi } from '$features/course/api';
import { profile } from '$lib/utils/store/user';
const INVITE_MODAL = 'course.navItem.people.invite_modal';
@@ -19,72 +19,58 @@
let recipientCsv = $state('');
let sendInviteEmails = $state(true);
let isSubmitting = $state(false);
async function createEmailInvites() {
async function inviteNewStudents() {
if (!courseId) return;
if (!recipientCsv.trim()) {
snackbar.error(`${INVITE_MODAL}.snackbar_add_one_recipient`);
snackbar.error('audience.import.snackbar_no_emails');
return;
}
studentInviteLinkStore.update((s) => ({ ...s, isCreating: true }));
isSubmitting = true;
try {
const settings = $inviteSettingsStore;
const payload = getInvitePayload(settings);
const response = await peopleApi.createStudentInvite(courseId, {
...payload,
const response = await orgApi.importAudienceMembers({
recipientCsv,
courseIds: [courseId],
sendEmail: sendInviteEmails
});
const createdCount = response?.invites?.length || 0;
const sent = response?.delivery?.sent || 0;
const failed = response?.delivery?.failed || 0;
const duplicates = response?.duplicatesSkipped?.length || 0;
snackbar.success(
$t(`${INVITE_MODAL}.snackbar_email_invites_success`, {
count: createdCount,
sent,
failed,
duplicates
})
);
recipientCsv = '';
onInviteCreated?.();
if (response) {
recipientCsv = '';
await courseApi.refreshCourse(courseId, $profile.id);
await onInviteCreated?.();
}
} catch (error) {
console.error('Failed to create email invites', error);
snackbar.error(`${INVITE_MODAL}.snackbar_failed_create_email_invites`);
console.error('Failed to invite new students into organization', error);
snackbar.error('audience.import.snackbar_failed');
} finally {
studentInviteLinkStore.update((s) => ({ ...s, isCreating: false }));
isSubmitting = false;
}
}
const isCreating = $derived($studentInviteLinkStore.isCreating);
</script>
<div class="rounded-md border p-4">
<p class="mb-2 text-base font-semibold">{$t(`${INVITE_MODAL}.direct_email_bulk`)}</p>
<p class="mb-2 text-base font-semibold">{$t(`${INVITE_MODAL}.invite_new_students_title`)}</p>
<p class="ui:text-muted-foreground mb-2 text-sm">
{$t(`${INVITE_MODAL}.direct_email_description`)}
{$t(`${INVITE_MODAL}.invite_new_students_description`)}
</p>
<TextareaField
label={$t(`${INVITE_MODAL}.recipient_emails`)}
label={$t('audience.import.emails_label')}
bind:value={recipientCsv}
rows={4}
className="w-full"
placeholder={$t(`${INVITE_MODAL}.recipient_emails_placeholder`)}
placeholder={$t('audience.import.emails_placeholder')}
/>
<CheckboxField
className="mt-2"
name="send-invite-emails"
label={$t(`${INVITE_MODAL}.send_invite_emails`)}
label={$t('audience.import.send_email')}
bind:checked={sendInviteEmails}
/>
<div class="mt-3">
<Button variant="secondary" onclick={createEmailInvites} loading={isCreating}>
{$t(`${INVITE_MODAL}.create_email_invites`)}
<Button variant="secondary" onclick={inviteNewStudents} loading={isSubmitting}>
{$t(`${INVITE_MODAL}.invite_new_students`)}
</Button>
</div>
</div>
@@ -0,0 +1,149 @@
<script lang="ts">
import { SvelteSet } from 'svelte/reactivity';
import { Button } from '@cio/ui/base/button';
import { CheckboxField } from '@cio/ui/custom/checkbox-field';
import { Search } from '@cio/ui/custom/search';
import { t } from '$lib/utils/functions/translations';
import { snackbar } from '$features/ui/snackbar/store';
import { orgApi } from '$features/org/api/org.svelte';
import { courseApi } from '$features/course/api';
import { profile } from '$lib/utils/store/user';
import type { OrgStudent } from './types';
interface Props {
courseId: string;
students: OrgStudent[];
isLoading?: boolean;
onAssigned?: () => void | Promise<void>;
}
let { courseId, students, isLoading = false, onAssigned }: Props = $props();
let searchValue = $state('');
const selectedProfileIds = new SvelteSet<string>();
let sendEmail = $state(true);
let isSubmitting = $state(false);
const filteredStudents = $derived.by(() => {
const query = searchValue.trim().toLowerCase();
const sortedStudents = [...students].sort((a, b) =>
(a.name || a.email || '').localeCompare(b.name || b.email || '', undefined, { sensitivity: 'base' })
);
if (!query) {
return sortedStudents;
}
return sortedStudents.filter((student) => {
const name = student.name?.toLowerCase() || '';
const email = student.email?.toLowerCase() || '';
return name.includes(query) || email.includes(query);
});
});
const selectedCount = $derived(selectedProfileIds.size);
function toggleStudent(profileId: string) {
if (selectedProfileIds.has(profileId)) {
selectedProfileIds.delete(profileId);
} else {
selectedProfileIds.add(profileId);
}
}
async function assignStudents() {
if (!courseId) return;
if (selectedProfileIds.size === 0) {
snackbar.error('course.navItem.people.invite_modal.select_at_least_one_student');
return;
}
isSubmitting = true;
try {
const result = await orgApi.assignAudienceToCourses({
profileIds: [...selectedProfileIds],
courseIds: [courseId],
sendEmail
});
if (result) {
await courseApi.refreshCourse(courseId, $profile.id);
selectedProfileIds.clear();
searchValue = '';
await onAssigned?.();
}
} catch (error) {
console.error('Failed to assign existing students to course', error);
snackbar.error('audience.assign.snackbar_failed');
} finally {
isSubmitting = false;
}
}
</script>
<div class="rounded-md border p-4">
<div class="space-y-1">
<p class="text-base font-semibold">{$t('course.navItem.people.invite_modal.existing_students_title')}</p>
<p class="ui:text-muted-foreground text-sm">
{$t('course.navItem.people.invite_modal.existing_students_description')}
</p>
</div>
<div class="mt-4 space-y-4">
<Search placeholder={$t('course.navItem.people.invite_modal.existing_students_search')} bind:value={searchValue} />
{#if selectedCount > 0}
<p class="ui:text-muted-foreground text-sm">{$t('audience.selected_count', { count: selectedCount })}</p>
{/if}
<div class="max-h-72 space-y-2 overflow-y-auto rounded-md border p-3">
{#if isLoading}
<p class="ui:text-muted-foreground text-sm">
{$t('course.navItem.people.invite_modal.loading_existing_students')}
</p>
{:else if students.length === 0}
<p class="ui:text-muted-foreground text-sm">{$t('course.navItem.people.invite_modal.no_existing_students')}</p>
{:else if filteredStudents.length === 0}
<p class="ui:text-muted-foreground text-sm">
{$t('course.navItem.people.invite_modal.no_matching_existing_students')}
</p>
{:else}
{#each filteredStudents as student (student.profileId)}
<div
class="ui:hover:bg-muted/40 rounded-md px-2 py-1"
role="button"
tabindex="0"
onclick={() => student.profileId && toggleStudent(student.profileId)}
onkeydown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
if (student.profileId) toggleStudent(student.profileId);
}
}}
>
<CheckboxField
name={`existing-student-${student.profileId}`}
label={student.name || student.email}
checked={student.profileId ? selectedProfileIds.has(student.profileId) : false}
>
<span class="ui:text-muted-foreground text-sm">{student.email}</span>
</CheckboxField>
</div>
{/each}
{/if}
</div>
<CheckboxField
name="notify-existing-students"
label={$t('course.navItem.people.invite_modal.notify_existing_students')}
bind:checked={sendEmail}
/>
<div class="flex justify-end">
<Button variant="secondary" onclick={assignStudents} loading={isSubmitting} disabled={isLoading || isSubmitting}>
{$t('course.navItem.people.invite_modal.grant_access')}
</Button>
</div>
</div>
</div>
@@ -13,19 +13,11 @@
import { peopleApi } from '$features/course/api';
import { orgApi } from '$features/org/api/org.svelte';
import type { OrgTeamMember } from '$lib/utils/types/org';
import {
DEFAULT_INVITE_SETTINGS_STATE,
DEFAULT_STUDENT_INVITE_LINK_STATE,
inviteSettingsStore,
studentInviteLinkStore
} from './store';
import type { Tutor } from './types';
import type { OrgStudent, Tutor } from './types';
import TutorSelectSection from './tutor-select-section.svelte';
import InviteSettingsSection from './invite-settings-section.svelte';
import ExistingStudentsSection from './existing-students-section.svelte';
import BulkEmailSection from './bulk-email-section.svelte';
import InviteListSection from './invite-list-section.svelte';
import type { InviteListItem } from './types';
let tutors = $state<Tutor[]>([]);
let selectedIds = $state<string[]>([]);
@@ -36,21 +28,9 @@
const selectedTutors = $derived(tutors.filter((t) => selectedIds.includes(t.id.toString())));
const INVITE_MODAL = 'course.navItem.people.invite_modal';
let existingInvites = $state<InviteListItem[]>([]);
let isLoadingInvites = $state(false);
let activeTab = $state<'tutors' | 'students'>('tutors');
let activeStudentTab = $state<'email' | 'history'>('email');
async function loadInvites() {
if (!courseId) return;
isLoadingInvites = true;
try {
const invites = await peopleApi.listStudentInvites(courseId);
existingInvites = (invites || []) as InviteListItem[];
} finally {
isLoadingInvites = false;
}
}
let isLoadingStudents = $state(false);
let activeTab = $state<'tutors' | 'students'>('students');
const availableStudents = $derived.by(() => getAvailableStudents(orgApi.audience, courseApi.group.people));
function getTutors(team: OrgTeamMember[]) {
const existingTutors = courseApi?.group?.tutors || [];
@@ -65,6 +45,22 @@
}));
}
function getAvailableStudents(students: OrgStudent[], courseMembers: typeof courseApi.group.people) {
const enrolledStudentIds = new Set(
courseMembers
.filter((member) => Number(member.roleId) === ROLE.STUDENT)
.map((member) => member.profileId)
.filter((profileId): profileId is string => Boolean(profileId))
);
return students.filter((student) => {
if (!student.profileId) {
return false;
}
return !enrolledStudentIds.has(student.profileId);
});
}
function setTutors(orgId: string | undefined) {
if (!orgId) return;
untrack(async () => {
@@ -77,6 +73,18 @@
});
}
function loadStudents(orgId: string | undefined) {
if (!orgId) return;
untrack(async () => {
isLoadingStudents = true;
try {
await orgApi.getOrgAudience(orgId);
} finally {
isLoadingStudents = false;
}
});
}
async function onSubmit() {
if (!selectedTutors.length) {
goto(resolve(page.url.pathname, {}));
@@ -105,11 +113,8 @@
$effect(() => {
if (!isOpen || !courseId) return;
untrack(() => {
activeTab = 'tutors';
activeStudentTab = 'email';
inviteSettingsStore.set({ ...DEFAULT_INVITE_SETTINGS_STATE });
studentInviteLinkStore.set({ ...DEFAULT_STUDENT_INVITE_LINK_STATE });
void loadInvites();
activeTab = 'students';
void loadStudents($currentOrg.id);
});
});
</script>
@@ -149,35 +154,8 @@
<UnderlineTabs.Content value="students">
<div class="mt-6 space-y-6">
<InviteSettingsSection />
<UnderlineTabs.Root bind:value={activeStudentTab}>
<UnderlineTabs.List class="flex flex-wrap">
<UnderlineTabs.Trigger value="email">
{$t(`${INVITE_MODAL}.direct_email_bulk`)}
</UnderlineTabs.Trigger>
<UnderlineTabs.Trigger value="history">
{$t(`${INVITE_MODAL}.existing_invites`)}
</UnderlineTabs.Trigger>
</UnderlineTabs.List>
<UnderlineTabs.Content value="email">
<div class="mt-6">
<BulkEmailSection {courseId} onInviteCreated={loadInvites} />
</div>
</UnderlineTabs.Content>
<UnderlineTabs.Content value="history">
<div class="mt-6">
<InviteListSection
{courseId}
invites={existingInvites}
isLoading={isLoadingInvites}
onRefresh={loadInvites}
/>
</div>
</UnderlineTabs.Content>
</UnderlineTabs.Root>
<ExistingStudentsSection {courseId} students={availableStudents} isLoading={isLoadingStudents} />
<BulkEmailSection {courseId} />
</div>
</UnderlineTabs.Content>
</UnderlineTabs.Root>
@@ -1,5 +1,6 @@
import type { StudentInviteListItem, StudentInviteAuditItem } from '../../utils/types';
import type { OrganizationTeamMembers } from '$features/org/utils/types';
import type { OrganizationAudience } from '$features/org/utils/types';
import type { TCourseInvitePreset } from '@cio/utils/validation/course/invite';
export interface Profile {
@@ -30,6 +31,9 @@ export type Tutor = Pick<OrgTeamMemberItem, 'id' | 'profileId' | 'email'> & {
text: OrgTeamMemberItem['fullname'];
};
type OrgAudienceMemberItem = OrganizationAudience[number];
export type OrgStudent = OrgAudienceMemberItem;
/** Re-export API-driven invite types from course utils */
export type InviteListItem = StudentInviteListItem;
export type InviteAuditItem = StudentInviteAuditItem;
@@ -4,8 +4,7 @@
import { resolve } from '$app/paths';
import Plus from '@lucide/svelte/icons/plus';
import * as Sidebar from '@cio/ui/base/sidebar';
import { Button } from '@cio/ui/base/button';
import ArrowLeftIcon from '@lucide/svelte/icons/arrow-left';
import { BackButton } from '@cio/ui';
import TableOfContentsIcon from '@lucide/svelte/icons/table-of-contents';
import {
@@ -231,10 +230,7 @@
</script>
<Sidebar.Group class="pt-0!">
<Button variant="link" class="h-fit! justify-start! px-2! py-2!" href={resolve(coursesListPath, {})}>
<ArrowLeftIcon class="custom" />
<span class="text-xs">{$t('org_navigation.courses')}</span>
</Button>
<BackButton href={resolve(coursesListPath, {})} label={$t('org_navigation.courses')} class="px-2! py-2!" />
<Sidebar.Menu>
{#each navItems as item (item.id)}