fix(dashboard): hide compliance in-progress badge and enforce deadline on view-site publish (#913)
* fix(dashboard): hide in-progress badge on compliance learner cards Keep the due date on My Learning and LMS current-course cards, and only show compliance status badges for states that need attention. Co-authored-by: best <[email protected]> * fix(dashboard): enforce compliance deadline when publishing from view course site Reuse the settings deadline-required dialog and navigate to the completion deadline section so unpublished-site publish matches settings and the API. Co-authored-by: best <[email protected]> * fix(dashboard): keep attention highlight visible after deadline navigation Prevent goto and dialog focus restoration from scrolling away from the completion deadline section before the highlight pulse can render. Co-authored-by: best <[email protected]> * fix(dashboard): keep deadline attention highlight visible longer Use onMount plus afterNavigate so the completion deadline ring is not lost to navigation scroll races, and show a stronger primary ring for five seconds. Co-authored-by: best <[email protected]> * test(dashboard): use Jest APIs in compliance badge tests Drop the Vitest import so the new test matches the dashboard's declared Jest runner and @types/jest globals. Co-authored-by: best <[email protected]> ---------
This commit is contained in:
@@ -23,7 +23,8 @@
|
|||||||
import {
|
import {
|
||||||
getStudentCourseComplianceDate,
|
getStudentCourseComplianceDate,
|
||||||
getStudentCourseComplianceStatusKey,
|
getStudentCourseComplianceStatusKey,
|
||||||
getStudentCourseComplianceStatusVariant
|
getStudentCourseComplianceStatusVariant,
|
||||||
|
shouldShowStudentCourseComplianceStatusBadge
|
||||||
} from '$features/course/utils/compliance-utils';
|
} from '$features/course/utils/compliance-utils';
|
||||||
import CardDropdown from './card-dropdown.svelte';
|
import CardDropdown from './card-dropdown.svelte';
|
||||||
import CoursePublishBadge from './course-publish-badge.svelte';
|
import CoursePublishBadge from './course-publish-badge.svelte';
|
||||||
@@ -195,6 +196,11 @@
|
|||||||
? getStudentCourseComplianceStatusKey(course as UserEnrolledCourses[number])
|
? getStudentCourseComplianceStatusKey(course as UserEnrolledCourses[number])
|
||||||
: null
|
: null
|
||||||
);
|
);
|
||||||
|
const showComplianceStatusBadge = $derived(
|
||||||
|
isLMS && type === 'COMPLIANCE' && !isExplore
|
||||||
|
? shouldShowStudentCourseComplianceStatusBadge(course as UserEnrolledCourses[number])
|
||||||
|
: false
|
||||||
|
);
|
||||||
const complianceStatusVariant = $derived(
|
const complianceStatusVariant = $derived(
|
||||||
isLMS && type === 'COMPLIANCE' && !isExplore
|
isLMS && type === 'COMPLIANCE' && !isExplore
|
||||||
? getStudentCourseComplianceStatusVariant(course as UserEnrolledCourses[number])
|
? getStudentCourseComplianceStatusVariant(course as UserEnrolledCourses[number])
|
||||||
@@ -300,7 +306,7 @@
|
|||||||
|
|
||||||
{#if type === 'COMPLIANCE'}
|
{#if type === 'COMPLIANCE'}
|
||||||
<div class="mt-2 flex flex-wrap items-center gap-2">
|
<div class="mt-2 flex flex-wrap items-center gap-2">
|
||||||
{#if complianceStatusKey}
|
{#if showComplianceStatusBadge && complianceStatusKey}
|
||||||
<Badge variant={complianceStatusVariant}>
|
<Badge variant={complianceStatusVariant}>
|
||||||
{$t(complianceStatusKey)}
|
{$t(complianceStatusKey)}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|||||||
+31
@@ -0,0 +1,31 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Button } from '@cio/ui/base/button';
|
||||||
|
import * as Dialog from '@cio/ui/base/dialog';
|
||||||
|
import { t } from '$lib/utils/functions/translations';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open?: boolean;
|
||||||
|
onGoToDeadline: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { open = $bindable(false), onGoToDeadline }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Dialog.Root bind:open>
|
||||||
|
<Dialog.Content class="max-w-md" onCloseAutoFocus={(e) => e.preventDefault()}>
|
||||||
|
<Dialog.Header>
|
||||||
|
<Dialog.Title>{$t('course.navItem.settings.certificate_deadline_required')}</Dialog.Title>
|
||||||
|
<Dialog.Description>
|
||||||
|
{$t('course.navItem.settings.certificate_deadline_required_description')}
|
||||||
|
</Dialog.Description>
|
||||||
|
</Dialog.Header>
|
||||||
|
<Dialog.Footer>
|
||||||
|
<Button variant="outline" onclick={() => (open = false)}>
|
||||||
|
{$t('cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button onclick={onGoToDeadline}>
|
||||||
|
{$t('course.navItem.settings.go_to_completion_deadline')}
|
||||||
|
</Button>
|
||||||
|
</Dialog.Footer>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Root>
|
||||||
+28
-3
@@ -1,10 +1,13 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { tick } from 'svelte';
|
||||||
import { Button } from '@cio/ui/base/button';
|
import { Button } from '@cio/ui/base/button';
|
||||||
import * as Dialog from '@cio/ui/base/dialog';
|
import * as Dialog from '@cio/ui/base/dialog';
|
||||||
import { courseApi } from '$features/course/api';
|
import { courseApi } from '$features/course/api';
|
||||||
import { publishCourse } from '$features/course/utils/publish-course';
|
import { publishCourse } from '$features/course/utils/publish-course';
|
||||||
import { openCoursePreview } from '$features/course/utils/course-preview';
|
import { openCoursePreview } from '$features/course/utils/course-preview';
|
||||||
|
import { goToCourseCompletionDeadline } from '$features/course/utils/compliance-deadline';
|
||||||
import { t } from '$lib/utils/functions/translations';
|
import { t } from '$lib/utils/functions/translations';
|
||||||
|
import CertificateDeadlineRequiredDialog from './certificate-deadline-required-dialog.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
open?: boolean;
|
open?: boolean;
|
||||||
@@ -14,6 +17,7 @@
|
|||||||
let { open = $bindable(false), currentOrgDomain = '' }: Props = $props();
|
let { open = $bindable(false), currentOrgDomain = '' }: Props = $props();
|
||||||
|
|
||||||
let isPublishing = $state(false);
|
let isPublishing = $state(false);
|
||||||
|
let openDeadlineDialog = $state(false);
|
||||||
|
|
||||||
async function handlePublishCourse() {
|
async function handlePublishCourse() {
|
||||||
const course = courseApi.course;
|
const course = courseApi.course;
|
||||||
@@ -23,10 +27,17 @@
|
|||||||
|
|
||||||
isPublishing = true;
|
isPublishing = true;
|
||||||
|
|
||||||
const published = await publishCourse(course);
|
const result = await publishCourse(course);
|
||||||
isPublishing = false;
|
isPublishing = false;
|
||||||
|
|
||||||
if (!published) {
|
if (!result.ok && result.reason === 'missing_deadline') {
|
||||||
|
open = false;
|
||||||
|
await tick();
|
||||||
|
openDeadlineDialog = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,6 +49,18 @@
|
|||||||
currentOrgDomain
|
currentOrgDomain
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function goToCompletionDeadline() {
|
||||||
|
const courseId = courseApi.course?.id;
|
||||||
|
openDeadlineDialog = false;
|
||||||
|
|
||||||
|
if (!courseId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await tick();
|
||||||
|
goToCourseCompletionDeadline(courseId);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Dialog.Root
|
<Dialog.Root
|
||||||
@@ -48,7 +71,7 @@
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Dialog.Content class="w-[calc(100%-2rem)] max-w-md! p-4">
|
<Dialog.Content class="w-[calc(100%-2rem)] max-w-md! p-4" onCloseAutoFocus={(e) => e.preventDefault()}>
|
||||||
<Dialog.Header>
|
<Dialog.Header>
|
||||||
<Dialog.Title>{$t('course.view_course_site.unpublished_title')}</Dialog.Title>
|
<Dialog.Title>{$t('course.view_course_site.unpublished_title')}</Dialog.Title>
|
||||||
<Dialog.Description>{$t('course.view_course_site.unpublished_description')}</Dialog.Description>
|
<Dialog.Description>{$t('course.view_course_site.unpublished_description')}</Dialog.Description>
|
||||||
@@ -64,3 +87,5 @@
|
|||||||
</Dialog.Footer>
|
</Dialog.Footer>
|
||||||
</Dialog.Content>
|
</Dialog.Content>
|
||||||
</Dialog.Root>
|
</Dialog.Root>
|
||||||
|
|
||||||
|
<CertificateDeadlineRequiredDialog bind:open={openDeadlineDialog} onGoToDeadline={goToCompletionDeadline} />
|
||||||
|
|||||||
@@ -6,12 +6,12 @@
|
|||||||
import { Switch } from '@cio/ui/base/switch';
|
import { Switch } from '@cio/ui/base/switch';
|
||||||
import * as RadioGroup from '@cio/ui/base/radio-group';
|
import * as RadioGroup from '@cio/ui/base/radio-group';
|
||||||
import * as Select from '@cio/ui/base/select';
|
import * as Select from '@cio/ui/base/select';
|
||||||
import * as Dialog from '@cio/ui/base/dialog';
|
|
||||||
import RotateCcwIcon from '@lucide/svelte/icons/rotate-ccw';
|
import RotateCcwIcon from '@lucide/svelte/icons/rotate-ccw';
|
||||||
import ArrowUpRightIcon from '@lucide/svelte/icons/arrow-up-right';
|
import ArrowUpRightIcon from '@lucide/svelte/icons/arrow-up-right';
|
||||||
import XIcon from '@lucide/svelte/icons/x';
|
import XIcon from '@lucide/svelte/icons/x';
|
||||||
|
|
||||||
import ReorderMaterialTabs from '$features/course/components/reorder-material-tabs.svelte';
|
import ReorderMaterialTabs from '$features/course/components/reorder-material-tabs.svelte';
|
||||||
|
import CertificateDeadlineRequiredDialog from '$features/course/components/certificate-deadline-required-dialog.svelte';
|
||||||
import { CourseTagPicker } from '$features/course/components';
|
import { CourseTagPicker } from '$features/course/components';
|
||||||
import { IconButton } from '@cio/ui/custom/icon-button';
|
import { IconButton } from '@cio/ui/custom/icon-button';
|
||||||
import { TextareaField } from '@cio/ui/custom/textarea-field';
|
import { TextareaField } from '@cio/ui/custom/textarea-field';
|
||||||
@@ -43,6 +43,7 @@
|
|||||||
import { handleOpenWidget } from '$features/ui/course-landing-page/store';
|
import { handleOpenWidget } from '$features/ui/course-landing-page/store';
|
||||||
import { currentOrgDomain, currentOrgPath, isFreePlan } from '$lib/utils/store/org';
|
import { currentOrgDomain, currentOrgPath, isFreePlan } from '$lib/utils/store/org';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
|
import { ROUTE_NAME, ROUTE_SECTIONS } from '$lib/routing/routes';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
hasUnsavedChanges?: boolean;
|
hasUnsavedChanges?: boolean;
|
||||||
@@ -519,24 +520,7 @@
|
|||||||
|
|
||||||
<DeleteModal onDelete={handleDeleteCourse} bind:open={openDeleteModal} />
|
<DeleteModal onDelete={handleDeleteCourse} bind:open={openDeleteModal} />
|
||||||
|
|
||||||
<Dialog.Root bind:open={openCertificateDeadlineDialog}>
|
<CertificateDeadlineRequiredDialog bind:open={openCertificateDeadlineDialog} onGoToDeadline={goToCompletionDeadline} />
|
||||||
<Dialog.Content class="max-w-md">
|
|
||||||
<Dialog.Header>
|
|
||||||
<Dialog.Title>{$t('course.navItem.settings.certificate_deadline_required')}</Dialog.Title>
|
|
||||||
<Dialog.Description>
|
|
||||||
{$t('course.navItem.settings.certificate_deadline_required_description')}
|
|
||||||
</Dialog.Description>
|
|
||||||
</Dialog.Header>
|
|
||||||
<Dialog.Footer>
|
|
||||||
<Button variant="outline" onclick={() => (openCertificateDeadlineDialog = false)}>
|
|
||||||
{$t('cancel')}
|
|
||||||
</Button>
|
|
||||||
<Button onclick={goToCompletionDeadline}>
|
|
||||||
{$t('course.navItem.settings.go_to_completion_deadline')}
|
|
||||||
</Button>
|
|
||||||
</Dialog.Footer>
|
|
||||||
</Dialog.Content>
|
|
||||||
</Dialog.Root>
|
|
||||||
|
|
||||||
<Field.Group class="w-full max-w-md! px-2">
|
<Field.Group class="w-full max-w-md! px-2">
|
||||||
<Field.Set>
|
<Field.Set>
|
||||||
@@ -705,7 +689,10 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<Field.Group class="mt-3">
|
<Field.Group class="mt-3">
|
||||||
<AttentionHighlight id="course-completion-deadline" trigger={completionDeadlineTrigger}>
|
<AttentionHighlight
|
||||||
|
id={ROUTE_SECTIONS[ROUTE_NAME.COURSE_SETTINGS].COMPLETION_DEADLINE}
|
||||||
|
trigger={completionDeadlineTrigger}
|
||||||
|
>
|
||||||
<Field.Field>
|
<Field.Field>
|
||||||
<Field.Label>
|
<Field.Label>
|
||||||
{$t('course.navItem.settings.completion_deadline_label')}
|
{$t('course.navItem.settings.completion_deadline_label')}
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { isPublishedComplianceMissingDeadline } from '@cio/utils/functions';
|
||||||
|
import { goAndHighlight } from '$lib/routing/go-and-highlight';
|
||||||
|
import { ROUTE_NAME, ROUTE_SECTIONS } from '$lib/routing/routes';
|
||||||
|
import type { Course } from './types';
|
||||||
|
|
||||||
|
type CourseDeadlineSource = Pick<Course, 'type'> & {
|
||||||
|
certificate?: { deadline?: string | null } | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function isCourseMissingComplianceDeadline(course: CourseDeadlineSource): boolean {
|
||||||
|
return isPublishedComplianceMissingDeadline({
|
||||||
|
type: course.type,
|
||||||
|
isPublished: true,
|
||||||
|
deadline: course.certificate?.deadline
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function goToCourseCompletionDeadline(courseId: string) {
|
||||||
|
goAndHighlight(ROUTE_NAME.COURSE_SETTINGS, ROUTE_SECTIONS[ROUTE_NAME.COURSE_SETTINGS].COMPLETION_DEADLINE, {
|
||||||
|
id: courseId
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { shouldShowStudentCourseComplianceStatusBadge, getStudentCourseComplianceStatusKey } from './compliance-utils';
|
||||||
|
|
||||||
|
function makeCourse(overrides: { type?: string; complianceStatus?: string | null }) {
|
||||||
|
return {
|
||||||
|
type: overrides.type ?? 'COMPLIANCE',
|
||||||
|
complianceStatus: overrides.complianceStatus
|
||||||
|
} as Parameters<typeof shouldShowStudentCourseComplianceStatusBadge>[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('shouldShowStudentCourseComplianceStatusBadge', () => {
|
||||||
|
it('hides the in-progress badge on learner course cards', () => {
|
||||||
|
const course = makeCourse({ complianceStatus: 'in_progress' });
|
||||||
|
|
||||||
|
expect(shouldShowStudentCourseComplianceStatusBadge(course)).toBe(false);
|
||||||
|
expect(getStudentCourseComplianceStatusKey(course)).toBe('course.navItem.compliance.status.in_progress');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still shows overdue and expiring statuses', () => {
|
||||||
|
expect(shouldShowStudentCourseComplianceStatusBadge(makeCourse({ complianceStatus: 'non_compliant' }))).toBe(true);
|
||||||
|
expect(shouldShowStudentCourseComplianceStatusBadge(makeCourse({ complianceStatus: 'expiring_soon' }))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is hidden for non-compliance courses', () => {
|
||||||
|
expect(
|
||||||
|
shouldShowStudentCourseComplianceStatusBadge(makeCourse({ type: 'SELF_PACED', complianceStatus: 'in_progress' }))
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -98,6 +98,12 @@ export function getStudentCourseComplianceStatusKey(course: StudentEnrolledCours
|
|||||||
return complianceStatus ? `course.navItem.compliance.status.${complianceStatus}` : null;
|
return complianceStatus ? `course.navItem.compliance.status.${complianceStatus}` : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function shouldShowStudentCourseComplianceStatusBadge(course: StudentEnrolledCourse) {
|
||||||
|
const complianceStatus = getStudentCourseComplianceStatus(course);
|
||||||
|
|
||||||
|
return !!complianceStatus && complianceStatus !== 'in_progress';
|
||||||
|
}
|
||||||
|
|
||||||
export function getStudentCourseComplianceStatusVariant(course: StudentEnrolledCourse) {
|
export function getStudentCourseComplianceStatusVariant(course: StudentEnrolledCourse) {
|
||||||
const complianceStatus = getStudentCourseComplianceStatus(course);
|
const complianceStatus = getStudentCourseComplianceStatus(course);
|
||||||
if (!complianceStatus) {
|
if (!complianceStatus) {
|
||||||
|
|||||||
@@ -2,10 +2,17 @@ import { generateSlug } from '@cio/utils/functions';
|
|||||||
import { isObject } from '$lib/utils/functions/isObject';
|
import { isObject } from '$lib/utils/functions/isObject';
|
||||||
import { courseApi } from '$features/course/api';
|
import { courseApi } from '$features/course/api';
|
||||||
import type { Course } from '$features/course/utils/types';
|
import type { Course } from '$features/course/utils/types';
|
||||||
|
import { isCourseMissingComplianceDeadline } from './compliance-deadline';
|
||||||
|
|
||||||
export async function publishCourse(course: Course): Promise<boolean> {
|
export type PublishCourseResult = { ok: true } | { ok: false; reason: 'missing_deadline' | 'failed' };
|
||||||
|
|
||||||
|
export async function publishCourse(course: Course): Promise<PublishCourseResult> {
|
||||||
if (!course?.id) {
|
if (!course?.id) {
|
||||||
return false;
|
return { ok: false, reason: 'failed' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCourseMissingComplianceDeadline(course)) {
|
||||||
|
return { ok: false, reason: 'missing_deadline' };
|
||||||
}
|
}
|
||||||
|
|
||||||
let slug = course.slug?.trim() ? course.slug : undefined;
|
let slug = course.slug?.trim() ? course.slug : undefined;
|
||||||
@@ -28,5 +35,13 @@ export async function publishCourse(course: Course): Promise<boolean> {
|
|||||||
{ showSuccessToast: true }
|
{ showSuccessToast: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
return !!result;
|
if (!result) {
|
||||||
|
if (courseApi.errors['certificate.deadline']) {
|
||||||
|
return { ok: false, reason: 'missing_deadline' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: false, reason: 'failed' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,8 @@
|
|||||||
getStudentCourseComplianceDate,
|
getStudentCourseComplianceDate,
|
||||||
getStudentCourseComplianceStatusKey,
|
getStudentCourseComplianceStatusKey,
|
||||||
getStudentCourseComplianceStatusVariant,
|
getStudentCourseComplianceStatusVariant,
|
||||||
getStudentCourseProgressPercent
|
getStudentCourseProgressPercent,
|
||||||
|
shouldShowStudentCourseComplianceStatusBadge
|
||||||
} from '$features/course/utils/compliance-utils';
|
} from '$features/course/utils/compliance-utils';
|
||||||
import { getStudentCourseContinuePath } from '$features/course/utils/student-course-navigation';
|
import { getStudentCourseContinuePath } from '$features/course/utils/student-course-navigation';
|
||||||
|
|
||||||
@@ -60,7 +61,7 @@
|
|||||||
>
|
>
|
||||||
{#if highlightedCourses.length > 0}
|
{#if highlightedCourses.length > 0}
|
||||||
<div class="flex h-full w-full flex-col justify-start overflow-y-auto">
|
<div class="flex h-full w-full flex-col justify-start overflow-y-auto">
|
||||||
{#each highlightedCourses as course}
|
{#each highlightedCourses as course (course.id)}
|
||||||
<div class="p-5">
|
<div class="p-5">
|
||||||
<span class="flex flex-col items-start gap-3 pb-5 lg:flex-row">
|
<span class="flex flex-col items-start gap-3 pb-5 lg:flex-row">
|
||||||
<img
|
<img
|
||||||
@@ -78,7 +79,7 @@
|
|||||||
{@const complianceStatusKey = getStudentCourseComplianceStatusKey(course)}
|
{@const complianceStatusKey = getStudentCourseComplianceStatusKey(course)}
|
||||||
{@const complianceDate = getStudentCourseComplianceDate(course)}
|
{@const complianceDate = getStudentCourseComplianceDate(course)}
|
||||||
<div class="mt-2 flex flex-wrap items-center gap-2">
|
<div class="mt-2 flex flex-wrap items-center gap-2">
|
||||||
{#if complianceStatusKey}
|
{#if shouldShowStudentCourseComplianceStatusBadge(course) && complianceStatusKey}
|
||||||
<Badge variant={getStudentCourseComplianceStatusVariant(course)}>
|
<Badge variant={getStudentCourseComplianceStatusVariant(course)}>
|
||||||
{$t(complianceStatusKey)}
|
{$t(complianceStatusKey)}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { afterNavigate, replaceState } from '$app/navigation';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { replaceState } from '$app/navigation';
|
|
||||||
import { resolve } from '$app/paths';
|
import { resolve } from '$app/paths';
|
||||||
import type { Snippet } from 'svelte';
|
import { onMount, tick, type Snippet } from 'svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -11,13 +11,13 @@
|
|||||||
children?: Snippet;
|
children?: Snippet;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { id, duration = 3, trigger = 0, children }: Props = $props();
|
let { id, duration = 5, trigger = 0, children }: Props = $props();
|
||||||
|
|
||||||
let containerRef = $state<HTMLDivElement | null>(null);
|
let containerRef = $state<HTMLDivElement | null>(null);
|
||||||
let pulsing = $state(false);
|
let pulsing = $state(false);
|
||||||
let handledFor = $state<string | null>(null);
|
|
||||||
let lastTrigger = trigger;
|
let lastTrigger = trigger;
|
||||||
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let retryTimeoutIds: ReturnType<typeof setTimeout>[] = [];
|
||||||
|
|
||||||
function getActiveHighlightId(url: URL): string | null {
|
function getActiveHighlightId(url: URL): string | null {
|
||||||
const query = url.searchParams.get('highlight');
|
const query = url.searchParams.get('highlight');
|
||||||
@@ -27,15 +27,44 @@
|
|||||||
return hash || null;
|
return hash || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clearScrollRetries() {
|
||||||
|
for (const retryTimeoutId of retryTimeoutIds) {
|
||||||
|
clearTimeout(retryTimeoutId);
|
||||||
|
}
|
||||||
|
|
||||||
|
retryTimeoutIds = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearTimers() {
|
||||||
|
if (timeoutId) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
timeoutId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearScrollRetries();
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollToSection() {
|
||||||
|
containerRef?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
}
|
||||||
|
|
||||||
function triggerPulse() {
|
function triggerPulse() {
|
||||||
pulsing = true;
|
pulsing = true;
|
||||||
|
scrollToSection();
|
||||||
|
|
||||||
containerRef?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
void tick().then(() => {
|
||||||
|
scrollToSection();
|
||||||
|
clearScrollRetries();
|
||||||
|
retryTimeoutIds = [50, 300].map((delayMs) => setTimeout(scrollToSection, delayMs));
|
||||||
|
});
|
||||||
|
|
||||||
if (timeoutId) clearTimeout(timeoutId);
|
if (timeoutId) clearTimeout(timeoutId);
|
||||||
|
|
||||||
|
const pulseMs = (Number(duration) || 5) * 1000;
|
||||||
timeoutId = setTimeout(() => {
|
timeoutId = setTimeout(() => {
|
||||||
pulsing = false;
|
pulsing = false;
|
||||||
timeoutId = null;
|
timeoutId = null;
|
||||||
|
clearScrollRetries();
|
||||||
|
|
||||||
const url = new URL(page.url);
|
const url = new URL(page.url);
|
||||||
const highlightActive = url.searchParams.get('highlight') === id;
|
const highlightActive = url.searchParams.get('highlight') === id;
|
||||||
@@ -47,21 +76,30 @@
|
|||||||
if (highlightActive || hashActive) {
|
if (highlightActive || hashActive) {
|
||||||
replaceState(resolve(`${url.pathname}${url.search}`, {}), page.state);
|
replaceState(resolve(`${url.pathname}${url.search}`, {}), page.state);
|
||||||
}
|
}
|
||||||
}, duration * 1000);
|
}, pulseMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
$effect(() => {
|
function pulseIfUrlMatches() {
|
||||||
const activeHighlightId = getActiveHighlightId(page.url);
|
if (getActiveHighlightId(page.url) !== id) return;
|
||||||
|
|
||||||
if (activeHighlightId !== id) {
|
if (pulsing) {
|
||||||
handledFor = null;
|
scrollToSection();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (handledFor === activeHighlightId) return;
|
|
||||||
|
|
||||||
handledFor = activeHighlightId;
|
|
||||||
triggerPulse();
|
triggerPulse();
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
pulseIfUrlMatches();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimers();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
afterNavigate(() => {
|
||||||
|
pulseIfUrlMatches();
|
||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -73,15 +111,12 @@
|
|||||||
triggerPulse();
|
triggerPulse();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
return () => {
|
|
||||||
if (timeoutId) clearTimeout(timeoutId);
|
|
||||||
};
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div bind:this={containerRef} class={`rounded-md p-2 ${pulsing ? 'ui:border-primary animate-pulse border' : ''}`}>
|
<div
|
||||||
|
bind:this={containerRef}
|
||||||
|
class={`scroll-mt-16 rounded-md p-2 ${pulsing ? 'ui:bg-primary/15 ui:ring-primary ui:ring-4 ui:ring-offset-background ui:ring-offset-2' : ''}`}
|
||||||
|
>
|
||||||
{#if children}
|
{#if children}
|
||||||
{@render children()}
|
{@render children()}
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -21,5 +21,5 @@ export function goAndHighlight<R extends ROUTE_NAME>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
query.set('highlight', sectionName);
|
query.set('highlight', sectionName);
|
||||||
goto(resolve(`${path}?${query.toString()}`, {}));
|
goto(resolve(`${path}?${query.toString()}`, {}), { noScroll: true, keepFocus: true });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ export const ROUTE_PATHS = {
|
|||||||
|
|
||||||
export const ROUTE_SECTIONS = {
|
export const ROUTE_SECTIONS = {
|
||||||
[ROUTE_NAME.COURSE_SETTINGS]: {
|
[ROUTE_NAME.COURSE_SETTINGS]: {
|
||||||
PUBLISH: 'publish'
|
PUBLISH: 'publish',
|
||||||
|
COMPLETION_DEADLINE: 'course-completion-deadline'
|
||||||
},
|
},
|
||||||
|
|
||||||
[ROUTE_NAME.COURSE_CERTIFICATE]: {
|
[ROUTE_NAME.COURSE_CERTIFICATE]: {
|
||||||
|
|||||||
Reference in New Issue
Block a user