feat: include image cropper (#617)
* fix: include image cropper * clean styles * ui improvements * bug bot feedbacks * fix: update image upload format to PNG for consistency with cropping output --------- Co-authored-by: Rotimi Best <[email protected]>
This commit is contained in:
co-authored by
Rotimi Best
parent
bc9e3b41ae
commit
fdadc7bccf
@@ -79,7 +79,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section
|
<section
|
||||||
class="m-auto w-full p-3 lg:w-11/12 lg:px-4"
|
class="mx-auto w-full p-3 lg:w-11/12 lg:px-4"
|
||||||
use:dndzone={{
|
use:dndzone={{
|
||||||
items: $lessons,
|
items: $lessons,
|
||||||
flipDurationMs,
|
flipDurationMs,
|
||||||
|
|||||||
@@ -178,7 +178,7 @@
|
|||||||
<DeleteLessonConfirmation bind:openDeleteModal {deleteLesson} />
|
<DeleteLessonConfirmation bind:openDeleteModal {deleteLesson} />
|
||||||
|
|
||||||
<section
|
<section
|
||||||
class="m-auto w-full p-3 lg:w-11/12 lg:px-4"
|
class="mx-auto w-full p-3 lg:w-11/12 lg:px-4"
|
||||||
use:dndzone={{
|
use:dndzone={{
|
||||||
items: $lessonSections,
|
items: $lessonSections,
|
||||||
flipDurationMs,
|
flipDurationMs,
|
||||||
|
|||||||
@@ -2,12 +2,11 @@
|
|||||||
import { Skeleton } from '@cio/ui/base/skeleton';
|
import { Skeleton } from '@cio/ui/base/skeleton';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="mb-7 flex max-w-3xl flex-col gap-5 rounded-md border border-gray-300 p-4">
|
<div class="mb-7 flex w-full max-w-3xl flex-col gap-3 rounded-md border border-gray-300 p-3">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Skeleton class="h-8 w-8 overflow-hidden rounded-full" />
|
<Skeleton class="h-8 w-8 overflow-hidden rounded-full" />
|
||||||
<Skeleton class="h-4 w-[30%]" />
|
<Skeleton class="h-4 w-[30%]" />
|
||||||
</div>
|
</div>
|
||||||
<Skeleton class="h-4 w-full" />
|
<Skeleton class="h-4 w-full" />
|
||||||
<Skeleton class="h-4 w-full" />
|
<Skeleton class="h-4 w-full" />
|
||||||
<Skeleton class="h-4 w-full" />
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,15 +8,14 @@
|
|||||||
import MessageSquareMoreIcon from '@lucide/svelte/icons/message-square-more';
|
import MessageSquareMoreIcon from '@lucide/svelte/icons/message-square-more';
|
||||||
import MessageCirclePlusIcon from '@lucide/svelte/icons/message-circle-plus';
|
import MessageCirclePlusIcon from '@lucide/svelte/icons/message-circle-plus';
|
||||||
|
|
||||||
|
import { Vote } from '$features/ui';
|
||||||
import { profile } from '$lib/utils/store/user';
|
import { profile } from '$lib/utils/store/user';
|
||||||
import { t } from '$lib/utils/functions/translations';
|
import { t } from '$lib/utils/functions/translations';
|
||||||
import { communityApi } from '../api/community.svelte';
|
|
||||||
import { calDateDiff } from '$lib/utils/functions/date';
|
import { calDateDiff } from '$lib/utils/functions/date';
|
||||||
import { currentOrg, currentOrgPath } from '$lib/utils/store/org';
|
import { currentOrg, currentOrgPath } from '$lib/utils/store/org';
|
||||||
|
|
||||||
import { AskCommunityButton } from '../components';
|
import { CommunityListLoader, AskCommunityButton } from '../components';
|
||||||
import { CommunityListLoader } from '../components';
|
import { communityApi } from '../api/community.svelte';
|
||||||
import { Vote } from '$features/ui';
|
|
||||||
import type { CommunityQuestionData } from '../utils/types';
|
import type { CommunityQuestionData } from '../utils/types';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -32,9 +31,7 @@
|
|||||||
if (!orgId || !profileId) return;
|
if (!orgId || !profileId) return;
|
||||||
|
|
||||||
untrack(async () => {
|
untrack(async () => {
|
||||||
// Fetch courses - API class handles this
|
|
||||||
await communityApi.fetchCoursesForOrg(profileId, orgId);
|
await communityApi.fetchCoursesForOrg(profileId, orgId);
|
||||||
// Call API - API class handles loading, errors, navigation
|
|
||||||
await communityApi.fetchCommunityQuestions({ orgId, isLMS });
|
await communityApi.fetchCommunityQuestions({ orgId, isLMS });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -46,8 +43,8 @@
|
|||||||
let filteredQuestions = $derived(
|
let filteredQuestions = $derived(
|
||||||
communityApi.questions.filter(
|
communityApi.questions.filter(
|
||||||
(question: CommunityQuestionData[number]) =>
|
(question: CommunityQuestionData[number]) =>
|
||||||
question.title.toLowerCase().includes(searchValue.toLowerCase()) &&
|
question?.title?.toLowerCase?.()?.includes(searchValue.toLowerCase()) &&
|
||||||
(!selectedId || question.courseId === selectedId)
|
(!selectedId || question?.courseId === selectedId)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
@@ -90,11 +87,11 @@
|
|||||||
{question.title}
|
{question.title}
|
||||||
</Item.Title>
|
</Item.Title>
|
||||||
<Item.Description>
|
<Item.Description>
|
||||||
{question.authorFullname} asked {calDateDiff(question.createdAt)}
|
{question?.authorFullname} asked {calDateDiff(question?.createdAt)}
|
||||||
</Item.Description>
|
</Item.Description>
|
||||||
<a class="m-0" href="/courses/{question.courseId}" onclick={(e) => e.stopPropagation()}>
|
<a class="m-0" href="/courses/{question.courseId}" onclick={(e) => e.stopPropagation()}>
|
||||||
<span class="text-muted-foreground p-0 text-xs">
|
<span class="text-muted-foreground p-0 text-xs">
|
||||||
#{question.courseTitle}
|
#{question?.courseTitle}
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
</Item.Content>
|
</Item.Content>
|
||||||
|
|||||||
@@ -250,7 +250,7 @@
|
|||||||
/>
|
/>
|
||||||
</RoleBasedSecurity>
|
</RoleBasedSecurity>
|
||||||
{#if isLoading}
|
{#if isLoading}
|
||||||
<div>
|
<div class="flex w-full flex-col items-center">
|
||||||
<NewsFeedLoader />
|
<NewsFeedLoader />
|
||||||
<NewsFeedLoader />
|
<NewsFeedLoader />
|
||||||
<NewsFeedLoader />
|
<NewsFeedLoader />
|
||||||
@@ -260,8 +260,7 @@
|
|||||||
title={$t('course.navItem.news_feed.body_header')}
|
title={$t('course.navItem.news_feed.body_header')}
|
||||||
description={$t('course.navItem.news_feed.body_content')}
|
description={$t('course.navItem.news_feed.body_content')}
|
||||||
icon={BookIcon}
|
icon={BookIcon}
|
||||||
class="h-full"
|
/>
|
||||||
></Empty>
|
|
||||||
{:else}
|
{:else}
|
||||||
{#each $newsFeed as feed}
|
{#each $newsFeed as feed}
|
||||||
{#if feed.isPinned}
|
{#if feed.isPinned}
|
||||||
@@ -286,4 +285,3 @@
|
|||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
</RoleBasedSecurity>
|
</RoleBasedSecurity>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Progress } from '@cio/ui/base/progress';
|
import { Progress } from '@cio/ui/base/progress';
|
||||||
|
import * as ImageCropper from '@cio/ui/custom/image-cropper';
|
||||||
import CameraIcon from '@lucide/svelte/icons/camera';
|
import CameraIcon from '@lucide/svelte/icons/camera';
|
||||||
|
import PencilIcon from '@lucide/svelte/icons/pencil';
|
||||||
|
|
||||||
import { t } from '$lib/utils/functions/translations';
|
import { t } from '$lib/utils/functions/translations';
|
||||||
|
|
||||||
@@ -19,7 +21,7 @@
|
|||||||
|
|
||||||
let {
|
let {
|
||||||
avatar = $bindable(),
|
avatar = $bindable(),
|
||||||
src,
|
src = $bindable(),
|
||||||
widthHeight = '',
|
widthHeight = '',
|
||||||
shape = 'rounded-full',
|
shape = 'rounded-full',
|
||||||
errorMessage = $bindable(null),
|
errorMessage = $bindable(null),
|
||||||
@@ -31,100 +33,99 @@
|
|||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
const defaultImg = 'https://cdn4.iconfinder.com/data/icons/small-n-flat/24/user-alt-512.png';
|
const defaultImg = 'https://cdn4.iconfinder.com/data/icons/small-n-flat/24/user-alt-512.png';
|
||||||
let fileinput: HTMLInputElement | undefined = $state();
|
|
||||||
let imgRef: HTMLImageElement | undefined = $state();
|
|
||||||
|
|
||||||
const onFileSelected = (
|
// Local image source for the cropper (never null)
|
||||||
e: Event & {
|
let cropperSrc = $derived(src || '');
|
||||||
currentTarget: EventTarget & HTMLInputElement;
|
|
||||||
}
|
|
||||||
) => {
|
|
||||||
const image = e.currentTarget?.files?.[0];
|
|
||||||
if (!image) return;
|
|
||||||
|
|
||||||
const maxFileSize = maxFileSizeInMb * 1024 * 1024;
|
const onCropped = async (croppedUrl: string) => {
|
||||||
if (image.size > maxFileSize) {
|
// Convert the cropped data URL to a File object
|
||||||
errorMessage = `${$t('settings.profile.profile_picture.validation_error')} ${maxFileSize / (1024 * 1024)} MB`;
|
const response = await fetch(croppedUrl);
|
||||||
|
const blob = await response.blob();
|
||||||
|
// getCroppedImg outputs PNG format, so use blob.type (which will be 'image/png') and matching filename
|
||||||
|
const file = new File([blob], 'cropped-image.png', { type: blob.type });
|
||||||
|
|
||||||
|
// Update avatar with the cropped file
|
||||||
|
avatar = file;
|
||||||
|
|
||||||
|
// Update the src preview
|
||||||
|
src = croppedUrl;
|
||||||
|
cropperSrc = croppedUrl;
|
||||||
|
|
||||||
|
// trigger onchange to parent
|
||||||
|
change?.();
|
||||||
|
|
||||||
|
// clear error
|
||||||
|
errorMessage = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onUnsupportedFile = (file: File) => {
|
||||||
|
const maxFileSizeInBytes = maxFileSizeInMb * 1024 * 1024;
|
||||||
|
|
||||||
|
// Check if error is due to file size
|
||||||
|
if (file.size > maxFileSizeInBytes) {
|
||||||
|
errorMessage = `${$t('settings.profile.profile_picture.validation_error')} File size exceeds ${maxFileSizeInMb}MB limit`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const reader = new FileReader();
|
// Otherwise, it's an unsupported file type
|
||||||
reader.addEventListener('load', function () {
|
errorMessage = `${$t('settings.profile.profile_picture.validation_error')} Unsupported file type: ${file.type}`;
|
||||||
if (!imgRef || !reader.result) return;
|
|
||||||
|
|
||||||
// pass image as avatar to parent component
|
|
||||||
avatar = image;
|
|
||||||
|
|
||||||
// update the image src on the DOM
|
|
||||||
imgRef.setAttribute('src', reader.result.toString());
|
|
||||||
|
|
||||||
// trigger onchange to parent
|
|
||||||
change?.();
|
|
||||||
|
|
||||||
// clear error
|
|
||||||
errorMessage = null;
|
|
||||||
});
|
|
||||||
reader.readAsDataURL(image);
|
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="width-fit flex p-3 {flexDirection} items-center justify-between gap-5">
|
<section class="flex w-fit p-3 {flexDirection} items-center justify-between gap-2">
|
||||||
<div
|
<ImageCropper.Root
|
||||||
class="avatar-container {widthHeight ||
|
bind:src={cropperSrc}
|
||||||
'setwidthheight'} pointer relative border-2 border-gray-200 dark:border-neutral-600 {shape}"
|
{onCropped}
|
||||||
>
|
{onUnsupportedFile}
|
||||||
<img bind:this={imgRef} class="h-full w-full {shape}" src={src || defaultImg} alt="" />
|
maxFileSize={maxFileSizeInMb * 1024 * 1024}
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-col items-center">
|
|
||||||
<button
|
|
||||||
class="width-fit ui:text-primary flex flex-col items-center text-sm {isDisabled || isUploading
|
|
||||||
? 'cursor-not-allowed opacity-50'
|
|
||||||
: 'cursor-pointer'}"
|
|
||||||
onclick={() => {
|
|
||||||
if (!isDisabled || isUploading) {
|
|
||||||
fileinput?.click();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={isDisabled || isUploading}
|
|
||||||
>
|
|
||||||
{#if isUploading}
|
|
||||||
<Progress />
|
|
||||||
{:else}
|
|
||||||
<CameraIcon size={16} />
|
|
||||||
{/if}
|
|
||||||
<span class="ml-2">{$t('settings.profile.profile_picture.upload_image')}</span>
|
|
||||||
</button>
|
|
||||||
<p class="text-center text-xs text-gray-500">
|
|
||||||
{$t('settings.profile.profile_picture.file_size')}
|
|
||||||
{maxFileSizeInMb}MB<br />
|
|
||||||
{$t('settings.profile.profile_picture.accepted')}: jpeg, jpg, png, webp
|
|
||||||
</p>
|
|
||||||
{#if errorMessage}
|
|
||||||
<p class="text-sm text-red-500">{errorMessage}</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<input
|
|
||||||
style="display:none"
|
|
||||||
type="file"
|
|
||||||
accept=".jpg, .jpeg, .png, .webp"
|
accept=".jpg, .jpeg, .png, .webp"
|
||||||
onchange={(e) => onFileSelected(e)}
|
disabled={isDisabled || isUploading}
|
||||||
bind:this={fileinput}
|
>
|
||||||
/>
|
<ImageCropper.UploadTrigger aria-disabled={isDisabled || isUploading}>
|
||||||
|
<div
|
||||||
|
class="avatar-container {widthHeight ||
|
||||||
|
'h-[128px] w-[128px]'} pointer border-2 border-gray-200 dark:border-neutral-600 {shape}"
|
||||||
|
>
|
||||||
|
<ImageCropper.Preview>
|
||||||
|
{#snippet child({ src: imageSrc })}
|
||||||
|
<div class="group relative h-full w-full">
|
||||||
|
<img class="h-full w-full {shape}" src={imageSrc || defaultImg} alt="" />
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 flex items-center justify-center bg-black/50 opacity-0 transition-opacity duration-200 group-hover:opacity-100 {shape}"
|
||||||
|
>
|
||||||
|
<PencilIcon class="stroke-white" size={16} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
</ImageCropper.Preview>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-1 flex flex-col items-center">
|
||||||
|
{#if isUploading}
|
||||||
|
<Progress />
|
||||||
|
{:else}
|
||||||
|
<CameraIcon size={16} />
|
||||||
|
{/if}
|
||||||
|
<span class="text-sm">{$t('settings.profile.profile_picture.upload_image')}</span>
|
||||||
|
</div>
|
||||||
|
</ImageCropper.UploadTrigger>
|
||||||
|
<div class="flex flex-col items-center">
|
||||||
|
<p class="text-center text-xs text-gray-500">
|
||||||
|
{$t('settings.profile.profile_picture.file_size')}
|
||||||
|
{maxFileSizeInMb}MB<br />
|
||||||
|
{$t('settings.profile.profile_picture.accepted')}: jpeg, jpg, png, webp
|
||||||
|
</p>
|
||||||
|
{#if errorMessage}
|
||||||
|
<p class="text-sm text-red-500">{errorMessage}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ImageCropper.Dialog>
|
||||||
|
<ImageCropper.Cropper cropShape={shape === 'rounded-full' ? 'round' : 'rect'} />
|
||||||
|
<ImageCropper.Controls>
|
||||||
|
<ImageCropper.Cancel />
|
||||||
|
<ImageCropper.Crop />
|
||||||
|
</ImageCropper.Controls>
|
||||||
|
</ImageCropper.Dialog>
|
||||||
|
</ImageCropper.Root>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<style>
|
|
||||||
.width-fit {
|
|
||||||
width: fit-content;
|
|
||||||
}
|
|
||||||
|
|
||||||
.avatar-container.setwidthheight {
|
|
||||||
height: 128px;
|
|
||||||
width: 128px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-icon {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -123,12 +123,13 @@
|
|||||||
"email_change_verification_note": "A verification email has been sent to your current email address. The new email will not be in effect until you accept the verification email.",
|
"email_change_verification_note": "A verification email has been sent to your current email address. The new email will not be in effect until you accept the verification email.",
|
||||||
"email_change_verification_sent": "A verification email has been sent to your new email address. Please check your inbox to verify your new email address."
|
"email_change_verification_sent": "A verification email has been sent to your new email address. Please check your inbox to verify your new email address."
|
||||||
},
|
},
|
||||||
|
"heading": "Profile",
|
||||||
"update_profile": "Update Profile",
|
"update_profile": "Update Profile",
|
||||||
"logout": "Log out"
|
"logout": "Log out"
|
||||||
},
|
},
|
||||||
"organization": {
|
"organization": {
|
||||||
"organization_profile": {
|
"organization_profile": {
|
||||||
"heading": "Organization Profile",
|
"heading": "Organization",
|
||||||
"organization_name": "Organization Name",
|
"organization_name": "Organization Name",
|
||||||
"upload_image": "Upload Image",
|
"upload_image": "Upload Image",
|
||||||
"update_organization": "Update Organization",
|
"update_organization": "Update Organization",
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
|
|
||||||
<Page.Header>
|
<Page.Header>
|
||||||
<Page.HeaderContent>
|
<Page.HeaderContent>
|
||||||
<Page.Title>{$t('settings.heading')}</Page.Title>
|
<Page.Title>{$t('settings.profile.heading')}</Page.Title>
|
||||||
</Page.HeaderContent>
|
</Page.HeaderContent>
|
||||||
<Page.Action>
|
<Page.Action>
|
||||||
<Button variant="secondary" loading={isLoading} onclick={handleUpdate}>
|
<Button variant="secondary" loading={isLoading} onclick={handleUpdate}>
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
|
|
||||||
<Page.Header>
|
<Page.Header>
|
||||||
<Page.HeaderContent>
|
<Page.HeaderContent>
|
||||||
<Page.Title>{$t('settings.heading')}</Page.Title>
|
<Page.Title>{$t('settings.profile.heading')}</Page.Title>
|
||||||
</Page.HeaderContent>
|
</Page.HeaderContent>
|
||||||
<Page.Action>
|
<Page.Action>
|
||||||
<Button variant="secondary" loading={isLoading} onclick={handleUpdate}>
|
<Button variant="secondary" loading={isLoading} onclick={handleUpdate}>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
bind:this={ref}
|
bind:this={ref}
|
||||||
data-slot="sidebar-inset"
|
data-slot="sidebar-inset"
|
||||||
class={cn(
|
class={cn(
|
||||||
'ui:bg-background ui:relative ui:flex ui:w-full ui:flex-1 ui:flex-col',
|
'ui:bg-background ui:relative ui:flex ui:w-full ui:flex-1 ui:flex-col ui:h-full',
|
||||||
'ui:md:peer-data-[variant=inset]:m-2 ui:md:peer-data-[variant=inset]:ml-0 ui:md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2 ui:md:peer-data-[variant=inset]:rounded-xl ui:md:peer-data-[variant=inset]:shadow-sm',
|
'ui:md:peer-data-[variant=inset]:m-2 ui:md:peer-data-[variant=inset]:ml-0 ui:md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2 ui:md:peer-data-[variant=inset]:rounded-xl ui:md:peer-data-[variant=inset]:shadow-sm',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -7,11 +7,11 @@
|
|||||||
import { cn } from '../../tools';
|
import { cn } from '../../tools';
|
||||||
|
|
||||||
const emptyVariants = tv({
|
const emptyVariants = tv({
|
||||||
base: 'ui:w-full',
|
base: 'ui:w-full ui:max-h-90',
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: '',
|
default: '',
|
||||||
page: 'ui:h-full ui:border ui:border-dashed ui:max-w-3xl ui:max-h-2/4 ui:mx-auto'
|
page: 'ui:h-full ui:border ui:border-dashed ui:max-w-3xl ui:mx-auto'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,12 +1,29 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { useImageCropperTrigger } from './image-cropper.svelte.js';
|
import { useImageCropperTrigger } from './image-cropper.svelte.js';
|
||||||
import type { ImageCropperUploadTriggerProps } from './types';
|
import type { ImageCropperUploadTriggerProps } from './types';
|
||||||
|
import { cn } from '../../tools';
|
||||||
|
|
||||||
let { ref = $bindable(null), children, ...rest }: ImageCropperUploadTriggerProps = $props();
|
let { ref = $bindable(null), children, ...rest }: ImageCropperUploadTriggerProps = $props();
|
||||||
|
|
||||||
const triggerState = useImageCropperTrigger();
|
const triggerState = useImageCropperTrigger();
|
||||||
|
const isDisabled = $derived(triggerState.rootState.disabled);
|
||||||
|
|
||||||
|
function handleClick(e: MouseEvent) {
|
||||||
|
if (isDisabled) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<label {...rest} bind:this={ref} for={triggerState.rootState.id} class="ui:hover:cursor-pointer">
|
<label
|
||||||
|
{...rest}
|
||||||
|
bind:this={ref}
|
||||||
|
for={isDisabled ? undefined : triggerState.rootState.id}
|
||||||
|
onclick={handleClick}
|
||||||
|
class={cn('ui:cursor-pointer', isDisabled ? 'ui:cursor-not-allowed ui:opacity-50 ui:pointer-events-none' : '')}
|
||||||
|
aria-disabled={isDisabled}
|
||||||
|
>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
@@ -1,43 +1,47 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { box } from 'svelte-toolbelt';
|
import { box } from 'svelte-toolbelt';
|
||||||
import { useImageCropperRoot } from './image-cropper.svelte.js';
|
import { useImageCropperRoot } from './image-cropper.svelte.js';
|
||||||
import type { ImageCropperRootProps } from './types';
|
import type { ImageCropperRootProps } from './types';
|
||||||
import { onDestroy } from 'svelte';
|
import { onDestroy } from 'svelte';
|
||||||
import { useId } from 'bits-ui';
|
import { useId } from 'bits-ui';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
id = useId(),
|
id = useId(),
|
||||||
src = $bindable(''),
|
src = $bindable(''),
|
||||||
onCropped = () => {},
|
onCropped = () => {},
|
||||||
onUnsupportedFile = () => {},
|
onUnsupportedFile = () => {},
|
||||||
children,
|
maxFileSize,
|
||||||
...rest
|
disabled = false,
|
||||||
}: ImageCropperRootProps = $props();
|
children,
|
||||||
|
...rest
|
||||||
|
}: ImageCropperRootProps = $props();
|
||||||
|
|
||||||
const rootState = useImageCropperRoot({
|
const rootState = useImageCropperRoot({
|
||||||
id: box.with(() => id),
|
id: box.with(() => id),
|
||||||
src: box.with(
|
src: box.with(
|
||||||
() => src,
|
() => src,
|
||||||
(v) => (src = v)
|
(v) => (src = v)
|
||||||
),
|
),
|
||||||
onCropped: box.with(() => onCropped),
|
onCropped: box.with(() => onCropped),
|
||||||
onUnsupportedFile: box.with(() => onUnsupportedFile)
|
onUnsupportedFile: box.with(() => onUnsupportedFile),
|
||||||
});
|
maxFileSize: box.with(() => maxFileSize),
|
||||||
|
disabled: box.with(() => disabled ?? undefined)
|
||||||
|
});
|
||||||
|
|
||||||
onDestroy(() => rootState.dispose());
|
onDestroy(() => rootState.dispose());
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
<input
|
<input
|
||||||
{...rest}
|
{...rest}
|
||||||
onchange={(e) => {
|
onchange={(e) => {
|
||||||
const file = e.currentTarget.files?.[0];
|
const file = e.currentTarget.files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
rootState.onUpload(file);
|
rootState.onUpload(file);
|
||||||
// reset so that we can reupload the same file
|
// reset so that we can reupload the same file
|
||||||
(e.target! as HTMLInputElement).value = '';
|
(e.target! as HTMLInputElement).value = '';
|
||||||
}}
|
}}
|
||||||
type="file"
|
type="file"
|
||||||
{id}
|
{id}
|
||||||
style="display: none;"
|
style="display: none;"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,167 +1,189 @@
|
|||||||
import type { ReadableBoxedValues, WritableBoxedValues } from 'svelte-toolbelt';
|
|
||||||
import { Context } from 'runed';
|
|
||||||
import type { CropArea, DispatchEvents } from 'svelte-easy-crop';
|
import type { CropArea, DispatchEvents } from 'svelte-easy-crop';
|
||||||
|
import type { ReadableBoxedValues, WritableBoxedValues } from 'svelte-toolbelt';
|
||||||
|
|
||||||
|
import { Context } from 'runed';
|
||||||
import { getCroppedImg } from './utils';
|
import { getCroppedImg } from './utils';
|
||||||
|
|
||||||
// https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/img#supported_image_formats
|
// https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/img#supported_image_formats
|
||||||
export const VALID_IMAGE_TYPES = [
|
export const VALID_IMAGE_TYPES = [
|
||||||
'image/apng',
|
'image/apng',
|
||||||
'image/avif',
|
'image/avif',
|
||||||
'image/gif',
|
'image/gif',
|
||||||
'image/jpeg',
|
'image/jpeg',
|
||||||
'image/png',
|
'image/png',
|
||||||
'image/svg+xml',
|
'image/svg+xml',
|
||||||
'image/webp'
|
'image/webp'
|
||||||
];
|
];
|
||||||
|
|
||||||
export type ImageCropperRootProps = WritableBoxedValues<{
|
export type ImageCropperRootProps = WritableBoxedValues<{
|
||||||
src: string;
|
src: string;
|
||||||
}> &
|
}> &
|
||||||
ReadableBoxedValues<{
|
ReadableBoxedValues<{
|
||||||
id: string;
|
id: string;
|
||||||
onCropped: (url: string) => void;
|
onCropped: (url: string) => void;
|
||||||
onUnsupportedFile: (file: File) => void;
|
onUnsupportedFile: (file: File) => void;
|
||||||
}>;
|
maxFileSize?: number; // Maximum file size in bytes
|
||||||
|
disabled?: boolean;
|
||||||
|
}>;
|
||||||
|
|
||||||
class ImageCropperRootState {
|
class ImageCropperRootState {
|
||||||
#createdUrls = $state<string[]>([]);
|
#createdUrls = $state<string[]>([]);
|
||||||
open = $state(false);
|
open = $state(false);
|
||||||
tempUrl = $state<string>();
|
tempUrl = $state<string>();
|
||||||
pixelCrop = $state<CropArea>();
|
pixelCrop = $state<CropArea>();
|
||||||
|
|
||||||
constructor(readonly opts: ImageCropperRootProps) {
|
constructor(readonly opts: ImageCropperRootProps) {
|
||||||
this.onUpload = this.onUpload.bind(this);
|
this.onUpload = this.onUpload.bind(this);
|
||||||
this.onCancel = this.onCancel.bind(this);
|
this.onCancel = this.onCancel.bind(this);
|
||||||
this.onCrop = this.onCrop.bind(this);
|
this.onCrop = this.onCrop.bind(this);
|
||||||
this.dispose = this.dispose.bind(this);
|
this.dispose = this.dispose.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
onUpload(file: File) {
|
get disabled() {
|
||||||
if (!VALID_IMAGE_TYPES.includes(file.type)) {
|
return this.opts.disabled?.current ?? false;
|
||||||
this.opts.onUnsupportedFile.current(file);
|
}
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.tempUrl = URL.createObjectURL(file);
|
onUpload(file: File) {
|
||||||
this.#createdUrls.push(this.tempUrl);
|
// Don't upload if disabled
|
||||||
this.open = true;
|
if (this.disabled) {
|
||||||
}
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
onCancel() {
|
// Check file size first if maxFileSize is specified
|
||||||
this.tempUrl = undefined;
|
if (this.opts.maxFileSize?.current !== undefined) {
|
||||||
this.open = false;
|
const maxFileSize = this.opts.maxFileSize.current;
|
||||||
this.pixelCrop = undefined;
|
if (file.size > maxFileSize) {
|
||||||
}
|
this.opts.onUnsupportedFile.current(file);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async onCrop() {
|
// Check file type
|
||||||
if (!this.pixelCrop || !this.tempUrl) return;
|
if (!VALID_IMAGE_TYPES.includes(file.type)) {
|
||||||
|
this.opts.onUnsupportedFile.current(file);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.opts.src.current = await getCroppedImg(this.tempUrl, this.pixelCrop);
|
this.tempUrl = URL.createObjectURL(file);
|
||||||
|
this.#createdUrls.push(this.tempUrl);
|
||||||
|
this.open = true;
|
||||||
|
}
|
||||||
|
|
||||||
this.open = false;
|
onCancel() {
|
||||||
|
this.tempUrl = undefined;
|
||||||
|
this.open = false;
|
||||||
|
this.pixelCrop = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
this.opts.onCropped.current(this.opts.src.current);
|
async onCrop() {
|
||||||
}
|
if (!this.pixelCrop || !this.tempUrl) return;
|
||||||
|
|
||||||
get src() {
|
this.opts.src.current = await getCroppedImg(this.tempUrl, this.pixelCrop);
|
||||||
return this.opts.src.current;
|
|
||||||
}
|
|
||||||
|
|
||||||
get id() {
|
this.open = false;
|
||||||
return this.opts.id.current;
|
|
||||||
}
|
|
||||||
|
|
||||||
dispose() {
|
this.opts.onCropped.current(this.opts.src.current);
|
||||||
for (const url of this.#createdUrls) {
|
}
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
}
|
get src() {
|
||||||
}
|
return this.opts.src.current;
|
||||||
|
}
|
||||||
|
|
||||||
|
get id() {
|
||||||
|
return this.opts.id.current;
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
for (const url of this.#createdUrls) {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ImageCropperTriggerProps = ReadableBoxedValues<{
|
export type ImageCropperTriggerProps = ReadableBoxedValues<{
|
||||||
id?: string;
|
id?: string;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
class ImageCropperTriggerState {
|
class ImageCropperTriggerState {
|
||||||
constructor(readonly rootState: ImageCropperRootState) {}
|
constructor(readonly rootState: ImageCropperRootState) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
class ImageCropperPreviewState {
|
class ImageCropperPreviewState {
|
||||||
constructor(readonly rootState: ImageCropperRootState) {}
|
constructor(readonly rootState: ImageCropperRootState) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
class ImageCropperDialogState {
|
class ImageCropperDialogState {
|
||||||
constructor(readonly rootState: ImageCropperRootState) {}
|
constructor(readonly rootState: ImageCropperRootState) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
class ImageCropperCropperState {
|
class ImageCropperCropperState {
|
||||||
constructor(readonly rootState: ImageCropperRootState) {
|
constructor(readonly rootState: ImageCropperRootState) {
|
||||||
this.onCropComplete = this.onCropComplete.bind(this);
|
this.onCropComplete = this.onCropComplete.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
onCropComplete(e: DispatchEvents['cropcomplete']) {
|
onCropComplete(e: DispatchEvents['cropcomplete']) {
|
||||||
this.rootState.pixelCrop = e.pixels;
|
this.rootState.pixelCrop = e.pixels;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class ImageCropperCropState {
|
class ImageCropperCropState {
|
||||||
constructor(readonly rootState: ImageCropperRootState) {
|
constructor(readonly rootState: ImageCropperRootState) {
|
||||||
this.onclick = this.onclick.bind(this);
|
this.onclick = this.onclick.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
onclick() {
|
onclick() {
|
||||||
this.rootState.onCrop();
|
this.rootState.onCrop();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class ImageCropperCancelState {
|
class ImageCropperCancelState {
|
||||||
constructor(readonly rootState: ImageCropperRootState) {
|
constructor(readonly rootState: ImageCropperRootState) {
|
||||||
this.onclick = this.onclick.bind(this);
|
this.onclick = this.onclick.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
onclick() {
|
onclick() {
|
||||||
this.rootState.onCancel();
|
this.rootState.onCancel();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const ImageCropperRootContext = new Context<ImageCropperRootState>('ImageCropper.Root');
|
const ImageCropperRootContext = new Context<ImageCropperRootState>('ImageCropper.Root');
|
||||||
|
|
||||||
export const useImageCropperRoot = (props: ImageCropperRootProps) => {
|
export const useImageCropperRoot = (props: ImageCropperRootProps) => {
|
||||||
return ImageCropperRootContext.set(new ImageCropperRootState(props));
|
return ImageCropperRootContext.set(new ImageCropperRootState(props));
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useImageCropperTrigger = () => {
|
export const useImageCropperTrigger = () => {
|
||||||
const rootState = ImageCropperRootContext.get();
|
const rootState = ImageCropperRootContext.get();
|
||||||
|
|
||||||
return new ImageCropperTriggerState(rootState);
|
return new ImageCropperTriggerState(rootState);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useImageCropperPreview = () => {
|
export const useImageCropperPreview = () => {
|
||||||
const rootState = ImageCropperRootContext.get();
|
const rootState = ImageCropperRootContext.get();
|
||||||
|
|
||||||
return new ImageCropperPreviewState(rootState);
|
return new ImageCropperPreviewState(rootState);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useImageCropperDialog = () => {
|
export const useImageCropperDialog = () => {
|
||||||
const rootState = ImageCropperRootContext.get();
|
const rootState = ImageCropperRootContext.get();
|
||||||
|
|
||||||
return new ImageCropperDialogState(rootState);
|
return new ImageCropperDialogState(rootState);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useImageCropperCropper = () => {
|
export const useImageCropperCropper = () => {
|
||||||
const rootState = ImageCropperRootContext.get();
|
const rootState = ImageCropperRootContext.get();
|
||||||
|
|
||||||
return new ImageCropperCropperState(rootState);
|
return new ImageCropperCropperState(rootState);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useImageCropperCrop = () => {
|
export const useImageCropperCrop = () => {
|
||||||
const rootState = ImageCropperRootContext.get();
|
const rootState = ImageCropperRootContext.get();
|
||||||
|
|
||||||
return new ImageCropperCropState(rootState);
|
return new ImageCropperCropState(rootState);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useImageCropperCancel = () => {
|
export const useImageCropperCancel = () => {
|
||||||
const rootState = ImageCropperRootContext.get();
|
const rootState = ImageCropperRootContext.get();
|
||||||
|
|
||||||
return new ImageCropperCancelState(rootState);
|
return new ImageCropperCancelState(rootState);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,19 +1,15 @@
|
|||||||
import type {
|
import type { AvatarRootProps, DialogContentProps, WithChildren, WithoutChild, WithoutChildren } from 'bits-ui';
|
||||||
AvatarRootProps,
|
|
||||||
DialogContentProps,
|
|
||||||
WithChildren,
|
|
||||||
WithoutChild,
|
|
||||||
WithoutChildren
|
|
||||||
} from 'bits-ui';
|
|
||||||
import type { Snippet } from 'svelte';
|
|
||||||
import type { CropperProps } from 'svelte-easy-crop';
|
|
||||||
import type { HTMLAttributes, HTMLInputAttributes } from 'svelte/elements';
|
import type { HTMLAttributes, HTMLInputAttributes } from 'svelte/elements';
|
||||||
|
|
||||||
|
import type { CropperProps } from 'svelte-easy-crop';
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
export type ImageCropperRootPropsWithoutHTML = WithChildren<{
|
export type ImageCropperRootPropsWithoutHTML = WithChildren<{
|
||||||
id?: string;
|
id?: string;
|
||||||
src?: string;
|
src?: string;
|
||||||
onCropped?: (url: string) => void;
|
onCropped?: (url: string) => void;
|
||||||
onUnsupportedFile?: (file: File) => void;
|
onUnsupportedFile?: (file: File) => void;
|
||||||
|
maxFileSize?: number; // Maximum file size in bytes
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export type ImageCropperRootProps = ImageCropperRootPropsWithoutHTML & HTMLInputAttributes;
|
export type ImageCropperRootProps = ImageCropperRootPropsWithoutHTML & HTMLInputAttributes;
|
||||||
@@ -23,22 +19,21 @@ export type ImageCropperDialogProps = DialogContentProps;
|
|||||||
export type ImageCropperCropperProps = Omit<Partial<CropperProps>, 'oncropcomplete' | 'image'>;
|
export type ImageCropperCropperProps = Omit<Partial<CropperProps>, 'oncropcomplete' | 'image'>;
|
||||||
|
|
||||||
export type ImageCropperControlsWithoutHTML = WithChildren<{
|
export type ImageCropperControlsWithoutHTML = WithChildren<{
|
||||||
ref?: HTMLDivElement | null;
|
ref?: HTMLDivElement | null;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export type ImageCropperControlsProps = ImageCropperControlsWithoutHTML &
|
export type ImageCropperControlsProps = ImageCropperControlsWithoutHTML &
|
||||||
WithoutChildren<HTMLAttributes<HTMLDivElement>>;
|
WithoutChildren<HTMLAttributes<HTMLDivElement>>;
|
||||||
|
|
||||||
export type ImageCropperPreviewPropsWithoutHTML = {
|
export type ImageCropperPreviewPropsWithoutHTML = {
|
||||||
child?: Snippet<[{ src: string }]>;
|
child?: Snippet<[{ src: string }]>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ImageCropperPreviewProps = ImageCropperPreviewPropsWithoutHTML &
|
export type ImageCropperPreviewProps = ImageCropperPreviewPropsWithoutHTML & WithoutChild<AvatarRootProps>;
|
||||||
WithoutChild<AvatarRootProps>;
|
|
||||||
|
|
||||||
export type ImageCropperUploadTriggerPropsWithoutHTML = WithChildren<{
|
export type ImageCropperUploadTriggerPropsWithoutHTML = WithChildren<{
|
||||||
ref?: HTMLLabelElement | null;
|
ref?: HTMLLabelElement | null;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export type ImageCropperUploadTriggerProps = ImageCropperUploadTriggerPropsWithoutHTML &
|
export type ImageCropperUploadTriggerProps = ImageCropperUploadTriggerPropsWithoutHTML &
|
||||||
WithoutChildren<HTMLAttributes<HTMLLabelElement>>;
|
WithoutChildren<HTMLAttributes<HTMLLabelElement>>;
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user