feat: Avatar cropping
This commit is contained in:
parent
d5f18b143b
commit
3b50bb5877
1
TODO.md
1
TODO.md
@ -4,6 +4,7 @@ A list of all tasks that need to be completed in the app<br>
|
||||
A more complex version of this list is available [here](https://trello.com/b/kJw6Aapn/svchat).
|
||||
|
||||
- [x] Account / Profile management
|
||||
- [x] Avatar cropping
|
||||
- [ ] Channel context menus
|
||||
- [x] Containerization with docker and docker-compose
|
||||
- [ ] Editing messages
|
||||
|
@ -49,21 +49,24 @@
|
||||
"autoprefixer": "^10.4.20",
|
||||
"better-auth": "^1.1.16",
|
||||
"better-sqlite3": "^11.8.1",
|
||||
"bits-ui": "^1.3.5",
|
||||
"bits-ui": "1.3.2",
|
||||
"cassandra-driver": "^4.7.2",
|
||||
"dotenv": "^16.4.7",
|
||||
"express": "^4.21.2",
|
||||
"lucide-svelte": "^0.477.0",
|
||||
"lucide-svelte": "^0.475.0",
|
||||
"markdown-it": "^14.1.0",
|
||||
"markdown-it-highlightjs": "^4.2.0",
|
||||
"markdown-it-link-attributes": "^4.0.1",
|
||||
"minio": "^8.0.4",
|
||||
"mode-watcher": "^0.5.1",
|
||||
"runed": "^0.23.4",
|
||||
"socket.io": "^4.8.1",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"svelte-easy-crop": "^4.0.0",
|
||||
"svelte-radix": "^2.0.1",
|
||||
"svelte-toolbelt": "^0.7.1",
|
||||
"sveltekit-superforms": "^2.23.1",
|
||||
"tailwind-merge": "^3.0.2",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"tailwind-variants": "^0.3.1",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"tsm": "^2.3.0",
|
||||
|
@ -1,29 +1,49 @@
|
||||
<script lang="ts">
|
||||
import { generateStream } from '$lib/functions/generateReadableStream';
|
||||
import { Button } from '$lib/components/ui/button/index';
|
||||
import { buttonVariants } from '$lib/components/ui/button/index';
|
||||
import * as ImageCropper from '$lib/components/ui/image-cropper';
|
||||
import { getFileFromUrl } from '$lib/components/ui/image-cropper';
|
||||
import type { PageData } from '../../../routes/(main)/account/$types';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||
import { Edit } from 'lucide-svelte';
|
||||
|
||||
let files: FileList;
|
||||
const { data }: { data: PageData } = $props();
|
||||
let src = $state(data.user.image ?? `https://api.dicebear.com/9.x/identicon/svg?seed=${data.session?.user.id}`);
|
||||
let open: boolean = $state(false);
|
||||
|
||||
async function submit(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
if (files.length === 0) return;
|
||||
await generateStream(files[0]).then((res) => {
|
||||
async function submit(file: File) {
|
||||
await generateStream(file).then((res) => {
|
||||
if (res.ok) window.location.reload();
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<form class="grid w-full items-start gap-3" onsubmit={submit}>
|
||||
<fieldset class="flex size-full flex-col justify-center gap-3 rounded-lg border p-4">
|
||||
<form class="grid w-full items-start gap-3">
|
||||
<fieldset class="flex size-full flex-col items-center justify-center gap-3 rounded-lg border p-4">
|
||||
<legend class="-ml-1 px-1 text-sm font-medium"> Upload Profile Image </legend>
|
||||
<input
|
||||
class="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0
|
||||
file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1
|
||||
focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
type="file"
|
||||
accept="image/jpeg, image/png"
|
||||
bind:files
|
||||
/>
|
||||
<Button type="submit">Update Profile Photo</Button>
|
||||
<ImageCropper.Root
|
||||
bind:src
|
||||
onCropped={async (url) => {
|
||||
const file = await getFileFromUrl(url);
|
||||
submit(file);
|
||||
}}
|
||||
>
|
||||
<div class="relative">
|
||||
<ImageCropper.Preview class="rounded-md" />
|
||||
<ImageCropper.UploadTrigger>
|
||||
<div class="absolute -bottom-3 -left-3 size-9 rounded-lg {buttonVariants({ variant: 'outline' })}">
|
||||
<Edit class="size-4" />
|
||||
</div>
|
||||
</ImageCropper.UploadTrigger>
|
||||
</div>
|
||||
<ImageCropper.Dialog>
|
||||
<ImageCropper.Cropper cropShape="rect" />
|
||||
<ImageCropper.Controls>
|
||||
<ImageCropper.Cancel />
|
||||
<ImageCropper.Crop />
|
||||
</ImageCropper.Controls>
|
||||
</ImageCropper.Dialog>
|
||||
</ImageCropper.Root>
|
||||
</fieldset>
|
||||
</form>
|
||||
|
14
src/lib/components/ui/avatar/avatar-fallback.svelte
Normal file
14
src/lib/components/ui/avatar/avatar-fallback.svelte
Normal file
@ -0,0 +1,14 @@
|
||||
<!--
|
||||
jsrepo 1.41.3
|
||||
Installed from github/ieedan/shadcn-svelte-extras
|
||||
3-4-2025
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import { Avatar as AvatarPrimitive } from 'bits-ui';
|
||||
import { cn } from '$lib/utils/utils.js';
|
||||
|
||||
let { ref = $bindable(null), class: className, ...restProps }: AvatarPrimitive.FallbackProps = $props();
|
||||
</script>
|
||||
|
||||
<AvatarPrimitive.Fallback bind:ref class={cn('flex h-full w-full items-center justify-center rounded-full bg-muted', className)} {...restProps} />
|
14
src/lib/components/ui/avatar/avatar-image.svelte
Normal file
14
src/lib/components/ui/avatar/avatar-image.svelte
Normal file
@ -0,0 +1,14 @@
|
||||
<!--
|
||||
jsrepo 1.41.3
|
||||
Installed from github/ieedan/shadcn-svelte-extras
|
||||
3-4-2025
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import { Avatar as AvatarPrimitive } from 'bits-ui';
|
||||
import { cn } from '$lib/utils/utils.js';
|
||||
|
||||
let { ref = $bindable(null), class: className, ...restProps }: AvatarPrimitive.ImageProps = $props();
|
||||
</script>
|
||||
|
||||
<AvatarPrimitive.Image bind:ref class={cn('aspect-square h-full w-full', className)} {...restProps} />
|
14
src/lib/components/ui/avatar/avatar.svelte
Normal file
14
src/lib/components/ui/avatar/avatar.svelte
Normal file
@ -0,0 +1,14 @@
|
||||
<!--
|
||||
jsrepo 1.41.3
|
||||
Installed from github/ieedan/shadcn-svelte-extras
|
||||
3-4-2025
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import { Avatar as AvatarPrimitive } from 'bits-ui';
|
||||
import { cn } from '$lib/utils/utils.js';
|
||||
|
||||
let { ref = $bindable(null), class: className, ...restProps }: AvatarPrimitive.RootProps = $props();
|
||||
</script>
|
||||
|
||||
<AvatarPrimitive.Root bind:ref class={cn('relative flex size-10 shrink-0 overflow-hidden rounded-full', className)} {...restProps} />
|
19
src/lib/components/ui/avatar/index.ts
Normal file
19
src/lib/components/ui/avatar/index.ts
Normal file
@ -0,0 +1,19 @@
|
||||
/*
|
||||
jsrepo 1.41.3
|
||||
Installed from github/ieedan/shadcn-svelte-extras
|
||||
3-4-2025
|
||||
*/
|
||||
|
||||
import Root from './avatar.svelte';
|
||||
import Image from './avatar-image.svelte';
|
||||
import Fallback from './avatar-fallback.svelte';
|
||||
|
||||
export {
|
||||
Root,
|
||||
Image,
|
||||
Fallback,
|
||||
//
|
||||
Root as Avatar,
|
||||
Image as AvatarImage,
|
||||
Fallback as AvatarFallback,
|
||||
};
|
@ -0,0 +1,21 @@
|
||||
<!--
|
||||
jsrepo 1.41.3
|
||||
Installed from github/ieedan/shadcn-svelte-extras
|
||||
3-4-2025
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import { type ButtonProps, Button } from '$lib/components/ui/button';
|
||||
import type { WithoutChildren } from 'bits-ui';
|
||||
import { useImageCropperCancel } from './image-cropper.svelte.js';
|
||||
import { Trash2 } from 'lucide-svelte';
|
||||
|
||||
let { variant = 'outline', size = 'sm', ...rest }: Omit<WithoutChildren<ButtonProps>, 'onclick'> = $props();
|
||||
|
||||
const cancelState = useImageCropperCancel();
|
||||
</script>
|
||||
|
||||
<Button {...rest} {size} {variant} onclick={cancelState.onclick}>
|
||||
<Trash2 />
|
||||
<span>Cancel</span>
|
||||
</Button>
|
@ -0,0 +1,16 @@
|
||||
<!--
|
||||
jsrepo 1.41.3
|
||||
Installed from github/ieedan/shadcn-svelte-extras
|
||||
3-4-2025
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import { cn } from '$lib/utils/utils';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
|
||||
let { class: className, children, ...rest }: HTMLAttributes<HTMLDivElement> = $props();
|
||||
</script>
|
||||
|
||||
<div {...rest} class={cn('flex w-full place-items-center justify-center gap-2', className)}>
|
||||
{@render children?.()}
|
||||
</div>
|
@ -0,0 +1,21 @@
|
||||
<!--
|
||||
jsrepo 1.41.3
|
||||
Installed from github/ieedan/shadcn-svelte-extras
|
||||
3-4-2025
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import { type ButtonProps, Button } from '$lib/components/ui/button';
|
||||
import type { WithoutChildren } from 'bits-ui';
|
||||
import { useImageCropperCrop } from './image-cropper.svelte.js';
|
||||
import { ImageUp } from 'lucide-svelte';
|
||||
|
||||
let { variant = 'default', size = 'sm', ...rest }: Omit<WithoutChildren<ButtonProps>, 'onclick'> = $props();
|
||||
|
||||
const cropState = useImageCropperCrop();
|
||||
</script>
|
||||
|
||||
<Button {...rest} {size} {variant} onclick={cropState.onclick}>
|
||||
<ImageUp />
|
||||
<span>Upload</span>
|
||||
</Button>
|
@ -0,0 +1,19 @@
|
||||
<!--
|
||||
jsrepo 1.41.3
|
||||
Installed from github/ieedan/shadcn-svelte-extras
|
||||
3-4-2025
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import Cropper, { type CropperProps } from 'svelte-easy-crop';
|
||||
import { useImageCropperCropper } from './image-cropper.svelte.js';
|
||||
|
||||
let { cropShape = 'round', aspect = 1, showGrid = false, ...rest }: Omit<Partial<CropperProps>, 'oncropcomplete' | 'image'> = $props();
|
||||
|
||||
const cropperState = useImageCropperCropper();
|
||||
</script>
|
||||
|
||||
<!-- This needs to be relative https://github.com/ValentinH/svelte-easy-crop#basic-usage -->
|
||||
<div class="relative h-full w-full">
|
||||
<Cropper {...rest} {cropShape} {aspect} {showGrid} image={cropperState.rootState.tempUrl} oncropcomplete={cropperState.onCropComplete} />
|
||||
</div>
|
@ -0,0 +1,24 @@
|
||||
<!--
|
||||
jsrepo 1.41.3
|
||||
Installed from github/ieedan/shadcn-svelte-extras
|
||||
3-4-2025
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { cn } from '$lib/utils/utils';
|
||||
import { useImageCropperDialog } from './image-cropper.svelte.js';
|
||||
import type { ImageCropperDialogProps } from './types';
|
||||
|
||||
let { children, class: className, ...rest }: ImageCropperDialogProps = $props();
|
||||
|
||||
const dialogState = useImageCropperDialog();
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open={dialogState.rootState.open}>
|
||||
<Dialog.Content {...rest} hideClose class={cn('min-h-96 max-w-full rounded-none border-x-0 sm:max-w-lg sm:rounded-lg sm:border-x', className)}>
|
||||
<div class="flex flex-col gap-4">
|
||||
{@render children?.()}
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
@ -0,0 +1,29 @@
|
||||
<!--
|
||||
jsrepo 1.41.3
|
||||
Installed from github/ieedan/shadcn-svelte-extras
|
||||
3-4-2025
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import * as Avatar from '$lib/components/ui/avatar';
|
||||
import type { ImageCropperPreviewProps } from './types';
|
||||
import { useImageCropperPreview } from './image-cropper.svelte.js';
|
||||
import { Upload } from 'lucide-svelte';
|
||||
import { cn } from '$lib/utils/utils';
|
||||
|
||||
let { child, class: className }: ImageCropperPreviewProps = $props();
|
||||
|
||||
const previewState = useImageCropperPreview();
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ src: previewState.rootState.src })}
|
||||
{:else}
|
||||
<Avatar.Root class={cn('size-20 ring-2 ring-accent ring-offset-2 ring-offset-background', className)}>
|
||||
<Avatar.Image src={previewState.rootState.src} />
|
||||
<Avatar.Fallback>
|
||||
<Upload class="size-4" />
|
||||
<span class="sr-only">Upload image</span>
|
||||
</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
{/if}
|
@ -0,0 +1,18 @@
|
||||
<!--
|
||||
jsrepo 1.41.3
|
||||
Installed from github/ieedan/shadcn-svelte-extras
|
||||
3-4-2025
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import { type WithChildren } from 'bits-ui';
|
||||
import { useImageCropperTrigger } from './image-cropper.svelte.js';
|
||||
|
||||
let { children }: WithChildren = $props();
|
||||
|
||||
const triggerState = useImageCropperTrigger();
|
||||
</script>
|
||||
|
||||
<label for={triggerState.rootState.id} class="hover:cursor-pointer">
|
||||
{@render children?.()}
|
||||
</label>
|
41
src/lib/components/ui/image-cropper/image-cropper.svelte
Normal file
41
src/lib/components/ui/image-cropper/image-cropper.svelte
Normal file
@ -0,0 +1,41 @@
|
||||
<!--
|
||||
jsrepo 1.41.3
|
||||
Installed from github/ieedan/shadcn-svelte-extras
|
||||
3-4-2025
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import { box } from 'svelte-toolbelt';
|
||||
import { useImageCropperRoot } from './image-cropper.svelte.js';
|
||||
import type { ImageCropperRootProps } from './types';
|
||||
import { onDestroy } from 'svelte';
|
||||
import { useId } from 'bits-ui';
|
||||
|
||||
let { id = useId(), src = $bindable(''), onCropped = () => {}, children, ...rest }: ImageCropperRootProps = $props();
|
||||
|
||||
const rootState = useImageCropperRoot({
|
||||
id: box.with(() => id),
|
||||
src: box.with(
|
||||
() => src,
|
||||
(v) => (src = v),
|
||||
),
|
||||
onCropped,
|
||||
});
|
||||
|
||||
onDestroy(() => rootState.dispose());
|
||||
</script>
|
||||
|
||||
{@render children?.()}
|
||||
<input
|
||||
{...rest}
|
||||
onchange={(e) => {
|
||||
const file = e.currentTarget.files?.[0];
|
||||
if (!file) return;
|
||||
rootState.onUpload(file);
|
||||
// reset so that we can reupload the same file
|
||||
(e.target! as HTMLInputElement).value = '';
|
||||
}}
|
||||
type="file"
|
||||
{id}
|
||||
style="display: none;"
|
||||
/>
|
157
src/lib/components/ui/image-cropper/image-cropper.svelte.ts
Normal file
157
src/lib/components/ui/image-cropper/image-cropper.svelte.ts
Normal file
@ -0,0 +1,157 @@
|
||||
/*
|
||||
jsrepo 1.41.3
|
||||
Installed from github/ieedan/shadcn-svelte-extras
|
||||
3-4-2025
|
||||
*/
|
||||
|
||||
import type { ReadableBoxedValues, WritableBoxedValues } from '$lib/utils/box';
|
||||
import { Context } from 'runed';
|
||||
import type { CropArea, DispatchEvents } from 'svelte-easy-crop';
|
||||
import { getCroppedImg } from './utils';
|
||||
|
||||
export type ImageCropperRootProps = WritableBoxedValues<{
|
||||
src: string;
|
||||
}> &
|
||||
ReadableBoxedValues<{
|
||||
id: string;
|
||||
}> & {
|
||||
onCropped: (url: string) => void;
|
||||
};
|
||||
|
||||
class ImageCropperRootState {
|
||||
#createdUrls = $state<string[]>([]);
|
||||
open = $state(false);
|
||||
tempUrl = $state<string>();
|
||||
pixelCrop = $state<CropArea>();
|
||||
|
||||
constructor(readonly opts: ImageCropperRootProps) {
|
||||
this.onUpload = this.onUpload.bind(this);
|
||||
this.onCancel = this.onCancel.bind(this);
|
||||
this.onCrop = this.onCrop.bind(this);
|
||||
this.dispose = this.dispose.bind(this);
|
||||
}
|
||||
|
||||
onUpload(file: File) {
|
||||
this.tempUrl = URL.createObjectURL(file);
|
||||
this.#createdUrls.push(this.tempUrl);
|
||||
this.open = true;
|
||||
}
|
||||
|
||||
onCancel() {
|
||||
this.tempUrl = undefined;
|
||||
this.open = false;
|
||||
this.pixelCrop = undefined;
|
||||
}
|
||||
|
||||
async onCrop() {
|
||||
if (!this.pixelCrop || !this.tempUrl) return;
|
||||
|
||||
this.opts.src.current = await getCroppedImg(this.tempUrl, this.pixelCrop);
|
||||
|
||||
this.open = false;
|
||||
|
||||
this.opts.onCropped(this.opts.src.current);
|
||||
}
|
||||
|
||||
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<{
|
||||
id?: string;
|
||||
}>;
|
||||
|
||||
class ImageCropperTriggerState {
|
||||
constructor(readonly rootState: ImageCropperRootState) {}
|
||||
}
|
||||
|
||||
class ImageCropperPreviewState {
|
||||
constructor(readonly rootState: ImageCropperRootState) {}
|
||||
}
|
||||
|
||||
class ImageCropperDialogState {
|
||||
constructor(readonly rootState: ImageCropperRootState) {}
|
||||
}
|
||||
|
||||
class ImageCropperCropperState {
|
||||
constructor(readonly rootState: ImageCropperRootState) {
|
||||
this.onCropComplete = this.onCropComplete.bind(this);
|
||||
}
|
||||
|
||||
onCropComplete(e: DispatchEvents['cropcomplete']) {
|
||||
this.rootState.pixelCrop = e.pixels;
|
||||
}
|
||||
}
|
||||
|
||||
class ImageCropperCropState {
|
||||
constructor(readonly rootState: ImageCropperRootState) {
|
||||
this.onclick = this.onclick.bind(this);
|
||||
}
|
||||
|
||||
onclick() {
|
||||
this.rootState.onCrop();
|
||||
}
|
||||
}
|
||||
|
||||
class ImageCropperCancelState {
|
||||
constructor(readonly rootState: ImageCropperRootState) {
|
||||
this.onclick = this.onclick.bind(this);
|
||||
}
|
||||
|
||||
onclick() {
|
||||
this.rootState.onCancel();
|
||||
}
|
||||
}
|
||||
|
||||
const ImageCropperRootContext = new Context<ImageCropperRootState>('ImageCropper.Root');
|
||||
|
||||
export const useImageCropperRoot = (props: ImageCropperRootProps) => {
|
||||
return ImageCropperRootContext.set(new ImageCropperRootState(props));
|
||||
};
|
||||
|
||||
export const useImageCropperTrigger = () => {
|
||||
const rootState = ImageCropperRootContext.get();
|
||||
|
||||
return new ImageCropperTriggerState(rootState);
|
||||
};
|
||||
|
||||
export const useImageCropperPreview = () => {
|
||||
const rootState = ImageCropperRootContext.get();
|
||||
|
||||
return new ImageCropperPreviewState(rootState);
|
||||
};
|
||||
|
||||
export const useImageCropperDialog = () => {
|
||||
const rootState = ImageCropperRootContext.get();
|
||||
|
||||
return new ImageCropperDialogState(rootState);
|
||||
};
|
||||
|
||||
export const useImageCropperCropper = () => {
|
||||
const rootState = ImageCropperRootContext.get();
|
||||
|
||||
return new ImageCropperCropperState(rootState);
|
||||
};
|
||||
|
||||
export const useImageCropperCrop = () => {
|
||||
const rootState = ImageCropperRootContext.get();
|
||||
|
||||
return new ImageCropperCropState(rootState);
|
||||
};
|
||||
|
||||
export const useImageCropperCancel = () => {
|
||||
const rootState = ImageCropperRootContext.get();
|
||||
|
||||
return new ImageCropperCancelState(rootState);
|
||||
};
|
17
src/lib/components/ui/image-cropper/index.ts
Normal file
17
src/lib/components/ui/image-cropper/index.ts
Normal file
@ -0,0 +1,17 @@
|
||||
/*
|
||||
jsrepo 1.41.3
|
||||
Installed from github/ieedan/shadcn-svelte-extras
|
||||
3-4-2025
|
||||
*/
|
||||
|
||||
import Root from './image-cropper.svelte';
|
||||
import UploadTrigger from './image-cropper-upload-trigger.svelte';
|
||||
import Preview from './image-cropper-preview.svelte';
|
||||
import Dialog from './image-cropper-dialog.svelte';
|
||||
import Cropper from './image-cropper-cropper.svelte';
|
||||
import Controls from './image-cropper-controls.svelte';
|
||||
import Crop from './image-cropper-crop.svelte';
|
||||
import Cancel from './image-cropper-cancel.svelte';
|
||||
import { getFileFromUrl } from './utils';
|
||||
|
||||
export { Root, UploadTrigger, Preview, Dialog, Cropper, Controls, Crop, Cancel, getFileFromUrl };
|
22
src/lib/components/ui/image-cropper/types.ts
Normal file
22
src/lib/components/ui/image-cropper/types.ts
Normal file
@ -0,0 +1,22 @@
|
||||
/*
|
||||
jsrepo 1.41.3
|
||||
Installed from github/ieedan/shadcn-svelte-extras
|
||||
3-4-2025
|
||||
*/
|
||||
|
||||
import type { AvatarRootProps, DialogContentProps, WithChildren } from 'bits-ui';
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { HTMLInputAttributes } from 'svelte/elements';
|
||||
|
||||
export type ImageCropperRootProps = HTMLInputAttributes &
|
||||
WithChildren<{
|
||||
id?: string;
|
||||
src?: string;
|
||||
onCropped?: (url: string) => void;
|
||||
}>;
|
||||
|
||||
export type ImageCropperDialogProps = DialogContentProps;
|
||||
|
||||
export type ImageCropperPreviewProps = Omit<AvatarRootProps, 'child'> & {
|
||||
child?: Snippet<[{ src: string }]>;
|
||||
};
|
87
src/lib/components/ui/image-cropper/utils.ts
Normal file
87
src/lib/components/ui/image-cropper/utils.ts
Normal file
@ -0,0 +1,87 @@
|
||||
/*
|
||||
jsrepo 1.41.3
|
||||
Installed from github/ieedan/shadcn-svelte-extras
|
||||
3-4-2025
|
||||
*/
|
||||
|
||||
import type { CropArea } from 'svelte-easy-crop';
|
||||
|
||||
export const getFileFromUrl = async (url: string, fileName = 'cropped.png'): Promise<File> => {
|
||||
// Fetch the file data from the URL
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch resource: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
// Convert the response into a Blob
|
||||
const blob = await response.blob();
|
||||
|
||||
// Create and return a File. You can set a custom type if needed.
|
||||
return new File([blob], fileName, { type: blob.type });
|
||||
};
|
||||
|
||||
const createImage = (url: string): Promise<HTMLImageElement> => {
|
||||
return new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const image = new Image();
|
||||
image.addEventListener('load', () => resolve(image));
|
||||
image.addEventListener('error', (error) => reject(error));
|
||||
image.setAttribute('crossOrigin', 'anonymous'); // needed to avoid cross-origin issues on CodeSandbox
|
||||
image.src = url;
|
||||
});
|
||||
};
|
||||
|
||||
const getRadianAngle = (degreeValue: number) => {
|
||||
return (degreeValue * Math.PI) / 180;
|
||||
};
|
||||
|
||||
/** Gets the cropped image from the src using the cropped area
|
||||
*
|
||||
* @param imageSrc
|
||||
* @param pixelCrop
|
||||
* @param rotation
|
||||
* @returns
|
||||
*/
|
||||
export const getCroppedImg = async (imageSrc: string, pixelCrop: CropArea, rotation = 0): Promise<string> => {
|
||||
const image = await createImage(imageSrc);
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
if (!ctx) {
|
||||
throw new Error('Error getting 2d rendering context');
|
||||
}
|
||||
|
||||
const maxSize = Math.max(image.width, image.height);
|
||||
const safeArea = 2 * ((maxSize / 2) * Math.sqrt(2));
|
||||
|
||||
// set each dimensions to double largest dimension to allow for a safe area for the
|
||||
// image to rotate in without being clipped by canvas context
|
||||
canvas.width = safeArea;
|
||||
canvas.height = safeArea;
|
||||
|
||||
// translate canvas context to a central location on image to allow rotating around the center.
|
||||
ctx.translate(safeArea / 2, safeArea / 2);
|
||||
ctx.rotate(getRadianAngle(rotation));
|
||||
ctx.translate(-safeArea / 2, -safeArea / 2);
|
||||
|
||||
// draw rotated image and store data.
|
||||
ctx.drawImage(image, safeArea / 2 - image.width * 0.5, safeArea / 2 - image.height * 0.5);
|
||||
const data = ctx.getImageData(0, 0, safeArea, safeArea);
|
||||
|
||||
// set canvas width to final desired crop size - this will clear existing context
|
||||
canvas.width = pixelCrop.width;
|
||||
canvas.height = pixelCrop.height;
|
||||
|
||||
// paste generated rotate image with correct offsets for x,y crop values.
|
||||
ctx.putImageData(
|
||||
data,
|
||||
Math.round(0 - safeArea / 2 + image.width * 0.5 - pixelCrop.x),
|
||||
Math.round(0 - safeArea / 2 + image.height * 0.5 - pixelCrop.y),
|
||||
);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
canvas.toBlob((file) => {
|
||||
resolve(URL.createObjectURL(file!));
|
||||
}, 'image/png');
|
||||
});
|
||||
};
|
17
src/lib/utils/box.ts
Normal file
17
src/lib/utils/box.ts
Normal file
@ -0,0 +1,17 @@
|
||||
/*
|
||||
jsrepo 1.41.3
|
||||
Installed from github/ieedan/shadcn-svelte-extras
|
||||
3-4-2025
|
||||
*/
|
||||
|
||||
import type { ReadableBox, WritableBox } from 'svelte-toolbelt';
|
||||
|
||||
export type Box<T> = ReadableBox<T> | WritableBox<T>;
|
||||
|
||||
export type WritableBoxedValues<T> = {
|
||||
[K in keyof T]: WritableBox<T[K]>;
|
||||
};
|
||||
|
||||
export type ReadableBoxedValues<T> = {
|
||||
[K in keyof T]: ReadableBox<T[K]>;
|
||||
};
|
12
src/lib/utils/utils.ts
Normal file
12
src/lib/utils/utils.ts
Normal file
@ -0,0 +1,12 @@
|
||||
/*
|
||||
jsrepo 1.41.3
|
||||
Installed from github/ieedan/shadcn-svelte-extras
|
||||
3-4-2025
|
||||
*/
|
||||
|
||||
import { type ClassValue, clsx } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
@ -20,7 +20,7 @@
|
||||
<div class="relative grid w-full grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<UpdatePassword data={data.newpassForm} />
|
||||
<UpdateUsername data={data.newuserForm} />
|
||||
<UpdatePfp />
|
||||
<UpdatePfp {data} />
|
||||
|
||||
<!-- Account Actions -->
|
||||
<div class="grid w-full items-start gap-3">
|
||||
|
Loading…
Reference in New Issue
Block a user