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). A more complex version of this list is available [here](https://trello.com/b/kJw6Aapn/svchat).
- [x] Account / Profile management - [x] Account / Profile management
- [x] Avatar cropping
- [ ] Channel context menus - [ ] Channel context menus
- [x] Containerization with docker and docker-compose - [x] Containerization with docker and docker-compose
- [ ] Editing messages - [ ] Editing messages

BIN
bun.lockb

Binary file not shown.

View File

@ -46,25 +46,27 @@
"@types/better-sqlite3": "^7.6.12", "@types/better-sqlite3": "^7.6.12",
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
"@types/markdown-it-link-attributes": "^3.0.5", "@types/markdown-it-link-attributes": "^3.0.5",
"@types/minio": "^7.1.1",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"better-auth": "^1.1.16", "better-auth": "^1.1.16",
"better-sqlite3": "^11.8.1", "better-sqlite3": "^11.8.1",
"bits-ui": "^1.3.5", "bits-ui": "1.3.2",
"cassandra-driver": "^4.7.2", "cassandra-driver": "^4.7.2",
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"express": "^4.21.2", "express": "^4.21.2",
"lucide-svelte": "^0.477.0", "lucide-svelte": "^0.475.0",
"markdown-it": "^14.1.0", "markdown-it": "^14.1.0",
"markdown-it-highlightjs": "^4.2.0", "markdown-it-highlightjs": "^4.2.0",
"markdown-it-link-attributes": "^4.0.1", "markdown-it-link-attributes": "^4.0.1",
"minio": "^8.0.4", "minio": "^8.0.4",
"mode-watcher": "^0.5.1", "mode-watcher": "^0.5.1",
"runed": "^0.23.4",
"socket.io": "^4.8.1", "socket.io": "^4.8.1",
"socket.io-client": "^4.8.1", "socket.io-client": "^4.8.1",
"svelte-easy-crop": "^4.0.0",
"svelte-radix": "^2.0.1", "svelte-radix": "^2.0.1",
"svelte-toolbelt": "^0.7.1",
"sveltekit-superforms": "^2.23.1", "sveltekit-superforms": "^2.23.1",
"tailwind-merge": "^3.0.2", "tailwind-merge": "^2.6.0",
"tailwind-variants": "^0.3.1", "tailwind-variants": "^0.3.1",
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.17",
"tsm": "^2.3.0", "tsm": "^2.3.0",

View File

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

View File

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

View File

@ -1,29 +1,49 @@
<script lang="ts"> <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 { 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) { async function submit(file: File) {
e.preventDefault(); await generateStream(file).then((res) => {
if (files.length === 0) return;
await generateStream(files[0]).then((res) => {
if (res.ok) window.location.reload(); if (res.ok) window.location.reload();
}); });
} }
</script> </script>
<form class="grid w-full items-start gap-3" onsubmit={submit}> <form class="grid w-full items-start gap-3">
<fieldset class="flex size-full flex-col justify-center gap-3 rounded-lg border p-4"> <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> <legend class="-ml-1 px-1 text-sm font-medium"> Upload Profile Image </legend>
<input <ImageCropper.Root
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 bind:error
file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 bind:src
focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50" onCropped={async (url) => {
type="file" const file = await getFileFromUrl(url);
accept="image/jpeg, image/png" submit(file);
bind:files }}
/> >
<Button type="submit">Update Profile Photo</Button> <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> </fieldset>
</form> </form>

View File

@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import MessagesSquare from 'lucide-svelte/icons/messages-square'; import { MessageSquare } from 'lucide-svelte';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import type { PageData } from '../../routes/(main)/$types'; import type { PageData } from '../../routes/(main)/$types';
import Channel from './channel.svelte'; 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-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"> <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"> <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> <span class="">SVChat</span>
</a> </a>
<ModeSwitcher /> <ModeSwitcher />

View File

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

View File

@ -1,8 +1,7 @@
<script lang="ts"> <script lang="ts">
import * as Tooltip from '$lib/components/ui/tooltip'; import * as Tooltip from '$lib/components/ui/tooltip';
import { buttonVariants } from '$lib/components/ui/button'; import { buttonVariants } from '$lib/components/ui/button';
import Moon from 'lucide-svelte/icons/moon-star'; import { Moon, Sun } from 'lucide-svelte';
import Sun from 'lucide-svelte/icons/sun';
import { toggleMode } from 'mode-watcher'; import { toggleMode } from 'mode-watcher';
</script> </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"> <script lang="ts">
import { ContextMenu as ContextMenuPrimitive, type WithoutChildrenOrChild } from 'bits-ui'; import { ContextMenu as ContextMenuPrimitive, type WithoutChildrenOrChild } from 'bits-ui';
import Check from 'lucide-svelte/icons/check'; import { Check, Minus } from 'lucide-svelte';
import Minus from 'lucide-svelte/icons/minus';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import { cn } from '$lib/utils.js'; import { cn } from '$lib/utils.js';

View File

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

View File

@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { ContextMenu as ContextMenuPrimitive, type WithoutChild } from 'bits-ui'; 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'; import { cn } from '$lib/utils.js';
let { let {

View File

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

View File

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

View File

@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive, type WithoutChild } from 'bits-ui'; 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'; import { cn } from '$lib/utils.js';
let { let {

View File

@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive, type WithoutChild } from 'bits-ui'; 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'; import { cn } from '$lib/utils.js';
let { 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"> <script lang="ts">
import { Select as SelectPrimitive, type WithoutChild } from 'bits-ui'; 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'; import { cn } from '$lib/utils.js';
let { let {

View File

@ -1,5 +1,5 @@
<script lang="ts"> <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 { Select as SelectPrimitive, type WithoutChildrenOrChild } from 'bits-ui';
import { cn } from '$lib/utils.js'; import { cn } from '$lib/utils.js';

View File

@ -1,5 +1,5 @@
<script lang="ts"> <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 { Select as SelectPrimitive, type WithoutChildrenOrChild } from 'bits-ui';
import { cn } from '$lib/utils.js'; import { cn } from '$lib/utils.js';

View File

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

View File

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

View File

@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { Button } from '$lib/components/ui/button/index'; import { Button } from '$lib/components/ui/button/index';
import * as Tooltip from '$lib/components/ui/tooltip'; 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'; import type { PageData } from '../../routes/(main)/$types';
const { data }: { data: PageData } = $props(); 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 { page } from '$app/state';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card'; import * as Card from '$lib/components/ui/card';
import BrokenHeart from 'lucide-svelte/icons/heart-crack'; import { BrokenHeart } from 'lucide-svelte';
</script> </script>
<main class="relative size-full"> <main class="relative size-full">

View File

@ -2,6 +2,7 @@
import { Button } from '$lib/components/ui/button/index'; import { Button } from '$lib/components/ui/button/index';
import * as Dialog from '$lib/components/ui/dialog'; import * as Dialog from '$lib/components/ui/dialog';
import type { PageData } from './$types'; import type { PageData } from './$types';
import { Trash2, LogOut } from 'lucide-svelte';
let { data }: { data: PageData } = $props(); let { data }: { data: PageData } = $props();
@ -20,16 +21,16 @@
<div class="relative grid w-full grid-cols-1 gap-3 md:grid-cols-2"> <div class="relative grid w-full grid-cols-1 gap-3 md:grid-cols-2">
<UpdatePassword data={data.newpassForm} /> <UpdatePassword data={data.newpassForm} />
<UpdateUsername data={data.newuserForm} /> <UpdateUsername data={data.newuserForm} />
<UpdatePfp /> <UpdatePfp {data} />
<!-- Account Actions --> <!-- Account Actions -->
<div class="grid w-full items-start gap-3"> <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"> <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> <legend class="-ml-1 px-1 text-sm font-medium"> Account Actions </legend>
<form method="POST" action="?/signOut"> <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> </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> </fieldset>
</div> </div>
</div> </div>
@ -43,7 +44,7 @@
This action cannot be undone. This will permanently delete your account and remove your data from our database. 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"> <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 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> </form>
</Dialog.Description> </Dialog.Description>
</Dialog.Header> </Dialog.Header>

View File

@ -6,7 +6,7 @@
import { buttonVariants } from '$lib/components/ui/button'; import { buttonVariants } from '$lib/components/ui/button';
import { autoResize } from '$lib/functions/autoresize.svelte'; import { autoResize } from '$lib/functions/autoresize.svelte';
import Websocket from '$lib/functions/clientWebsocket.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 { io } from 'socket.io-client';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import type { PageData } from './$types'; import type { PageData } from './$types';

View File

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