Compare commits
10 Commits
07ce8dea90
...
6fd8f0f85f
Author | SHA1 | Date | |
---|---|---|---|
6fd8f0f85f | |||
9001356f53 | |||
52619a0330 | |||
3c29d1d99c | |||
655b3a9216 | |||
57c7f35c09 | |||
ae9ab3668d | |||
193bd380c3 | |||
3b50bb5877 | |||
d5f18b143b |
1
TODO.md
1
TODO.md
@ -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
|
||||
|
10
package.json
10
package.json
@ -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",
|
||||
|
@ -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%;
|
||||
|
@ -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;
|
||||
|
@ -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>
|
||||
|
@ -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 />
|
||||
|
@ -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 -->
|
||||
|
@ -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>
|
||||
|
||||
|
14
src/lib/components/ui/avatar/avatar-fallback.svelte
Normal file
14
src/lib/components/ui/avatar/avatar-fallback.svelte
Normal 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} />
|
14
src/lib/components/ui/avatar/avatar-image.svelte
Normal file
14
src/lib/components/ui/avatar/avatar-image.svelte
Normal 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} />
|
14
src/lib/components/ui/avatar/avatar.svelte
Normal file
14
src/lib/components/ui/avatar/avatar.svelte
Normal 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} />
|
19
src/lib/components/ui/avatar/index.ts
Normal file
19
src/lib/components/ui/avatar/index.ts
Normal 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,
|
||||
};
|
@ -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';
|
||||
|
||||
|
@ -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();
|
||||
|
@ -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 {
|
||||
|
@ -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';
|
||||
|
@ -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';
|
||||
|
||||
|
@ -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 {
|
||||
|
@ -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 {
|
||||
|
@ -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>
|
@ -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>
|
@ -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>
|
@ -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>
|
@ -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>
|
@ -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}
|
@ -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>
|
54
src/lib/components/ui/image-cropper/image-cropper.svelte
Normal file
54
src/lib/components/ui/image-cropper/image-cropper.svelte
Normal 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;"
|
||||
/>
|
154
src/lib/components/ui/image-cropper/image-cropper.svelte.ts
Normal file
154
src/lib/components/ui/image-cropper/image-cropper.svelte.ts
Normal 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);
|
||||
};
|
17
src/lib/components/ui/image-cropper/index.ts
Normal file
17
src/lib/components/ui/image-cropper/index.ts
Normal 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 };
|
22
src/lib/components/ui/image-cropper/types.ts
Normal file
22
src/lib/components/ui/image-cropper/types.ts
Normal 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 }]>;
|
||||
};
|
87
src/lib/components/ui/image-cropper/utils.ts
Normal file
87
src/lib/components/ui/image-cropper/utils.ts
Normal 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');
|
||||
});
|
||||
};
|
@ -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 {
|
||||
|
@ -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';
|
||||
|
||||
|
@ -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';
|
||||
|
||||
|
@ -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();
|
||||
|
@ -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';
|
||||
|
@ -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
17
src/lib/utils/box.ts
Normal 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
12
src/lib/utils/utils.ts
Normal 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));
|
||||
}
|
@ -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">
|
||||
|
@ -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>
|
||||
|
@ -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';
|
||||
|
@ -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);
|
||||
|
Loading…
Reference in New Issue
Block a user