feat(dashboard): add content page options menu (#757)

* feat(dashboard): add content page options menu

Add a three-dot menu on the course Content page with actions to unlock
all locked lessons/exercises and toggle content grouping. Reuse shared
locked-content collection logic from course settings.

Co-authored-by: Rotimi Best <[email protected]>

* fix(dashboard): keep unlock-all enabled and add menu icons

Show a success snackbar when all content is already unlocked instead of
disabling the action. Add lock-open and table-of-contents icons to menu items.

Co-authored-by: Rotimi Best <[email protected]>

* fix(dashboard): check courseApi.update return for grouping toggle

Use the update() return value instead of courseApi.success, which can be
false after a successful PUT if the follow-up refresh request fails.

Co-authored-by: Rotimi Best <[email protected]>

---------

Co-authored-by: Rotimi Best <[email protected]>
This commit is contained in:
Rotimi Best
2026-07-15 22:28:23 +01:00
committed by GitHub
co-authored by Rotimi Best
parent bfafc685e0
commit 656fa4fa7e
14 changed files with 231 additions and 26 deletions
@@ -0,0 +1,112 @@
<script lang="ts">
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
import LockOpenIcon from '@lucide/svelte/icons/lock-open';
import TableOfContentsIcon from '@lucide/svelte/icons/table-of-contents';
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
import { IconButton } from '@cio/ui/custom/icon-button';
import { RoleBasedSecurity } from '$features/ui';
import { t } from '$lib/utils/functions/translations';
import { contentApi, courseApi } from '$features/course/api';
import { collectLockedContentItems } from '$features/course/utils/content-lock-utils';
import { isObject } from '$lib/utils/functions/isObject';
import { snackbar } from '$features/ui/snackbar/store';
interface Props {
courseId: string;
disabled?: boolean;
}
let { courseId, disabled = false }: Props = $props();
let isUnlockingAll = $state(false);
let isTogglingGrouping = $state(false);
const contentGroupingEnabled = $derived(courseApi.course?.metadata?.isContentGroupingEnabled ?? true);
const lockedContentItems = $derived(collectLockedContentItems(courseApi.course));
const menuDisabled = $derived(disabled || isUnlockingAll || isTogglingGrouping);
async function handleUnlockAllContent() {
if (!courseId || isUnlockingAll) return;
if (lockedContentItems.length === 0) {
snackbar.success('snackbar.course_settings.success.all_content_already_unlocked');
return;
}
isUnlockingAll = true;
const itemsToUnlock = [...lockedContentItems];
const didUnlock = await contentApi.updateContent(
courseId,
itemsToUnlock.map((item) => ({ id: item.id, type: item.type, isUnlocked: true }))
);
if (didUnlock) {
for (const item of itemsToUnlock) {
courseApi.updateContentItem(item.id, item.type, { isUnlocked: true });
}
snackbar.success('snackbar.course_settings.success.unlocked_all_content');
}
isUnlockingAll = false;
}
async function handleToggleContentGrouping() {
const course = courseApi.course;
if (!course || isTogglingGrouping) return;
const nextEnabled = !contentGroupingEnabled;
const metadata = isObject(course.metadata) ? course.metadata : {};
isTogglingGrouping = true;
const result = await courseApi.update(
course.id,
{
metadata: {
...metadata,
isContentGroupingEnabled: nextEnabled
}
},
{ showSuccessToast: false }
);
if (result) {
const snackbarKey = nextEnabled
? 'snackbar.course_settings.success.content_grouping_enabled'
: 'snackbar.course_settings.success.content_grouping_disabled';
snackbar.success(snackbarKey);
}
isTogglingGrouping = false;
}
</script>
<RoleBasedSecurity allowedRoles={[1, 2]}>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<IconButton
disabled={menuDisabled}
tooltip={t.get('course.navItem.lessons.content_menu.label')}
tooltipSide="bottom"
aria-label={t.get('course.navItem.lessons.content_menu.label')}
>
<EllipsisVerticalIcon size={16} />
</IconButton>
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item onclick={handleUnlockAllContent} disabled={menuDisabled}>
<span class="flex items-center gap-2">
<LockOpenIcon size={14} />
{$t('course.navItem.lessons.content_menu.unlock_all')}
</span>
</DropdownMenu.Item>
<DropdownMenu.Item onclick={handleToggleContentGrouping} disabled={menuDisabled}>
<span class="flex items-center gap-2">
<TableOfContentsIcon size={14} />
{contentGroupingEnabled
? $t('course.navItem.lessons.content_menu.disable_grouping')
: $t('course.navItem.lessons.content_menu.enable_grouping')}
</span>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</RoleBasedSecurity>
@@ -25,7 +25,6 @@
import LockOpenIcon from '@lucide/svelte/icons/lock-open';
import type { TCourseType } from '@cio/db/types';
import type { Course } from '../utils/types';
import { ContentType } from '@cio/utils/constants/content';
import { t } from '$lib/utils/functions/translations';
import { isObject } from '$lib/utils/functions/isObject';
import { snackbar } from '$features/ui/snackbar/store';
@@ -33,6 +32,7 @@
import { DEFAULT_COMPLIANCE_SETTINGS } from '../utils/compliance-utils';
import { DeleteModal } from '$features/ui';
import { contentApi, courseApi } from '$features/course/api';
import { collectLockedContentItems } from '$features/course/utils/content-lock-utils';
import { tagApi } from '$features/tag/api';
import { uploadImage } from '$lib/utils/services/upload';
import { copyToClipboard } from '$lib/utils/functions/formatYoutubeVideo';
@@ -120,22 +120,6 @@
let isUnlockingAll = $state(false);
type LockedContentItem = { id: string; type: ContentType.Lesson | ContentType.Exercise };
function collectLockedContentItems(course: Course | null): LockedContentItem[] {
const content = course?.content;
if (!content) return [];
const items = content.grouped ? content.sections.flatMap((section) => section.items) : content.items;
return items
.filter((item) => (item.isUnlocked ?? true) === false)
.map((item) => ({
id: item.id,
type: item.type === ContentType.Exercise ? ContentType.Exercise : ContentType.Lesson
}));
}
const lockedContentItems = $derived(collectLockedContentItems(courseApi.course));
const isLiveClassCourse = $derived(courseApi.course?.type === 'LIVE_CLASS');
const showLockedContentNotice = $derived($settings.isPublished && lockedContentItems.length > 0);
@@ -1,5 +1,22 @@
import { ContentType } from '@cio/utils/constants/content';
import type { StudentContentLockReason } from '$features/ai-assistant/utils/content-ask-ai-bar';
import type { Course } from './types';
import { getCourseContent } from './content';
export type LockedContentItem = { id: string; type: ContentType.Lesson | ContentType.Exercise };
export function collectLockedContentItems(course: Course | null): LockedContentItem[] {
const content = getCourseContent(course);
const items = content.grouped ? content.sections.flatMap((section) => section.items) : content.items;
return items
.filter((item) => (item.isUnlocked ?? true) === false)
.map((item) => ({
id: item.id,
type: item.type === ContentType.Exercise ? ContentType.Exercise : ContentType.Lesson
}));
}
export function getStudentContentLockTitleKey(reason: StudentContentLockReason): string {
if (reason === 'progression_locked') {