diff --git a/TODO.md b/TODO.md index 2d0e27e..9042aa3 100644 --- a/TODO.md +++ b/TODO.md @@ -4,6 +4,7 @@ A list of all tasks that need to be completed in the app
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 diff --git a/bun.lockb b/bun.lockb index 4aba811..f7d3f0c 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 83de4a3..92fe675 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/lib/components/forms/updatePFP.svelte b/src/lib/components/forms/updatePFP.svelte index 0721fc0..9aeaed7 100644 --- a/src/lib/components/forms/updatePFP.svelte +++ b/src/lib/components/forms/updatePFP.svelte @@ -1,29 +1,49 @@ -
-
+ +
Upload Profile Image - - + { + const file = await getFileFromUrl(url); + submit(file); + }} + > +
+ + +
+ +
+
+
+ + + + + + + +
diff --git a/src/lib/components/ui/avatar/avatar-fallback.svelte b/src/lib/components/ui/avatar/avatar-fallback.svelte new file mode 100644 index 0000000..408bf49 --- /dev/null +++ b/src/lib/components/ui/avatar/avatar-fallback.svelte @@ -0,0 +1,14 @@ + + + + + diff --git a/src/lib/components/ui/avatar/avatar-image.svelte b/src/lib/components/ui/avatar/avatar-image.svelte new file mode 100644 index 0000000..d4905dd --- /dev/null +++ b/src/lib/components/ui/avatar/avatar-image.svelte @@ -0,0 +1,14 @@ + + + + + diff --git a/src/lib/components/ui/avatar/avatar.svelte b/src/lib/components/ui/avatar/avatar.svelte new file mode 100644 index 0000000..bb48088 --- /dev/null +++ b/src/lib/components/ui/avatar/avatar.svelte @@ -0,0 +1,14 @@ + + + + + diff --git a/src/lib/components/ui/avatar/index.ts b/src/lib/components/ui/avatar/index.ts new file mode 100644 index 0000000..62b5f28 --- /dev/null +++ b/src/lib/components/ui/avatar/index.ts @@ -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, +}; diff --git a/src/lib/components/ui/image-cropper/image-cropper-cancel.svelte b/src/lib/components/ui/image-cropper/image-cropper-cancel.svelte new file mode 100644 index 0000000..4d4d233 --- /dev/null +++ b/src/lib/components/ui/image-cropper/image-cropper-cancel.svelte @@ -0,0 +1,21 @@ + + + + + diff --git a/src/lib/components/ui/image-cropper/image-cropper-controls.svelte b/src/lib/components/ui/image-cropper/image-cropper-controls.svelte new file mode 100644 index 0000000..58d2f5e --- /dev/null +++ b/src/lib/components/ui/image-cropper/image-cropper-controls.svelte @@ -0,0 +1,16 @@ + + + + +
+ {@render children?.()} +
diff --git a/src/lib/components/ui/image-cropper/image-cropper-crop.svelte b/src/lib/components/ui/image-cropper/image-cropper-crop.svelte new file mode 100644 index 0000000..cd66895 --- /dev/null +++ b/src/lib/components/ui/image-cropper/image-cropper-crop.svelte @@ -0,0 +1,21 @@ + + + + + diff --git a/src/lib/components/ui/image-cropper/image-cropper-cropper.svelte b/src/lib/components/ui/image-cropper/image-cropper-cropper.svelte new file mode 100644 index 0000000..cd05d8c --- /dev/null +++ b/src/lib/components/ui/image-cropper/image-cropper-cropper.svelte @@ -0,0 +1,19 @@ + + + + + +
+ +
diff --git a/src/lib/components/ui/image-cropper/image-cropper-dialog.svelte b/src/lib/components/ui/image-cropper/image-cropper-dialog.svelte new file mode 100644 index 0000000..1a07ce5 --- /dev/null +++ b/src/lib/components/ui/image-cropper/image-cropper-dialog.svelte @@ -0,0 +1,24 @@ + + + + + + +
+ {@render children?.()} +
+
+
diff --git a/src/lib/components/ui/image-cropper/image-cropper-preview.svelte b/src/lib/components/ui/image-cropper/image-cropper-preview.svelte new file mode 100644 index 0000000..e843cca --- /dev/null +++ b/src/lib/components/ui/image-cropper/image-cropper-preview.svelte @@ -0,0 +1,29 @@ + + + + +{#if child} + {@render child({ src: previewState.rootState.src })} +{:else} + + + + + Upload image + + +{/if} diff --git a/src/lib/components/ui/image-cropper/image-cropper-upload-trigger.svelte b/src/lib/components/ui/image-cropper/image-cropper-upload-trigger.svelte new file mode 100644 index 0000000..69a72c8 --- /dev/null +++ b/src/lib/components/ui/image-cropper/image-cropper-upload-trigger.svelte @@ -0,0 +1,18 @@ + + + + + diff --git a/src/lib/components/ui/image-cropper/image-cropper.svelte b/src/lib/components/ui/image-cropper/image-cropper.svelte new file mode 100644 index 0000000..320a658 --- /dev/null +++ b/src/lib/components/ui/image-cropper/image-cropper.svelte @@ -0,0 +1,41 @@ + + + + +{@render children?.()} + { + 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;" +/> diff --git a/src/lib/components/ui/image-cropper/image-cropper.svelte.ts b/src/lib/components/ui/image-cropper/image-cropper.svelte.ts new file mode 100644 index 0000000..1bd9587 --- /dev/null +++ b/src/lib/components/ui/image-cropper/image-cropper.svelte.ts @@ -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([]); + open = $state(false); + tempUrl = $state(); + pixelCrop = $state(); + + 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('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); +}; diff --git a/src/lib/components/ui/image-cropper/index.ts b/src/lib/components/ui/image-cropper/index.ts new file mode 100644 index 0000000..f2bf559 --- /dev/null +++ b/src/lib/components/ui/image-cropper/index.ts @@ -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 }; diff --git a/src/lib/components/ui/image-cropper/types.ts b/src/lib/components/ui/image-cropper/types.ts new file mode 100644 index 0000000..6059e96 --- /dev/null +++ b/src/lib/components/ui/image-cropper/types.ts @@ -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 & { + child?: Snippet<[{ src: string }]>; +}; diff --git a/src/lib/components/ui/image-cropper/utils.ts b/src/lib/components/ui/image-cropper/utils.ts new file mode 100644 index 0000000..bff2c75 --- /dev/null +++ b/src/lib/components/ui/image-cropper/utils.ts @@ -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 => { + // 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 => { + return new Promise((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 => { + 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'); + }); +}; diff --git a/src/lib/utils/box.ts b/src/lib/utils/box.ts new file mode 100644 index 0000000..f4aef79 --- /dev/null +++ b/src/lib/utils/box.ts @@ -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 = ReadableBox | WritableBox; + +export type WritableBoxedValues = { + [K in keyof T]: WritableBox; +}; + +export type ReadableBoxedValues = { + [K in keyof T]: ReadableBox; +}; diff --git a/src/lib/utils/utils.ts b/src/lib/utils/utils.ts new file mode 100644 index 0000000..fc28227 --- /dev/null +++ b/src/lib/utils/utils.ts @@ -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)); +} diff --git a/src/routes/(main)/account/+page.svelte b/src/routes/(main)/account/+page.svelte index 4a9a070..1d695c4 100644 --- a/src/routes/(main)/account/+page.svelte +++ b/src/routes/(main)/account/+page.svelte @@ -20,7 +20,7 @@
- +