Compare commits

..

No commits in common. "6fd8f0f85f24c3fbe2f9b1405b09f2f046eeb288" and "07ce8dea90ec1980dc4c032e11d8371773983507" have entirely different histories.

44 changed files with 60 additions and 649 deletions

View File

@ -4,7 +4,6 @@ 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,27 +46,25 @@
"@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.2", "bits-ui": "^1.3.5",
"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.475.0", "lucide-svelte": "^0.477.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": "^2.6.0", "tailwind-merge": "^3.0.2",
"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: 240 6% 90%; --secondary: 60 4.8% 95.9%;
--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'; import MessageSquare from 'lucide-svelte/icons/message-square';
interface Props { interface Props {
channelName: string; channelName: string;

View File

@ -1,49 +1,29 @@
<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 { Edit } from 'lucide-svelte'; import { Button } from '$lib/components/ui/button/index';
import type { PageData } from '../../../routes/(main)/account/$types';
const { data }: { data: PageData } = $props(); let files: FileList;
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(file: File) { async function submit(e: SubmitEvent) {
await generateStream(file).then((res) => { e.preventDefault();
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"> <form class="grid w-full items-start gap-3" onsubmit={submit}>
<fieldset class="flex size-full flex-col items-center 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"> Upload Profile Image </legend> <legend class="-ml-1 px-1 text-sm font-medium"> Upload Profile Image </legend>
<ImageCropper.Root <input
bind:error 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:src file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1
onCropped={async (url) => { focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
const file = await getFileFromUrl(url); type="file"
submit(file); accept="image/jpeg, image/png"
}} bind:files
> />
<div class="relative"> <Button type="submit">Update Profile Photo</Button>
<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 { MessageSquare } from 'lucide-svelte'; import MessagesSquare from 'lucide-svelte/icons/messages-square';
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">
<MessageSquare class="h-6 w-6" /> <MessagesSquare class="h-6 w-6" />
<span class="">SVChat</span> <span class="">SVChat</span>
</a> </a>
<ModeSwitcher /> <ModeSwitcher />

View File

@ -4,7 +4,10 @@
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, SquareUserRound, IdCard, CalendarClock } from 'lucide-svelte'; 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';
interface Props { interface Props {
open: boolean; open: boolean;
@ -60,7 +63,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,7 +1,8 @@
<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, Sun } from 'lucide-svelte'; import Moon from 'lucide-svelte/icons/moon-star';
import Sun from 'lucide-svelte/icons/sun';
import { toggleMode } from 'mode-watcher'; import { toggleMode } from 'mode-watcher';
</script> </script>

View File

@ -1,14 +0,0 @@
<!--
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

@ -1,14 +0,0 @@
<!--
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

@ -1,14 +0,0 @@
<!--
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

@ -1,19 +0,0 @@
/*
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,6 +1,7 @@
<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, Minus } from 'lucide-svelte'; import Check from 'lucide-svelte/icons/check';
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'; import Circle from 'lucide-svelte/icons/circle';
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'; import ChevronRight from 'lucide-svelte/icons/chevron-right';
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'; import X from 'lucide-svelte/icons/x';
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,6 +1,7 @@
<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, Minus } from 'lucide-svelte'; import Check from 'lucide-svelte/icons/check';
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'; import Circle from 'lucide-svelte/icons/circle';
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'; import ChevronRight from 'lucide-svelte/icons/chevron-right';
import { cn } from '$lib/utils.js'; import { cn } from '$lib/utils.js';
let { let {

View File

@ -1,21 +0,0 @@
<!--
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

@ -1,16 +0,0 @@
<!--
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

@ -1,21 +0,0 @@
<!--
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

@ -1,19 +0,0 @@
<!--
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

@ -1,24 +0,0 @@
<!--
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

@ -1,29 +0,0 @@
<!--
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

@ -1,18 +0,0 @@
<!--
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

@ -1,54 +0,0 @@
<!--
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

@ -1,154 +0,0 @@
/*
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

@ -1,17 +0,0 @@
/*
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

@ -1,22 +0,0 @@
/*
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

@ -1,87 +0,0 @@
/*
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'; import Check from 'lucide-svelte/icons/check';
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'; import ChevronDown from 'lucide-svelte/icons/chevron-down';
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'; import ChevronUp from 'lucide-svelte/icons/chevron-up';
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'; import ChevronDown from 'lucide-svelte/icons/chevron-down';
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'; import X from 'lucide-svelte/icons/x';
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'; import Cog from 'lucide-svelte/icons/cog';
import type { PageData } from '../../routes/(main)/$types'; import type { PageData } from '../../routes/(main)/$types';
const { data }: { data: PageData } = $props(); const { data }: { data: PageData } = $props();

View File

@ -1,17 +0,0 @@
/*
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]>;
};

View File

@ -1,12 +0,0 @@
/*
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'; import BrokenHeart from 'lucide-svelte/icons/heart-crack';
</script> </script>
<main class="relative size-full"> <main class="relative size-full">

View File

@ -2,7 +2,6 @@
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();
@ -21,16 +20,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 {data} /> <UpdatePfp />
<!-- 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"><LogOut /> Sign Out</Button> <Button type="submit" class="w-full">Sign Out</Button>
</form> </form>
<Button variant="destructive" class="w-full" onclick={() => (open = !open)}><Trash2 /> Delete Account</Button> <Button variant="destructive" class="w-full" onclick={() => (open = !open)}>Delete Account</Button>
</fieldset> </fieldset>
</div> </div>
</div> </div>
@ -44,7 +43,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"><Trash2 /> Delete Account</Button> <Button variant="destructive" class="w-1/2" type="submit">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'; import Send from 'lucide-svelte/icons/send';
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,6 +9,7 @@ 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();
@ -18,6 +19,7 @@ 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"]');
}); });
@ -30,8 +32,7 @@ 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 page.waitForTimeout(500); await submitButton.click();
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 });
@ -49,11 +50,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 page.waitForTimeout(500); await submitButton.click();
// Check for error // Wait for upload to complete
const errorMessageLocator = page.locator(`.text-sm.text-red-500:has-text("Please upload a valid image.")`); const response = await page.waitForResponse((response) => response.request().method() === 'POST', { timeout: 30000 });
await expect(errorMessageLocator).toBeVisible(); expect(response.status()).toBe(500);
// 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);