fix(dashboard): fix instructor avatar upload on course landing page (#745)

* fix(dashboard): fix instructor avatar upload on course landing page

The crop dialog was hidden behind the full-screen (z-250) landing-page
editor because bits-ui portals it to <body> at z-modal (200), and every
crop triggered an unbounded loop of POST /media/image requests.

- Add opt-in `elevatedDialog` prop to UploadImage that raises the crop
  dialog above the editor takeover; enable it in the instructor,
  certificate, and review landing-page editor forms.
- Break the upload effect loop in instructor-form by reading `course.id`
  through `untrack`, so the effect no longer re-runs when the setter
  reassigns `course` after each unique upload URL.
- Handle upload failures in instructor-form with a snackbar and always
  reset `isUploading`, so a failed upload no longer leaves the widget
  stuck and silent.

* docs(email): add email notification preferences PRD

Specify a Settings feature letting users enable/disable notification emails at two scopes: personal per-user preferences and org-wide admin defaults. Both gates must allow a send; transactional emails stay mandatory. Grounds storage in organization.settings and a new profile.settings jsonb, with a single preference guard at the email enqueue chokepoint.
This commit is contained in:
Rotimi Best
2026-07-13 00:01:11 +01:00
committed by GitHub
parent 7439f1f510
commit 2815a61437
15 changed files with 434 additions and 21 deletions
@@ -53,7 +53,7 @@
<p class="text-center font-bold">
{$t('course.navItem.landing_page.editor.certificate_form.upload_template')}
</p>
<UploadImage bind:avatar src={templateUrl} shape="rounded-none" bind:isUploading />
<UploadImage bind:avatar src={templateUrl} shape="rounded-none" bind:isUploading elevatedDialog />
</div>
<div class="mt-5 flex flex-col gap-2">
@@ -1,4 +1,5 @@
<script lang="ts">
import { untrack } from 'svelte';
import get from 'lodash/get';
import { TextareaField } from '@cio/ui/custom/textarea-field';
import { InputField } from '@cio/ui/custom/input-field';
@@ -8,6 +9,7 @@
import type { Course } from '$features/course/utils/types';
import { t } from '$lib/utils/functions/translations';
import { currentOrg } from '$lib/utils/store/org';
import { snackbar } from '$features/ui/snackbar/store';
interface Props {
course: Course;
@@ -36,18 +38,19 @@
let isUploading = $state(false);
async function onAvatarChange(_avatar: File | undefined) {
if (!_avatar || !course.id) return;
const courseId = untrack(() => course.id);
if (!_avatar || !courseId) return;
isUploading = true;
const logo = await uploadImage(_avatar);
if (!logo) {
try {
imgUrl = await uploadImage(_avatar);
} catch (error) {
console.error('Failed to upload instructor avatar:', error);
snackbar.error('snackbar.landing_page_settings.error.upload_failed');
} finally {
isUploading = false;
return;
}
imgUrl = logo;
isUploading = false;
}
$effect(() => {
@@ -73,7 +76,7 @@
{$t('course.navItem.landing_page.editor.instructor_form.upload')}
</label>
<div class="flex w-full justify-center">
<UploadImage bind:avatar src={imgUrl} bind:isUploading />
<UploadImage bind:avatar src={imgUrl} bind:isUploading elevatedDialog />
</div>
</div>
@@ -33,7 +33,13 @@
</script>
<div class="flex w-full flex-col items-center">
<UploadImage bind:avatar src={review.avatar_url} widthHeight="w-20 h-20" errorMessage={errors.avatar_url} />
<UploadImage
bind:avatar
src={review.avatar_url}
widthHeight="w-20 h-20"
errorMessage={errors.avatar_url}
elevatedDialog
/>
<InputField
className="mt-2 w-full"
@@ -19,6 +19,8 @@
flexDirection?: string;
isUploading?: boolean;
previewVariant?: 'avatar' | 'signature';
/** Raise the crop dialog above full-screen takeover surfaces (e.g. the z-250 landing-page editor). */
elevatedDialog?: boolean;
change?: () => void;
}
@@ -33,6 +35,7 @@
flexDirection = 'flex-col',
isUploading = $bindable(false),
previewVariant = 'avatar',
elevatedDialog = false,
change
}: Props = $props();
@@ -147,7 +150,7 @@
{/if}
</div>
<ImageCropper.Dialog>
<ImageCropper.Dialog class={elevatedDialog ? 'z-[300]!' : undefined}>
<ImageCropper.Cropper cropShape={shape === 'rounded-full' ? 'round' : 'rect'} />
<ImageCropper.Controls>
{#if isSignaturePreview}