Compare commits

..

10 Commits

Author SHA1 Message Date
6fd8f0f85f
fix: Make icon import style consistent
Some checks failed
ESLint / lint (push) Has been cancelled
Push to GHCR / build (push) Has been cancelled
Tests / Tests (push) Has been cancelled
Prettier / checkformat (push) Has been cancelled
2025-03-05 16:31:11 -05:00
9001356f53
feat: Icons for account deletion and signout 2025-03-05 16:25:55 -05:00
52619a0330
feat: Subtle border on pfp in settings 2025-03-05 16:20:56 -05:00
3c29d1d99c
fix: Broken secondary color
I belive this got replaced in 07ce8de, this reverts it back to its
previous state.
2025-03-05 16:09:00 -05:00
655b3a9216
fix: Unused imports 2025-03-05 09:38:54 -05:00
57c7f35c09
fix: Update tests for new component 2025-03-05 09:36:49 -05:00
ae9ab3668d
fix: Only allow image uploads 2025-03-05 09:24:25 -05:00
193bd380c3
fix: Make profile image clickable to upload new one 2025-03-05 08:30:49 -05:00
3b50bb5877
feat: Avatar cropping 2025-03-04 12:10:45 -05:00
d5f18b143b
build: Remove stub types definition 2025-03-04 10:32:44 -05:00
44 changed files with 649 additions and 60 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

@ -46,25 +46,27 @@
"@types/better-sqlite3": "^7.6.12",
"@types/express": "^5.0.0",
"@types/markdown-it-link-attributes": "^3.0.5",
"@types/minio": "^7.1.1",
"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

@ -23,7 +23,7 @@
--input: 20 5.9% 90%;
--primary: 24 9.8% 10%;
--primary-foreground: 60 9.1% 97.8%;
--secondary: 60 4.8% 95.9%;
--secondary: 240 6% 90%;
--secondary-foreground: 24 9.8% 10%;
--accent: 60 4.8% 95.9%;
--accent-foreground: 24 9.8% 10%;

View File

@ -1,6 +1,6 @@
<script lang="ts">
import { page } from '$app/state';
import MessageSquare from 'lucide-svelte/icons/message-square';
import { MessageSquare } from 'lucide-svelte';
interface Props {
channelName: string;

View File

@ -1,29 +1,49 @@
<script lang="ts">
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 { generateStream } from '$lib/functions/generateReadableStream';
import { Button } from '$lib/components/ui/button/index';
import { Edit } from 'lucide-svelte';
import type { PageData } from '../../../routes/(main)/account/$types';
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 error: string | null = $state(null);
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:error
bind:src
onCropped={async (url) => {
const file = await getFileFromUrl(url);
submit(file);
}}
>
<div class="relative">
<ImageCropper.UploadTrigger>
<ImageCropper.Preview class="rounded-md border bg-white" />
<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>
{#if error}<span class="text-sm text-red-500">{error}</span>{/if}
</fieldset>
</form>

View File

@ -1,5 +1,5 @@
<script lang="ts">
import MessagesSquare from 'lucide-svelte/icons/messages-square';
import { MessageSquare } from 'lucide-svelte';
import type { Snippet } from 'svelte';
import type { PageData } from '../../routes/(main)/$types';
import Channel from './channel.svelte';
@ -23,7 +23,7 @@
<div class="flex h-full max-h-screen flex-col gap-2">
<div class="flex h-14 items-center border-b px-4 lg:h-[60px] lg:px-6">
<a href="/" class="flex items-center gap-2 font-semibold">
<MessagesSquare class="h-6 w-6" />
<MessageSquare class="h-6 w-6" />
<span class="">SVChat</span>
</a>
<ModeSwitcher />

View File

@ -4,10 +4,7 @@
import renderMarkdown from '$lib/functions/renderMarkdown';
import { type TypeMessage } from '$lib/types';
import Clipboard from 'lucide-svelte/icons/clipboard';
import SquareUserRound from 'lucide-svelte/icons/square-user-round';
import IDCard from 'lucide-svelte/icons/id-card';
import CalendarClock from 'lucide-svelte/icons/calendar-clock';
import { Clipboard, SquareUserRound, IdCard, CalendarClock } from 'lucide-svelte';
interface Props {
open: boolean;
@ -63,7 +60,7 @@
>
<!-- Copy User ID -->
<ContextMenu.Item class="flex cursor-pointer items-center gap-1.5" onclick={() => copy('user ID', uid)}
><IDCard size={16} />Copy User ID</ContextMenu.Item
><IdCard size={16} />Copy User ID</ContextMenu.Item
>
<ContextMenu.Separator />
<!-- Copy Text -->

View File

@ -1,8 +1,7 @@
<script lang="ts">
import * as Tooltip from '$lib/components/ui/tooltip';
import { buttonVariants } from '$lib/components/ui/button';
import Moon from 'lucide-svelte/icons/moon-star';
import Sun from 'lucide-svelte/icons/sun';
import { Moon, Sun } from 'lucide-svelte';
import { toggleMode } from 'mode-watcher';
</script>

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

@ -1,7 +1,6 @@
<script lang="ts">
import { ContextMenu as ContextMenuPrimitive, type WithoutChildrenOrChild } from 'bits-ui';
import Check from 'lucide-svelte/icons/check';
import Minus from 'lucide-svelte/icons/minus';
import { Check, Minus } from 'lucide-svelte';
import type { Snippet } from 'svelte';
import { cn } from '$lib/utils.js';

View File

@ -1,6 +1,6 @@
<script lang="ts">
import { ContextMenu as ContextMenuPrimitive, type WithoutChild } from 'bits-ui';
import Circle from 'lucide-svelte/icons/circle';
import { Circle } from 'lucide-svelte';
import { cn } from '$lib/utils.js';
let { ref = $bindable(null), class: className, children: childrenProp, ...restProps }: WithoutChild<ContextMenuPrimitive.RadioItemProps> = $props();

View File

@ -1,6 +1,6 @@
<script lang="ts">
import { ContextMenu as ContextMenuPrimitive, type WithoutChild } from 'bits-ui';
import ChevronRight from 'lucide-svelte/icons/chevron-right';
import { ChevronRight } from 'lucide-svelte';
import { cn } from '$lib/utils.js';
let {

View File

@ -1,6 +1,6 @@
<script lang="ts">
import { Dialog as DialogPrimitive, type WithoutChildrenOrChild } from 'bits-ui';
import X from 'lucide-svelte/icons/x';
import { X } from 'lucide-svelte';
import type { Snippet } from 'svelte';
import * as Dialog from './index.js';
import { cn } from '$lib/utils.js';

View File

@ -1,7 +1,6 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive, type WithoutChildrenOrChild } from 'bits-ui';
import Check from 'lucide-svelte/icons/check';
import Minus from 'lucide-svelte/icons/minus';
import { Check, Minus } from 'lucide-svelte';
import { cn } from '$lib/utils.js';
import type { Snippet } from 'svelte';

View File

@ -1,6 +1,6 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive, type WithoutChild } from 'bits-ui';
import Circle from 'lucide-svelte/icons/circle';
import { Circle } from 'lucide-svelte';
import { cn } from '$lib/utils.js';
let {

View File

@ -1,6 +1,6 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive, type WithoutChild } from 'bits-ui';
import ChevronRight from 'lucide-svelte/icons/chevron-right';
import { ChevronRight } from 'lucide-svelte';
import { cn } from '$lib/utils.js';
let {

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} data-testid="crop">
<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,54 @@
<!--
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,
error = $bindable(null),
...rest
}: ImageCropperRootProps & { error?: string | null } = $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;
// Prevent the user from uploading non-image files
if (file.type.split('/')[0] !== 'image') {
error = 'Please upload a valid image.';
return;
}
error = null;
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,154 @@
/*
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');
});
};

View File

@ -1,6 +1,6 @@
<script lang="ts">
import { Select as SelectPrimitive, type WithoutChild } from 'bits-ui';
import Check from 'lucide-svelte/icons/check';
import { Check } from 'lucide-svelte';
import { cn } from '$lib/utils.js';
let {

View File

@ -1,5 +1,5 @@
<script lang="ts">
import ChevronDown from 'lucide-svelte/icons/chevron-down';
import { ChevronDown } from 'lucide-svelte';
import { Select as SelectPrimitive, type WithoutChildrenOrChild } from 'bits-ui';
import { cn } from '$lib/utils.js';

View File

@ -1,5 +1,5 @@
<script lang="ts">
import ChevronUp from 'lucide-svelte/icons/chevron-up';
import { ChevronUp } from 'lucide-svelte';
import { Select as SelectPrimitive, type WithoutChildrenOrChild } from 'bits-ui';
import { cn } from '$lib/utils.js';

View File

@ -1,6 +1,6 @@
<script lang="ts">
import { Select as SelectPrimitive, type WithoutChild } from 'bits-ui';
import ChevronDown from 'lucide-svelte/icons/chevron-down';
import { ChevronDown } from 'lucide-svelte';
import { cn } from '$lib/utils.js';
let { ref = $bindable(null), class: className, children, ...restProps }: WithoutChild<SelectPrimitive.TriggerProps> = $props();

View File

@ -21,7 +21,7 @@
<script lang="ts">
import { Dialog as SheetPrimitive, type WithoutChildrenOrChild } from 'bits-ui';
import X from 'lucide-svelte/icons/x';
import { X } from 'lucide-svelte';
import type { Snippet } from 'svelte';
import SheetOverlay from './sheet-overlay.svelte';
import { cn } from '$lib/utils.js';

View File

@ -1,7 +1,7 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button/index';
import * as Tooltip from '$lib/components/ui/tooltip';
import Cog from 'lucide-svelte/icons/cog';
import { Cog } from 'lucide-svelte';
import type { PageData } from '../../routes/(main)/$types';
const { data }: { data: PageData } = $props();

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

@ -2,7 +2,7 @@
import { page } from '$app/state';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import BrokenHeart from 'lucide-svelte/icons/heart-crack';
import { BrokenHeart } from 'lucide-svelte';
</script>
<main class="relative size-full">

View File

@ -2,6 +2,7 @@
import { Button } from '$lib/components/ui/button/index';
import * as Dialog from '$lib/components/ui/dialog';
import type { PageData } from './$types';
import { Trash2, LogOut } from 'lucide-svelte';
let { data }: { data: PageData } = $props();
@ -20,16 +21,16 @@
<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">
<fieldset class="flex size-full flex-col justify-center gap-3 rounded-lg border p-4">
<legend class="-ml-1 px-1 text-sm font-medium"> Account Actions </legend>
<form method="POST" action="?/signOut">
<Button type="submit" class="w-full">Sign Out</Button>
<Button type="submit" class="w-full"><LogOut /> Sign Out</Button>
</form>
<Button variant="destructive" class="w-full" onclick={() => (open = !open)}>Delete Account</Button>
<Button variant="destructive" class="w-full" onclick={() => (open = !open)}><Trash2 /> Delete Account</Button>
</fieldset>
</div>
</div>
@ -43,7 +44,7 @@
This action cannot be undone. This will permanently delete your account and remove your data from our database.
<form class="mt-2 flex gap-2" method="POST" action="?/deleteAccount">
<Button class="w-1/2" onclick={() => (open = !open)}>I changed my mind!</Button>
<Button variant="destructive" class="w-1/2" type="submit">Delete Account</Button>
<Button variant="destructive" class="w-1/2" type="submit"><Trash2 /> Delete Account</Button>
</form>
</Dialog.Description>
</Dialog.Header>

View File

@ -6,7 +6,7 @@
import { buttonVariants } from '$lib/components/ui/button';
import { autoResize } from '$lib/functions/autoresize.svelte';
import Websocket from '$lib/functions/clientWebsocket.svelte';
import Send from 'lucide-svelte/icons/send';
import { Send } from 'lucide-svelte';
import { io } from 'socket.io-client';
import { onMount } from 'svelte';
import type { PageData } from './$types';

View File

@ -9,7 +9,6 @@ test.describe('Profile Photo Update', () => {
let page: Page;
let fileInput: Locator;
let profileImage: Locator;
let submitButton: Locator;
test.beforeEach(async ({ browser }) => {
page = await browser.newPage();
@ -19,7 +18,6 @@ test.describe('Profile Photo Update', () => {
await page.goto('/account', { timeout: 30000, waitUntil: 'domcontentloaded' });
// Initialize locators
submitButton = page.getByRole('button', { name: 'Update Profile Photo' });
profileImage = page.locator('img#userimage');
fileInput = page.locator('input[type="file"]');
});
@ -32,7 +30,8 @@ test.describe('Profile Photo Update', () => {
// Upload the new image
await fileInput.setInputFiles(['./static/freakybear.jpg']);
await submitButton.click();
await page.waitForTimeout(500);
await page.getByTestId('crop').click();
// Wait for upload to complete
const response = await page.waitForResponse((response) => response.request().method() === 'POST', { timeout: 30000 });
@ -50,11 +49,11 @@ test.describe('Profile Photo Update', () => {
// Upload the new image
await fileInput.setInputFiles(['./README.md']);
await submitButton.click();
await page.waitForTimeout(500);
// Wait for upload to complete
const response = await page.waitForResponse((response) => response.request().method() === 'POST', { timeout: 30000 });
expect(response.status()).toBe(500);
// Check for error
const errorMessageLocator = page.locator(`.text-sm.text-red-500:has-text("Please upload a valid image.")`);
await expect(errorMessageLocator).toBeVisible();
// Make sure the src is the same as the original
expect(await getImgSrc(profileImage)).toEqual(initalSrc);