feat: Avatar cropping

This commit is contained in:
April Hall 2025-03-04 12:10:45 -05:00
parent d5f18b143b
commit 3b50bb5877
No known key found for this signature in database
GPG Key ID: A49AC35CB186266C
23 changed files with 606 additions and 20 deletions

View File

@ -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

BIN
bun.lockb

Binary file not shown.

View File

@ -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",

View File

@ -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>

View 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} />

View 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} />

View 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} />

View 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,
};

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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}

View File

@ -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>

View 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;"
/>

View 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);
};

View 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 };

View 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 }]>;
};

View 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
View 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
View 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));
}

View File

@ -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">