feat(course): crop cover image to 280x200 on upload

The course cover Upload tab stored the raw file and relied on CSS
object-cover to fake the 280x200 frame at display time. Route uploads
through the existing ImageCropper at a 280/200 aspect ratio so the saved
asset matches the frame. Also teach the cropper UploadTrigger to accept
drag-and-drop so the prior drop-to-upload UX is preserved.
This commit is contained in:
Rotimi Best
2026-06-16 23:50:55 +01:00
parent a3a3c5bf48
commit 219ed2eb92
2 changed files with 82 additions and 26 deletions
@@ -10,10 +10,10 @@
import { t } from '$lib/utils/functions/translations'; import { t } from '$lib/utils/functions/translations';
import { uploadImage } from '$lib/utils/services/upload'; import { uploadImage } from '$lib/utils/services/upload';
import * as FileDropZone from '@cio/ui/custom/file-drop-zone'; import * as ImageCropper from '@cio/ui/custom/image-cropper';
import type { FileRejectedReason } from '@cio/ui/custom/file-drop-zone';
import { InputField } from '@cio/ui/custom/input-field'; import { InputField } from '@cio/ui/custom/input-field';
import { Button } from '@cio/ui/base/button'; import { Button } from '@cio/ui/base/button';
import UploadCloudIcon from '@lucide/svelte/icons/upload-cloud';
interface Props { interface Props {
imageURL?: string; imageURL?: string;
@@ -27,10 +27,15 @@
{ label: 'Unsplash', value: 'unsplash' } { label: 'Unsplash', value: 'unsplash' }
]; ];
// Course cover preview is rendered in a 280×200 frame, so crop uploads to
// that ratio instead of storing the raw image and letting CSS squeeze it.
const COVER_ASPECT = 280 / 200;
let isUploading = $state(false); let isUploading = $state(false);
let isSearching = $state(false); let isSearching = $state(false);
let currentTab = $state(tabs[0].value); let currentTab = $state(tabs[0].value);
let searchQuery = $state(''); let searchQuery = $state('');
let cropperSrc = $state('');
let unsplashImages: { let unsplashImages: {
id: string | number; id: string | number;
user: { user: {
@@ -43,7 +48,7 @@
alt_description: string; alt_description: string;
}[] = $state([]); }[] = $state([]);
const MAX_IMAGE_SIZE = 500 * FileDropZone.KILOBYTE; const MAX_IMAGE_SIZE = 500 * 1000;
async function handleImageClick(img: string) { async function handleImageClick(img: string) {
onchange?.(img); onchange?.(img);
@@ -52,18 +57,22 @@
$handleOpenWidget.open = false; $handleOpenWidget.open = false;
} }
async function handleFilesUpload(files: File[]) { function handleUnsupportedFile(file: File) {
const file = files[0]; if (file.size > MAX_IMAGE_SIZE) {
if (file) await handleUploadImage(file); snackbar.error('snackbar.landing_page_settings.error.file_size');
return;
} }
function handleFileRejected({ reason }: { reason: FileRejectedReason }) {
if (reason === 'Maximum file size exceeded') {
snackbar.error('snackbar.landing_page_settings.error.file_size');
} else {
snackbar.error('snackbar.landing_page_settings.error.file_size'); snackbar.error('snackbar.landing_page_settings.error.file_size');
} }
}
const handleCropped = async (croppedUrl: string) => {
const response = await fetch(croppedUrl);
const blob = await response.blob();
const file = new File([blob], 'course-cover.png', { type: blob.type });
await handleUploadImage(file);
};
const handleUploadImage = async (image: File) => { const handleUploadImage = async (image: File) => {
isUploading = true; isUploading = true;
@@ -117,19 +126,34 @@
</UnderlineTabs.List> </UnderlineTabs.List>
<UnderlineTabs.Content value="upload"> <UnderlineTabs.Content value="upload">
<div class="w-full {isUploading ? 'ui:opacity-50 ui:pointer-events-none' : ''}"> <div class="w-full {isUploading ? 'ui:opacity-50 ui:pointer-events-none' : ''}">
<FileDropZone.Root <ImageCropper.Root
accept={FileDropZone.ACCEPT_IMAGE} bind:src={cropperSrc}
maxFiles={1} onCropped={handleCropped}
fileCount={0} onUnsupportedFile={handleUnsupportedFile}
maxFileSize={MAX_IMAGE_SIZE} maxFileSize={MAX_IMAGE_SIZE}
onUpload={handleFilesUpload} accept=".jpg, .jpeg, .png, .webp"
onFileRejected={handleFileRejected} disabled={isUploading}
> >
<FileDropZone.Trigger <ImageCropper.UploadTrigger
label={$t('course.navItem.landing_page.upload_widget.drag_drop')} class="ui:flex ui:w-full ui:flex-col ui:items-center ui:justify-center ui:gap-2 ui:rounded-lg ui:border-2 ui:border-dashed ui:border-input ui:bg-muted/30 ui:px-6 ui:py-10 ui:text-center ui:transition-colors ui:hover:bg-muted/60"
formatMaxSize={(_size) => $t('course.navItem.landing_page.upload_widget.size')} >
/> <UploadCloudIcon class="ui:text-muted-foreground" size={28} />
</FileDropZone.Root> <p class="ui:m-0 ui:text-sm ui:font-medium">
{$t('course.navItem.landing_page.upload_widget.drag_drop')}
</p>
<p class="ui:m-0 ui:text-xs ui:text-muted-foreground">
{$t('course.navItem.landing_page.upload_widget.size')}
</p>
</ImageCropper.UploadTrigger>
<ImageCropper.Dialog class="ui:z-[400]!">
<ImageCropper.Cropper cropShape="rect" aspect={COVER_ASPECT} />
<ImageCropper.Controls>
<ImageCropper.Cancel />
<ImageCropper.Crop />
</ImageCropper.Controls>
</ImageCropper.Dialog>
</ImageCropper.Root>
</div> </div>
</UnderlineTabs.Content> </UnderlineTabs.Content>
<UnderlineTabs.Content value="unsplash"> <UnderlineTabs.Content value="unsplash">
@@ -3,11 +3,13 @@
import type { ImageCropperUploadTriggerProps } from './types'; import type { ImageCropperUploadTriggerProps } from './types';
import { cn } from '../../tools'; import { cn } from '../../tools';
let { ref = $bindable(null), children, ...rest }: ImageCropperUploadTriggerProps = $props(); let { ref = $bindable(null), class: className, children, ...rest }: ImageCropperUploadTriggerProps = $props();
const triggerState = useImageCropperTrigger(); const triggerState = useImageCropperTrigger();
const isDisabled = $derived(triggerState.rootState.disabled); const isDisabled = $derived(triggerState.rootState.disabled);
let isDragging = $state(false);
function handleClick(e: MouseEvent) { function handleClick(e: MouseEvent) {
if (isDisabled) { if (isDisabled) {
e.preventDefault(); e.preventDefault();
@@ -15,6 +17,28 @@
return false; return false;
} }
} }
function handleDragOver(e: DragEvent) {
if (isDisabled) return;
e.preventDefault();
isDragging = true;
}
function handleDragLeave() {
isDragging = false;
}
function handleDrop(e: DragEvent) {
isDragging = false;
if (isDisabled) return;
const file = e.dataTransfer?.files?.[0];
if (!file) return;
e.preventDefault();
triggerState.rootState.onUpload(file);
}
</script> </script>
<label <label
@@ -22,7 +46,15 @@
bind:this={ref} bind:this={ref}
for={isDisabled ? undefined : triggerState.rootState.id} for={isDisabled ? undefined : triggerState.rootState.id}
onclick={handleClick} onclick={handleClick}
class={cn('ui:cursor-pointer', isDisabled ? 'ui:cursor-not-allowed ui:opacity-50 ui:pointer-events-none' : '')} ondragover={handleDragOver}
ondragleave={handleDragLeave}
ondrop={handleDrop}
class={cn(
'ui:cursor-pointer',
isDisabled ? 'ui:cursor-not-allowed ui:opacity-50 ui:pointer-events-none' : '',
isDragging ? 'ui:ring-2 ui:ring-primary' : '',
className
)}
aria-disabled={isDisabled} aria-disabled={isDisabled}
> >
{@render children?.()} {@render children?.()}