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:
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
snackbar.error('snackbar.landing_page_settings.error.file_size');
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleFileRejected({ reason }: { reason: FileRejectedReason }) {
|
const handleCropped = async (croppedUrl: string) => {
|
||||||
if (reason === 'Maximum file size exceeded') {
|
const response = await fetch(croppedUrl);
|
||||||
snackbar.error('snackbar.landing_page_settings.error.file_size');
|
const blob = await response.blob();
|
||||||
} else {
|
const file = new File([blob], 'course-cover.png', { type: blob.type });
|
||||||
snackbar.error('snackbar.landing_page_settings.error.file_size');
|
|
||||||
}
|
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?.()}
|
||||||
|
|||||||
Reference in New Issue
Block a user