chore: ai, public course and other ui improvements

This commit is contained in:
Rotimi Best
2026-05-09 06:05:03 +01:00
parent 5717dd8004
commit c31ad63955
318 changed files with 20668 additions and 4788 deletions
@@ -6,7 +6,7 @@
import * as UnderlineTabs from '@cio/ui/custom/underline-tabs';
import EyeIcon from '@lucide/svelte/icons/eye';
import PencilIcon from '@lucide/svelte/icons/pencil';
import { onDestroy, onMount, untrack } from 'svelte';
import { onDestroy, onMount, tick, untrack } from 'svelte';
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
import CirclePlusIcon from '@lucide/svelte/icons/circle-plus';
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
@@ -14,7 +14,11 @@
import { ContentType } from '@cio/utils/constants/content';
import {
type ExerciseEditorErrors,
handleAddQuestion,
handleAddSection,
getQuestionsForSection,
hasSections,
questionnaire,
questionnaireOrder,
reset,
@@ -36,21 +40,28 @@
import { IconButton } from '@cio/ui/custom/icon-button';
import UpdateDescription from '$features/course/components/exercise/update-description.svelte';
import { ContentNavigationActions } from '$features/course/components/lesson';
import { RefreshPageData, RoleBasedSecurity } from '$features/ui';
import {
// RefreshPageData,
RoleBasedSecurity,
UnsavedChanges
} from '$features/ui';
import { isSelfPacedLikeCourse } from '$features/course/utils/compliance-utils';
import { getOrderedNavigableContent } from '$features/course/utils/content';
import { refreshExercisePageData } from '$features/course/utils/exercise-page-utils';
import {
hydrateExercisePageData
// , refreshExercisePageData
} from '$features/course/utils/exercise-page-utils';
import { Empty } from '@cio/ui/custom/empty';
import VideoIcon from '@lucide/svelte/icons/video';
import {
getQuestionTypeId,
getQuestionTypeKeyFromId,
getQuestionTypeOptionById
getQuestionTypeKeyFromId
} from '$features/course/components/exercise/question-type-utils';
import type { SubmissionListItem } from '$features/course/utils/types';
import { Badge } from '@cio/ui/base/badge';
import { isAutoGradableQuestionType } from '@cio/question-types';
import * as Dialog from '@cio/ui/base/dialog';
import type { ExerciseSectionState } from '$features/course/components/exercise/store';
interface Props {
exerciseId?: string;
@@ -85,7 +96,32 @@
let shouldDeleteExercise = $state(false);
let isSaving = $state(false);
let isDeleting = $state(false);
let reorderQuestions = $state(false);
let selectedTab = $state<ExerciseTab>('questions');
// eslint-disable-next-line svelte/prefer-writable-derived -- must be writable: cleared before intentional navigations and bound to UnsavedChanges
let hasUnsavedChanges = $state(false);
function isTemporaryId(id: string | number | undefined) {
return typeof id === 'string' && id.includes('-form');
}
function hasDirtyQuestionnaire() {
const hasDirtyQuestion = ($questionnaire.questions ?? []).some((question) => {
const hasDirtyOption = (question.options ?? []).some((option) => option.isDirty || isTemporaryId(option.id));
return question.isDirty || Boolean(question.deletedAt) || isTemporaryId(question.id) || hasDirtyOption;
});
const hasDirtySection = ($questionnaire.sections ?? []).some(
(section) => section.isDirty || Boolean(section.deletedAt)
);
return Boolean(
$questionnaire.isTitleDirty ||
$questionnaire.isDescriptionDirty ||
$questionnaire.isDueByDirty ||
hasDirtyQuestion ||
hasDirtySection
);
}
async function handleDeleteExercise() {
if (!courseApi.course?.id) return;
@@ -94,6 +130,7 @@
await exerciseApi.delete(courseApi.course.id, exerciseId);
if (exerciseApi.success) {
hasUnsavedChanges = false;
courseApi.removeContentItem(exerciseId, ContentType.Exercise);
goBack();
}
@@ -111,6 +148,25 @@
});
}
function getSectionValidationErrors(sections: ExerciseSectionState[]): ExerciseEditorErrors {
const sectionErrors: ExerciseEditorErrors = {};
const requiredFieldMessage = t.get('validations.generic.required_field');
for (const section of sections) {
const nextSectionErrors: ExerciseEditorErrors[string] = {};
if (!section.title?.trim()) {
nextSectionErrors.title = requiredFieldMessage;
}
if (Object.keys(nextSectionErrors).length === 0) continue;
sectionErrors[section.id] = nextSectionErrors;
}
return sectionErrors;
}
async function handleSave() {
if ($isOrgStudent || !courseApi.course?.id) return;
@@ -119,16 +175,33 @@
const transformedQuestions = transformQuestionsToApiFormat($questionnaire.questions, {
shouldFilterEmptyLabels: false
});
const activeSections = $questionnaire.sections.filter((section) => !section.deletedAt);
const sectionsPayload = activeSections.map((section) => ({
id: section.id,
title: section.title,
description: section.description,
order: section.order,
colorTheme: section.colorTheme,
afterBehavior: section.afterBehavior,
questionIds: getQuestionsForSection($questionnaire.questions, section.id).map((question) => question.id)
}));
const shouldSyncSections = $questionnaire.sections.length > 0 || hasSections($questionnaire.sections);
const sectionValidationErrors = getSectionValidationErrors(activeSections);
// Validate using Zod schema
const zodResult = ZExerciseUpdate.safeParse({
questions: transformedQuestions
questions: transformedQuestions,
sections: shouldSyncSections ? sectionsPayload : undefined,
sectionDisplayMode: $questionnaire.sectionDisplayMode
});
if (!zodResult.success) {
const zodErrors = mapZodErrorsToTranslations(zodResult.error);
if (!zodResult.success || Object.keys(sectionValidationErrors).length > 0) {
const zodErrors = !zodResult.success ? mapZodErrorsToTranslations(zodResult.error) : {};
const questionErrors = mapZodErrorsToQuestionErrors(zodErrors, $questionnaire.questions);
questionnaireValidation.set(questionErrors);
questionnaireValidation.set({
...questionErrors,
...sectionValidationErrors
});
snackbar.error('Please fix all validation errors before saving');
return;
}
@@ -167,7 +240,9 @@
title: $questionnaire.title ?? '',
description: $questionnaire.description ?? '',
dueBy: $questionnaire.dueBy ?? '',
questions: questions
questions: questions,
sections: shouldSyncSections ? sectionsPayload : undefined,
sectionDisplayMode: $questionnaire.sectionDisplayMode
});
// Check if there are validation errors from the API
@@ -179,20 +254,10 @@
}
if (exerciseApi.success) {
questionnaire.update((q) => ({
...q,
isTitleDirty: false,
isDescriptionDirty: false,
questions:
exerciseApi.exercise?.questions?.map((question) => {
const questionType = getQuestionTypeOptionById(getQuestionTypeId(question));
return {
...question,
questionTypeId: questionType.id,
questionType
};
}) || q.questions
}));
if (exerciseApi.exercise) {
hydrateExercisePageData(exerciseApi.exercise, exerciseId);
}
hasUnsavedChanges = false;
patchExerciseListItemLocally();
snackbar.success('snackbar.exercise.success');
}
@@ -216,6 +281,17 @@
selectedTab = nextTab;
});
$effect(() => {
hasUnsavedChanges = !$isOrgStudent && hasDirtyQuestionnaire();
});
$effect(() => {
const hasActiveSections = hasSections($questionnaire.sections);
if (!hasActiveSections && reorderQuestions) {
reorderQuestions = false;
}
});
$effect(() => {
const currentTab = page.url.searchParams.get('tab') ?? '';
// Prevent self-navigation loops: only update URL when it actually changes.
@@ -250,6 +326,7 @@
const isExerciseUnlocked = $derived(exerciseContentItem?.isUnlocked ?? false);
const isSelfPacedCourse = $derived(isSelfPacedLikeCourse(courseApi.course?.type));
const isPublicCourse = $derived(courseApi.course?.type === 'PUBLIC');
const activeQuestionTypeIds = $derived(
($questionnaire.questions ?? []).filter((q) => !q.deletedAt).map((q) => getQuestionTypeId(q))
);
@@ -265,11 +342,42 @@
if ($isOrgStudent || !isSelfPacedCourse || activeQuestionTypeIds.length === 0) return null;
return isExerciseFullyAutoGradable(activeQuestionTypeIds) ? ('auto' as const) : ('manual' as const);
});
async function scrollToExerciseElement(elementId: string) {
await tick();
requestAnimationFrame(() => {
document.getElementById(elementId)?.scrollIntoView({
block: 'start',
behavior: 'smooth',
inline: 'nearest'
});
});
}
function getFirstActiveSectionId() {
const [firstActiveSection] = [...($questionnaire.sections ?? [])]
.filter((section) => !section.deletedAt)
.sort((leftSection, rightSection) => leftSection.order - rightSection.order);
return firstActiveSection?.id ?? null;
}
async function addQuestionFromHeader() {
preview = false;
const questionId = handleAddQuestion(getFirstActiveSectionId());
await scrollToExerciseElement(`exercise-question-${questionId}`);
}
async function addSectionFromHeader() {
preview = false;
const sectionId = handleAddSection();
await scrollToExerciseElement(`exercise-section-${sectionId}`);
}
</script>
<Page.Header isSticky={true} class="z-50! min-h-[36px]">
<Page.Header isSticky={true} class="z-100! min-h-[36px]">
<Page.HeaderContent>
<Page.Title class="flex flex-wrap items-center gap-2">
<Page.Title class="flex flex-col gap-2">
<span>{$questionnaire.title || 'Exercise'}</span>
<RoleBasedSecurity allowedRoles={[1, 2]}>
{#if teacherAutoGradeBadge === 'auto'}
@@ -298,7 +406,10 @@
{$t('course.navItem.lessons.exercises.all_exercises.save')}
</Button>
<IconButton
onclick={() => (preview = !preview)}
onclick={() => {
preview = !preview;
reorderQuestions = false;
}}
tooltip={preview
? $t('course.navItem.lessons.exercises.all_exercises.view_mode.edit')
: $t('course.navItem.lessons.exercises.all_exercises.preview')}
@@ -310,13 +421,22 @@
<EyeIcon size={20} />
{/if}
</IconButton>
<IconButton
onclick={handleAddQuestion}
tooltip={$t('course.navItem.lessons.exercises.all_exercises.add_question')}
tooltipSide="bottom"
>
<CirclePlusIcon size={20} />
</IconButton>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<Button type="button" variant="ghost" size="icon" class="h-8 w-8">
<CirclePlusIcon size={20} />
<span class="sr-only">{$t('course.navItem.lessons.exercises.all_exercises.add')}</span>
</Button>
</DropdownMenu.Trigger>
<DropdownMenu.Content align="center" class="z-201!">
<DropdownMenu.Item onclick={addQuestionFromHeader}>
{$t('course.navItem.lessons.exercises.all_exercises.add_question')}
</DropdownMenu.Item>
<DropdownMenu.Item onclick={addSectionFromHeader}>
{$t('course.navItem.lessons.exercises.all_exercises.add_section')}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<Button variant="ghost" size="icon" class="h-8 w-8">
@@ -325,10 +445,19 @@
</Button>
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item onclick={() => ($questionnaireOrder.open = true)}>
{$t('course.navItem.lessons.exercises.all_exercises.reorder')}
</DropdownMenu.Item>
<DropdownMenu.Separator />
{#if hasSections($questionnaire.sections)}
<DropdownMenu.Item onclick={() => (reorderQuestions = !reorderQuestions)}>
{reorderQuestions
? $t('course.navItem.lessons.exercises.all_exercises.reorder_done')
: $t('course.navItem.lessons.exercises.all_exercises.reorder')}
</DropdownMenu.Item>
<DropdownMenu.Separator />
{:else}
<DropdownMenu.Item onclick={() => ($questionnaireOrder.open = true)}>
{$t('course.navItem.lessons.exercises.all_exercises.reorder')}
</DropdownMenu.Item>
<DropdownMenu.Separator />
{/if}
<DropdownMenu.Item
class="text-red-600 focus:text-red-600 dark:text-red-400"
onclick={() => (shouldDeleteExercise = true)}
@@ -341,7 +470,7 @@
</div>
{/if}
<RefreshPageData onRefresh={() => refreshExercisePageData(courseApi.course?.id ?? '', exerciseId)} />
<!-- <RefreshPageData onRefresh={() => refreshExercisePageData(courseApi.course?.id ?? '', exerciseId)} /> -->
</RoleBasedSecurity>
</div>
</Page.Action>
@@ -377,7 +506,7 @@
<Badge variant="outline">{submissions.length}</Badge>
</UnderlineTabs.Trigger>
</UnderlineTabs.List>
<UnderlineTabs.Content value="questions">
<UnderlineTabs.Content value="questions" class="mx-auto w-full md:max-w-3xl">
<UpdateDescription {preview} />
{#if !preview}
<EditMode
@@ -385,6 +514,8 @@
{goBack}
{requiresPositivePointsForAutoGrade}
selfPacedCourse={isSelfPacedCourse}
{isPublicCourse}
{reorderQuestions}
/>
{:else}
<ViewMode {preview} {exerciseId} isFetchingExercise={isFetching} {mySubmissions} />
@@ -430,3 +561,5 @@
</div>
{/snippet}
</Page.Body>
<UnsavedChanges bind:hasUnsavedChanges />
@@ -73,6 +73,8 @@
const lessonTitle = $derived(lessonApi.lesson?.title || 'Lesson');
const isLessonUnlocked = $derived(lessonApi.lesson?.isUnlocked ?? false);
const lessonSlug = $derived(lessonApi.lesson?.slug ?? '');
const isPublicCourse = $derived(courseApi.course?.type === 'PUBLIC');
function setModeQueryParam(value: (typeof MODES)[keyof typeof MODES]) {
const params = new SvelteURLSearchParams($page.url.searchParams);
@@ -202,7 +204,8 @@
isUnlocked: lessonApi.lesson.isUnlocked ?? undefined,
slideUrl: lessonApi.lesson.slideUrl || undefined,
videos: lessonApi.lesson.videos || [],
documents: lessonApi.lesson.documents || []
documents: lessonApi.lesson.documents || [],
slug: isPublicCourse && lessonApi.lesson.slug ? lessonApi.lesson.slug : undefined
}),
saveOrUpdateTranslation(lessonApi.currentLocale, lessonId)
]);
@@ -218,6 +221,10 @@
lessonApi.updateLessonState('title', value);
}
function handleLessonSlugChange(value: string) {
lessonApi.updateLessonState('slug', value);
}
function handleToggleLessonLock() {
const currentValue = lessonApi.lesson?.isUnlocked ?? false;
lessonApi.updateLessonState('isUnlocked', !currentValue);
@@ -361,6 +368,10 @@
onTitleChange={handleLessonTitleChange}
onToggleLock={handleToggleLessonLock}
onDeleteLesson={handleDeleteLesson}
slug={lessonSlug}
showSlugEditor={isPublicCourse}
slugError={lessonApi.errors.slug}
onSlugChange={handleLessonSlugChange}
/>
</Page.HeaderContent>
<Page.Action>
@@ -5,6 +5,7 @@
import BookOpenIcon from '@lucide/svelte/icons/book-open';
import ContentList from '$features/course/components/lesson/content-list.svelte';
import ContentSectionList from '$features/course/components/lesson/content-section-list.svelte';
import CourseContentIcon from '$features/course/components/course-content-icon.svelte';
import { courseApi } from '$features/course/api';
import { t } from '$lib/utils/functions/translations';
import { getCourseContent } from '$features/course/utils/content';
@@ -28,6 +29,12 @@
contentItems.filter((item) => item.type === ContentType.Lesson || item.type === ContentType.Exercise)
);
const sectionsTotal = $derived(
contentData.grouped ? contentData.sections.filter((section) => section.id !== 'ungrouped').length : 0
);
const lessonsTotal = $derived(contentItems.filter((item) => item.type === ContentType.Lesson).length);
const exercisesTotal = $derived(contentItems.filter((item) => item.type === ContentType.Exercise).length);
let isFetching: boolean = $state(false);
let hasHandledNext = $state(false);
@@ -65,6 +72,34 @@
variant="page"
/>
{:else if contentLength > 0}
<div
class="mb-5 grid grid-cols-1 gap-3 sm:grid-cols-3"
role="region"
aria-label={t.get('course.navItem.lessons.heading_v2')}
>
<div class="ui:border-border flex flex-col gap-1 rounded-lg border px-4 py-3">
<div class="ui:text-muted-foreground flex items-center gap-2 text-xs font-medium">
<CourseContentIcon type={ContentType.Section} size={14} />
<span>{$t('course.navItem.lessons.stats.sections')}</span>
</div>
<p class="text-2xl font-semibold tabular-nums">{sectionsTotal}</p>
</div>
<div class="ui:border-border flex flex-col gap-1 rounded-lg border px-4 py-3">
<div class="ui:text-muted-foreground flex items-center gap-2 text-xs font-medium">
<CourseContentIcon type={ContentType.Lesson} size={14} />
<span>{$t('course.navItem.lessons.stats.lessons')}</span>
</div>
<p class="text-2xl font-semibold tabular-nums">{lessonsTotal}</p>
</div>
<div class="ui:border-border flex flex-col gap-1 rounded-lg border px-4 py-3">
<div class="ui:text-muted-foreground flex items-center gap-2 text-xs font-medium">
<CourseContentIcon type={ContentType.Exercise} size={14} />
<span>{$t('course.navItem.lessons.stats.exercises')}</span>
</div>
<p class="text-2xl font-semibold tabular-nums">{exercisesTotal}</p>
</div>
</div>
{#if reorder}
<p class="text-center text-xs text-gray-400 italic dark:text-white">
{$t('course.navItem.lessons.drag')}
@@ -16,7 +16,7 @@
import Empty from '@cio/ui/custom/empty/empty.svelte';
import { BookIcon } from '@lucide/svelte';
import { ROLE } from '@cio/utils/constants';
import { buildNextNewsfeedReaction } from '@cio/ui/custom/newsfeed-reactions';
import { buildNextNewsfeedReaction, type NewsfeedReactionType } from '@cio/ui/custom/newsfeed-reactions';
interface Props {
courseId: string;
@@ -48,13 +48,13 @@
await newsfeedApi.deleteComment(courseId, feedId, commentId);
};
const addNewReaction = async (reactionType: string, feedId: string, authorId: string) => {
const addNewReaction = async (reactionType: NewsfeedReactionType, feedId: string, authorId: string) => {
if (!authorId || isReactingByFeedId[feedId]) return;
const reactedFeed = newsfeedApi.feeds.find((feed) => feed.id === feedId);
if (!reactedFeed) return;
const updatedReaction = buildNextNewsfeedReaction(reactedFeed.reaction, reactionType, authorId);
const updatedReaction = buildNextNewsfeedReaction(reactedFeed.reaction ?? undefined, reactionType, authorId);
isReactingByFeedId = { ...isReactingByFeedId, [feedId]: true };
@@ -170,7 +170,8 @@
metadata: metadataPayload,
slug: courseApi.course.slug ?? undefined,
compliance:
$settings.type === 'COMPLIANCE' ? (courseApi.course.compliance ?? DEFAULT_COMPLIANCE_SETTINGS) : undefined
$settings.type === 'COMPLIANCE' ? (courseApi.course.compliance ?? DEFAULT_COMPLIANCE_SETTINGS) : undefined,
callout: $settings.type === 'PUBLIC' ? sanitizeCalloutForSave($settings.callout) : null
};
const normalizedSelectedTagIds = normalizeTagIds(selectedTagIds);
@@ -220,11 +221,46 @@
lessonDownload: !!course.metadata?.lessonDownload,
isPublished: !!course.isPublished,
allowNewStudents: !!course.metadata?.allowNewStudent,
isContentGroupingEnabled: course.metadata?.isContentGroupingEnabled ?? true
isContentGroupingEnabled: course.metadata?.isContentGroupingEnabled ?? true,
callout: normalizeCallout(course.callout)
});
});
}
function sanitizeCalloutForSave(value: typeof $settings.callout) {
if (!value) return null;
const title = value.title.trim();
const description = value.description.trim();
const buttonLabel = value.buttonLabel.trim();
const buttonUrl = value.buttonUrl.trim();
if (!title && !description && !buttonLabel && !buttonUrl) return null;
return { title, description, buttonLabel, buttonUrl };
}
function normalizeCallout(value: unknown): typeof $settings.callout {
if (!value || typeof value !== 'object') return null;
const candidate = value as Record<string, unknown>;
if (
typeof candidate.title !== 'string' ||
typeof candidate.description !== 'string' ||
typeof candidate.buttonLabel !== 'string' ||
typeof candidate.buttonUrl !== 'string'
) {
return null;
}
return {
title: candidate.title,
description: candidate.description,
buttonLabel: candidate.buttonLabel,
buttonUrl: candidate.buttonUrl
};
}
// Initialize course from page data
$effect(() => {
const courseData = $page.data?.course;
@@ -240,6 +276,15 @@
}
});
$effect(() => {
if ($settings.type === 'PUBLIC' && $settings.callout === null) {
settings.update((prev) => ({
...prev,
callout: { title: '', description: '', buttonLabel: '', buttonUrl: '' }
}));
}
});
$effect(() => {
const courseId = courseApi.course?.id;
@@ -525,21 +570,97 @@
}}
>
<div class="mb-3 flex items-center space-x-2">
<RadioGroup.Item value={'LIVE_CLASS' as TCourseType} id="live-class" />
<Label for="live-class">{$t('course.navItem.settings.live_class')}</Label>
</div>
<div class="flex items-center space-x-2">
<RadioGroup.Item value={'SELF_PACED' as TCourseType} id="self-paced" />
<Label for="self-paced">{$t('course.navItem.settings.self_paced')}</Label>
</div>
<div class="mt-3 flex items-center space-x-2">
<div class="mb-3 flex items-center space-x-2">
<RadioGroup.Item value={'LIVE_CLASS' as TCourseType} id="live-class" />
<Label for="live-class">{$t('course.navItem.settings.live_class')}</Label>
</div>
<div class="mb-3 flex items-center space-x-2">
<RadioGroup.Item value={'COMPLIANCE' as TCourseType} id="compliance-course" />
<Label for="compliance-course">{$t('course.navItem.settings.compliance')}</Label>
</div>
<div class="flex items-center space-x-2">
<RadioGroup.Item value={'PUBLIC' as TCourseType} id="public-course" />
<Label for="public-course">{$t('course.navItem.settings.public')}</Label>
</div>
</RadioGroup.Root>
</Field.Field>
{#if courseApi.errors.type}
<div
class="ui:mt-2 ui:rounded-md ui:border ui:border-destructive/30 ui:bg-destructive/5 ui:p-3 ui:text-sm ui:text-destructive"
role="alert"
>
<div class="ui:font-medium">{$t('course.navItem.settings.convert_to_public_blocked')}</div>
<p class="ui:mt-1 ui:text-destructive/90">{courseApi.errors.type}</p>
</div>
{/if}
</Field.Set>
{#if $settings.type === 'PUBLIC' && $settings.callout}
<Field.Separator />
<Field.Set>
<Field.Legend>{$t('course.navItem.settings.callout.legend')}</Field.Legend>
<Field.Description>{$t('course.navItem.settings.callout.description')}</Field.Description>
<Field.Group>
<Field.Field>
<Field.Label>{$t('course.navItem.settings.callout.title_label')}</Field.Label>
<InputField
bind:value={$settings.callout.title}
onInputChange={() => (hasUnsavedChanges = true)}
placeholder={$t('course.navItem.settings.callout.title_placeholder')}
/>
</Field.Field>
<Field.Field>
<Field.Label>{$t('course.navItem.settings.callout.description_label')}</Field.Label>
<TextareaField
bind:value={$settings.callout.description}
oninput={() => (hasUnsavedChanges = true)}
rows={3}
placeholder={$t('course.navItem.settings.callout.description_placeholder')}
/>
</Field.Field>
<Field.Field>
<Field.Label>{$t('course.navItem.settings.callout.button_label')}</Field.Label>
<InputField
bind:value={$settings.callout.buttonLabel}
onInputChange={() => (hasUnsavedChanges = true)}
placeholder={$t('course.navItem.settings.callout.button_label_placeholder')}
/>
</Field.Field>
<Field.Field>
<Field.Label>{$t('course.navItem.settings.callout.button_url_label')}</Field.Label>
<InputField
bind:value={$settings.callout.buttonUrl}
onInputChange={() => (hasUnsavedChanges = true)}
placeholder={$t('course.navItem.settings.callout.button_url_placeholder')}
type="url"
/>
</Field.Field>
</Field.Group>
<Field.Field>
<Button
variant="outline"
size="sm"
onclick={() => {
settings.update((prev) => ({ ...prev, callout: null }));
hasUnsavedChanges = true;
}}
>
{$t('course.navItem.settings.callout.clear')}
</Button>
</Field.Field>
</Field.Set>
{/if}
<Field.Separator />
<Field.Set>