feat: add email verification, profile pictures, deposits, and calendar
export Backend: - Add email verification code generation and verification endpoints - Add profile picture upload with S3 storage and image processing - Add deposit_required field to users with 48h advance booking requirement - Add loyalty stamps that accumulate on completed bookings - Auto-transition bookings: confirmed → in_progress → completed - Add booking cancellation handler with no-show detection - Add ICS calendar file download endpoint for bookings - Sync bookings to CalDAV on confirmation Frontend: - Add schedule page route - Add avatar and image-cropper UI components - Update shadcn-svelte components (button, dialog) - Add "Add to Calendar" button in booking modal Database: - Add verification_codes table - Add profile_pic_url, loyalty_stamps, deposits_required to users - Various schema updates
This commit is contained in:
@@ -280,6 +280,15 @@
|
||||
{/if}
|
||||
|
||||
<Modal.Footer class="flex items-center justify-end gap-2">
|
||||
{#if selectedBooking}
|
||||
<Button variant="outline" onclick={() => {
|
||||
if (selectedBooking) {
|
||||
window.open(`/api/bookings/${selectedBooking.id}/calendar`, '_blank');
|
||||
}
|
||||
}}>
|
||||
Add to Calendar
|
||||
</Button>
|
||||
{/if}
|
||||
<Button onclick={() => (open = false)}>Close</Button>
|
||||
</Modal.Footer>
|
||||
</Modal.Content>
|
||||
|
||||
@@ -35,7 +35,14 @@
|
||||
overrideDurationMinutes?: number;
|
||||
};
|
||||
|
||||
let notes = $state(booking.notes || '');
|
||||
let notes = $state('');
|
||||
|
||||
// Sync notes with booking.notes when booking changes
|
||||
$effect(() => {
|
||||
if (booking?.notes !== undefined) {
|
||||
notes = booking.notes || '';
|
||||
}
|
||||
});
|
||||
let serviceOverrides = $state<
|
||||
Record<
|
||||
string,
|
||||
|
||||
@@ -8,12 +8,13 @@
|
||||
const links = [
|
||||
{ href: '/', label: 'Home', showWhen: 'always', width: 'w-12' },
|
||||
{ href: '/prices', label: 'Price List', showWhen: 'guest', width: 'w-20' },
|
||||
{ href: '/book', label: 'Book your appointment', showWhen: 'auth', width: 'w-36' },
|
||||
{ href: '/schedule', label: 'My Schedule', showWhen: 'auth', width: 'w-24' },
|
||||
{ href: '/book', label: 'Book an appointment', showWhen: 'auth', width: 'w-36' },
|
||||
{ href: '/portfolio', label: 'Portfolio', showWhen: 'always', width: 'w-20' },
|
||||
{ href: '/contact', label: 'Contact', showWhen: 'always', width: 'w-16' },
|
||||
{ href: '/account', label: 'My Account', showWhen: 'auth', width: 'w-24' },
|
||||
{ href: '/admin', label: 'Admin Dashboard', showWhen: 'admin', width: 'w-28' },
|
||||
{ href: '/today', label: 'Today', showWhen: 'admin', width: 'w-28' }
|
||||
{ href: '/today', label: 'Today', showWhen: 'admin', width: 'w-28' },
|
||||
{ href: '/contact', label: 'Contact', showWhen: 'always', width: 'w-16' },
|
||||
{ href: '/account', label: 'My Account', showWhen: 'auth', width: 'w-24' }
|
||||
];
|
||||
|
||||
let mobileMenuOpen = $state(false);
|
||||
|
||||
@@ -131,7 +131,7 @@
|
||||
|
||||
interval = setInterval(() => {
|
||||
calculateTimes();
|
||||
}, 60000); // Update every minute
|
||||
}, 15000); // Update every 15 seconds
|
||||
|
||||
return () => {
|
||||
if (interval) clearInterval(interval);
|
||||
@@ -193,18 +193,22 @@
|
||||
|
||||
{#if activeAppointment}
|
||||
{#if isInProgress}
|
||||
<Badge class="bg-blue-100 px-3 py-1 text-sm text-blue-800">
|
||||
<span class="relative mr-2 flex h-2 w-2">
|
||||
<span
|
||||
class="absolute inline-flex h-full w-full animate-ping rounded-full bg-blue-400 opacity-75"
|
||||
></span>
|
||||
<span class="relative inline-flex h-2 w-2 rounded-full bg-blue-600"></span>
|
||||
</span>
|
||||
In Progress • {timeRemaining} min remaining
|
||||
{#if freeTimeAfter > 0}
|
||||
• {freeTimeAfter} min free
|
||||
<div class="flex flex-col gap-2 items-end">
|
||||
<Badge class="bg-blue-100 px-3 py-1 text-sm text-blue-800">
|
||||
<span class="relative mr-2 flex h-2 w-2">
|
||||
<span
|
||||
class="absolute inline-flex h-full w-full animate-ping rounded-full bg-blue-400 opacity-75"
|
||||
></span>
|
||||
<span class="relative inline-flex h-2 w-2 rounded-full bg-blue-600"></span>
|
||||
</span>
|
||||
In Progress • {timeRemaining} min remaining
|
||||
</Badge>
|
||||
{#if freeTimeAfter > 0 && timeRemaining > 29}
|
||||
<Badge class="bg-blue-100 px-3 py-1 text-sm text-blue-800">
|
||||
{freeTimeAfter} min free afterwards
|
||||
</Badge>
|
||||
{/if}
|
||||
</Badge>
|
||||
</div>
|
||||
{:else}
|
||||
<Badge class="bg-amber-100 px-3 py-1 text-sm text-amber-800">
|
||||
Starts in {timeRemaining} min
|
||||
@@ -259,10 +263,11 @@
|
||||
class="h-20 w-20 rounded-full object-cover ring-4 ring-blue-200"
|
||||
/>
|
||||
{:else}
|
||||
{@const initials = activeAppointment.user?.full_name?.split(' ').map(n => n[0]).join('') || '?'}
|
||||
<div
|
||||
class="flex h-20 w-20 items-center justify-center rounded-full bg-gray-200 text-2xl font-bold text-gray-600 ring-4 ring-blue-200"
|
||||
>
|
||||
{activeAppointment.user?.full_name?.charAt(0) || '?'}
|
||||
{initials}
|
||||
</div>
|
||||
{/if}
|
||||
<div>
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { Avatar as AvatarPrimitive } from 'bits-ui';
|
||||
import { cn } from '$lib/utils.js';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: AvatarPrimitive.FallbackProps = $props();
|
||||
</script>
|
||||
|
||||
<AvatarPrimitive.Fallback
|
||||
bind:ref
|
||||
data-slot="avatar-fallback"
|
||||
class={cn('bg-muted flex size-full items-center justify-center rounded-full', className)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { Avatar as AvatarPrimitive } from 'bits-ui';
|
||||
import { cn } from '$lib/utils.js';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: AvatarPrimitive.ImageProps = $props();
|
||||
</script>
|
||||
|
||||
<AvatarPrimitive.Image
|
||||
bind:ref
|
||||
data-slot="avatar-image"
|
||||
class={cn('aspect-square size-full', className)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { Avatar as AvatarPrimitive } from 'bits-ui';
|
||||
import { cn } from '$lib/utils.js';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: AvatarPrimitive.RootProps = $props();
|
||||
</script>
|
||||
|
||||
<AvatarPrimitive.Root
|
||||
bind:ref
|
||||
data-slot="avatar"
|
||||
class={cn('relative flex size-8 shrink-0 overflow-hidden rounded-full', className)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,13 @@
|
||||
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,82 +1,124 @@
|
||||
<script lang="ts" module>
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from "svelte/elements";
|
||||
import { type VariantProps, tv } from "tailwind-variants";
|
||||
import type { WithChildren, WithoutChildren } from 'bits-ui';
|
||||
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from 'svelte/elements';
|
||||
import { type VariantProps, tv } from 'tailwind-variants';
|
||||
|
||||
export const buttonVariants = tv({
|
||||
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium outline-none transition-all focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
base: "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive focus-visible:border-ring focus-visible:ring-ring/50 relative inline-flex shrink-0 items-center justify-center gap-2 overflow-hidden rounded-md text-sm font-medium whitespace-nowrap outline-hidden transition-all select-none focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90 shadow-2xs',
|
||||
destructive:
|
||||
"bg-destructive shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 text-white",
|
||||
'bg-destructive hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 text-white shadow-2xs',
|
||||
outline:
|
||||
"bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 border",
|
||||
secondary: "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
'bg-background hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50 border shadow-2xs',
|
||||
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80 shadow-2xs',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
|
||||
link: 'text-primary underline-offset-4 hover:underline'
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
"icon-sm": "size-8",
|
||||
"icon-lg": "size-10",
|
||||
},
|
||||
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
|
||||
sm: 'h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5',
|
||||
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
|
||||
icon: 'size-9'
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
variant: 'default',
|
||||
size: 'default'
|
||||
}
|
||||
});
|
||||
|
||||
export type ButtonVariant = VariantProps<typeof buttonVariants>["variant"];
|
||||
export type ButtonSize = VariantProps<typeof buttonVariants>["size"];
|
||||
export type ButtonVariant = VariantProps<typeof buttonVariants>['variant'];
|
||||
export type ButtonSize = VariantProps<typeof buttonVariants>['size'];
|
||||
|
||||
export type ButtonProps = WithElementRef<HTMLButtonAttributes> &
|
||||
WithElementRef<HTMLAnchorAttributes> & {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
export type ButtonPropsWithoutHTML = WithChildren<{
|
||||
ref?: HTMLElement | null;
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
loading?: boolean;
|
||||
'data-slot'?: string;
|
||||
onClickPromise?: (
|
||||
e: MouseEvent & {
|
||||
currentTarget: EventTarget & HTMLButtonElement;
|
||||
}
|
||||
) => Promise<void>;
|
||||
}>;
|
||||
|
||||
export type AnchorElementProps = ButtonPropsWithoutHTML &
|
||||
WithoutChildren<Omit<HTMLAnchorAttributes, 'href' | 'type'>> & {
|
||||
href: HTMLAnchorAttributes['href'];
|
||||
type?: never;
|
||||
disabled?: HTMLButtonAttributes['disabled'];
|
||||
};
|
||||
|
||||
export type ButtonElementProps = ButtonPropsWithoutHTML &
|
||||
WithoutChildren<Omit<HTMLButtonAttributes, 'type' | 'href'>> & {
|
||||
type?: HTMLButtonAttributes['type'];
|
||||
href?: never;
|
||||
disabled?: HTMLButtonAttributes['disabled'];
|
||||
};
|
||||
|
||||
export type ButtonProps = AnchorElementProps | ButtonElementProps;
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { cn } from '$lib/utils.js';
|
||||
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle';
|
||||
|
||||
let {
|
||||
class: className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
ref = $bindable(null),
|
||||
variant = 'default',
|
||||
size = 'default',
|
||||
href = undefined,
|
||||
type = "button",
|
||||
disabled,
|
||||
type = 'button',
|
||||
loading = false,
|
||||
disabled = false,
|
||||
tabindex = 0,
|
||||
onclick,
|
||||
onClickPromise,
|
||||
class: className,
|
||||
'data-slot': dataSlot = 'button',
|
||||
children,
|
||||
...restProps
|
||||
...rest
|
||||
}: ButtonProps = $props();
|
||||
</script>
|
||||
|
||||
{#if href}
|
||||
<a
|
||||
bind:this={ref}
|
||||
data-slot="button"
|
||||
class={cn(buttonVariants({ variant, size }), className)}
|
||||
href={disabled ? undefined : href}
|
||||
aria-disabled={disabled}
|
||||
role={disabled ? "link" : undefined}
|
||||
tabindex={disabled ? -1 : undefined}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</a>
|
||||
{:else}
|
||||
<button
|
||||
bind:this={ref}
|
||||
data-slot="button"
|
||||
class={cn(buttonVariants({ variant, size }), className)}
|
||||
{type}
|
||||
{disabled}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</button>
|
||||
{/if}
|
||||
<!-- This approach to disabled links is inspired by bits-ui see: https://github.com/huntabyte/bits-ui/pull/1055 -->
|
||||
<svelte:element
|
||||
this={href ? 'a' : 'button'}
|
||||
{...rest}
|
||||
data-slot={dataSlot}
|
||||
type={href ? undefined : type}
|
||||
href={href && !disabled ? href : undefined}
|
||||
disabled={href ? undefined : disabled || loading}
|
||||
aria-disabled={href ? disabled : undefined}
|
||||
role={href && disabled ? 'link' : undefined}
|
||||
tabindex={href && disabled ? -1 : tabindex}
|
||||
class={cn(buttonVariants({ variant, size }), className)}
|
||||
bind:this={ref}
|
||||
onclick={async (
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
e: any
|
||||
) => {
|
||||
onclick?.(e);
|
||||
|
||||
if (type === undefined) return;
|
||||
|
||||
if (onClickPromise) {
|
||||
loading = true;
|
||||
|
||||
await onClickPromise(e);
|
||||
|
||||
loading = false;
|
||||
}
|
||||
}}
|
||||
>
|
||||
{#if type !== undefined && loading}
|
||||
<div class="flex animate-spin place-items-center justify-center">
|
||||
<LoaderCircleIcon class="size-4" />
|
||||
</div>
|
||||
<span class="sr-only">Loading</span>
|
||||
{/if}
|
||||
{@render children?.()}
|
||||
</svelte:element>
|
||||
|
||||
@@ -2,8 +2,11 @@ import Root, {
|
||||
type ButtonProps,
|
||||
type ButtonSize,
|
||||
type ButtonVariant,
|
||||
buttonVariants,
|
||||
} from "./button.svelte";
|
||||
type AnchorElementProps,
|
||||
type ButtonElementProps,
|
||||
type ButtonPropsWithoutHTML,
|
||||
buttonVariants
|
||||
} from './button.svelte';
|
||||
|
||||
export {
|
||||
Root,
|
||||
@@ -14,4 +17,7 @@ export {
|
||||
type ButtonProps,
|
||||
type ButtonSize,
|
||||
type ButtonVariant,
|
||||
type AnchorElementProps,
|
||||
type ButtonElementProps,
|
||||
type ButtonPropsWithoutHTML
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||
import { Dialog as DialogPrimitive } from 'bits-ui';
|
||||
|
||||
let { ref = $bindable(null), ...restProps }: DialogPrimitive.CloseProps = $props();
|
||||
</script>
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
<script lang="ts">
|
||||
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||
import XIcon from "@lucide/svelte/icons/x";
|
||||
import type { Snippet } from "svelte";
|
||||
import * as Dialog from "./index.js";
|
||||
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
||||
import { Dialog as DialogPrimitive } from 'bits-ui';
|
||||
import XIcon from '@lucide/svelte/icons/x';
|
||||
import type { Snippet } from 'svelte';
|
||||
import * as Dialog from './index.js';
|
||||
import { cn, type WithoutChildrenOrChild } from '$lib/utils.js';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
portalProps,
|
||||
hideClose = false,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...restProps
|
||||
}: WithoutChildrenOrChild<DialogPrimitive.ContentProps> & {
|
||||
portalProps?: DialogPrimitive.PortalProps;
|
||||
children: Snippet;
|
||||
showCloseButton?: boolean;
|
||||
hideClose?: boolean;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
@@ -25,15 +25,15 @@
|
||||
bind:ref
|
||||
data-slot="dialog-content"
|
||||
class={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed left-[50%] top-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
{#if showCloseButton}
|
||||
{#if !hideClose}
|
||||
<DialogPrimitive.Close
|
||||
class="ring-offset-background focus:ring-ring rounded-xs focus:outline-hidden absolute end-4 top-4 opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0"
|
||||
class="ring-offset-background focus:ring-ring absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<XIcon />
|
||||
<span class="sr-only">Close</span>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
import { Dialog as DialogPrimitive } from 'bits-ui';
|
||||
import { cn } from '$lib/utils.js';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
@@ -12,6 +12,6 @@
|
||||
<DialogPrimitive.Description
|
||||
bind:ref
|
||||
data-slot="dialog-description"
|
||||
class={cn("text-muted-foreground text-sm", className)}
|
||||
class={cn('text-muted-foreground text-sm', className)}
|
||||
{...restProps}
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
@@ -13,7 +13,7 @@
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="dialog-footer"
|
||||
class={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
|
||||
class={cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
@@ -13,7 +13,7 @@
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="dialog-header"
|
||||
class={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
class={cn('flex flex-col gap-2 text-center sm:text-left', className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
import { Dialog as DialogPrimitive } from 'bits-ui';
|
||||
import { cn } from '$lib/utils.js';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
@@ -13,7 +13,7 @@
|
||||
bind:ref
|
||||
data-slot="dialog-overlay"
|
||||
class={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
import { Dialog as DialogPrimitive } from 'bits-ui';
|
||||
import { cn } from '$lib/utils.js';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
@@ -12,6 +12,6 @@
|
||||
<DialogPrimitive.Title
|
||||
bind:ref
|
||||
data-slot="dialog-title"
|
||||
class={cn("text-lg font-semibold leading-none", className)}
|
||||
class={cn('text-lg leading-none font-semibold', className)}
|
||||
{...restProps}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||
import { Dialog as DialogPrimitive } from 'bits-ui';
|
||||
|
||||
let { ref = $bindable(null), ...restProps }: DialogPrimitive.TriggerProps = $props();
|
||||
</script>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||
import { Dialog as DialogPrimitive } from 'bits-ui';
|
||||
|
||||
import Title from "./dialog-title.svelte";
|
||||
import Footer from "./dialog-footer.svelte";
|
||||
import Header from "./dialog-header.svelte";
|
||||
import Overlay from "./dialog-overlay.svelte";
|
||||
import Content from "./dialog-content.svelte";
|
||||
import Description from "./dialog-description.svelte";
|
||||
import Trigger from "./dialog-trigger.svelte";
|
||||
import Close from "./dialog-close.svelte";
|
||||
import Title from './dialog-title.svelte';
|
||||
import Footer from './dialog-footer.svelte';
|
||||
import Header from './dialog-header.svelte';
|
||||
import Overlay from './dialog-overlay.svelte';
|
||||
import Content from './dialog-content.svelte';
|
||||
import Description from './dialog-description.svelte';
|
||||
import Trigger from './dialog-trigger.svelte';
|
||||
import Close from './dialog-close.svelte';
|
||||
|
||||
const Root = DialogPrimitive.Root;
|
||||
const Portal = DialogPrimitive.Portal;
|
||||
@@ -33,5 +33,5 @@ export {
|
||||
Overlay as DialogOverlay,
|
||||
Content as DialogContent,
|
||||
Description as DialogDescription,
|
||||
Close as DialogClose,
|
||||
Close as DialogClose
|
||||
};
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<script lang="ts">
|
||||
import { type ButtonElementProps, Button } from '$lib/components/ui/button';
|
||||
import { useImageCropperCancel } from './image-cropper.svelte.js';
|
||||
import Trash2Icon from '@lucide/svelte/icons/trash-2';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
variant = 'outline',
|
||||
size = 'sm',
|
||||
onclick,
|
||||
...rest
|
||||
}: ButtonElementProps = $props();
|
||||
|
||||
const cancelState = useImageCropperCancel();
|
||||
</script>
|
||||
|
||||
<Button
|
||||
{...rest}
|
||||
bind:ref
|
||||
{size}
|
||||
{variant}
|
||||
onclick={(
|
||||
e: MouseEvent & {
|
||||
currentTarget: EventTarget & HTMLButtonElement;
|
||||
}
|
||||
) => {
|
||||
onclick?.(e);
|
||||
|
||||
cancelState.onclick();
|
||||
}}
|
||||
>
|
||||
<Trash2Icon />
|
||||
<span>Cancel</span>
|
||||
</Button>
|
||||
@@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
import { cn } from '$lib/utils.js';
|
||||
import type { ImageCropperControlsProps } from './types';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...rest
|
||||
}: ImageCropperControlsProps = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
{...rest}
|
||||
bind:this={ref}
|
||||
class={cn('flex w-full place-items-center justify-center gap-2', className)}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -0,0 +1,34 @@
|
||||
<script lang="ts">
|
||||
import { type ButtonElementProps, Button } from '$lib/components/ui/button';
|
||||
import { useImageCropperCrop } from './image-cropper.svelte.js';
|
||||
import CropIcon from '@lucide/svelte/icons/crop';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
variant = 'default',
|
||||
size = 'sm',
|
||||
onclick,
|
||||
...rest
|
||||
}: ButtonElementProps = $props();
|
||||
|
||||
const cropState = useImageCropperCrop();
|
||||
</script>
|
||||
|
||||
<Button
|
||||
{...rest}
|
||||
bind:ref
|
||||
{size}
|
||||
{variant}
|
||||
onclick={(
|
||||
e: MouseEvent & {
|
||||
currentTarget: EventTarget & HTMLButtonElement;
|
||||
}
|
||||
) => {
|
||||
onclick?.(e);
|
||||
|
||||
cropState.onclick();
|
||||
}}
|
||||
>
|
||||
<CropIcon />
|
||||
<span>Crop</span>
|
||||
</Button>
|
||||
@@ -0,0 +1,26 @@
|
||||
<script lang="ts">
|
||||
import Cropper from 'svelte-easy-crop';
|
||||
import { useImageCropperCropper } from './image-cropper.svelte.js';
|
||||
import type { ImageCropperCropperProps } from './types.js';
|
||||
|
||||
let {
|
||||
cropShape = 'round',
|
||||
aspect = 1,
|
||||
showGrid = false,
|
||||
...rest
|
||||
}: ImageCropperCropperProps = $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,25 @@
|
||||
<script lang="ts">
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { cn } from '$lib/utils.js';
|
||||
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,25 @@
|
||||
<script lang="ts">
|
||||
import * as Avatar from '$lib/components/ui/avatar';
|
||||
import type { ImageCropperPreviewProps } from './types';
|
||||
import { useImageCropperPreview } from './image-cropper.svelte.js';
|
||||
import UploadIcon from '@lucide/svelte/icons/upload';
|
||||
import { cn } from '$lib/utils.js';
|
||||
|
||||
let { child, class: className }: ImageCropperPreviewProps = $props();
|
||||
|
||||
const previewState = useImageCropperPreview();
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ src: previewState.rootState.src })}
|
||||
{:else}
|
||||
<Avatar.Root
|
||||
class={cn('ring-accent ring-offset-background size-20 ring-2 ring-offset-2', className)}
|
||||
>
|
||||
<Avatar.Image src={previewState.rootState.src} />
|
||||
<Avatar.Fallback>
|
||||
<UploadIcon class="size-4" />
|
||||
<span class="sr-only">Upload image</span>
|
||||
</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
{/if}
|
||||
@@ -0,0 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { useImageCropperTrigger } from './image-cropper.svelte.js';
|
||||
import type { ImageCropperUploadTriggerProps } from './types';
|
||||
|
||||
let { ref = $bindable(null), children, ...rest }: ImageCropperUploadTriggerProps = $props();
|
||||
|
||||
const triggerState = useImageCropperTrigger();
|
||||
</script>
|
||||
|
||||
<label {...rest} bind:this={ref} for={triggerState.rootState.id} class="hover:cursor-pointer">
|
||||
{@render children?.()}
|
||||
</label>
|
||||
@@ -0,0 +1,43 @@
|
||||
<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 = () => {},
|
||||
onUnsupportedFile = () => {},
|
||||
children,
|
||||
...rest
|
||||
}: ImageCropperRootProps = $props();
|
||||
|
||||
const rootState = useImageCropperRoot({
|
||||
id: box.with(() => id),
|
||||
src: box.with(
|
||||
() => src,
|
||||
(v) => (src = v)
|
||||
),
|
||||
onCropped: box.with(() => onCropped),
|
||||
onUnsupportedFile: box.with(() => onUnsupportedFile)
|
||||
});
|
||||
|
||||
onDestroy(() => rootState.dispose());
|
||||
</script>
|
||||
|
||||
{@render children?.()}
|
||||
<input
|
||||
{...rest}
|
||||
onchange={(e) => {
|
||||
const file = e.currentTarget.files?.[0];
|
||||
if (!file) return;
|
||||
rootState.onUpload(file);
|
||||
// reset so that we can reupload the same file
|
||||
(e.target! as HTMLInputElement).value = '';
|
||||
}}
|
||||
type="file"
|
||||
{id}
|
||||
style="display: none;"
|
||||
/>
|
||||
@@ -0,0 +1,167 @@
|
||||
import type { ReadableBoxedValues, WritableBoxedValues } from 'svelte-toolbelt';
|
||||
import { Context } from 'runed';
|
||||
import type { CropArea, DispatchEvents } from 'svelte-easy-crop';
|
||||
import { getCroppedImg } from './utils';
|
||||
|
||||
// https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/img#supported_image_formats
|
||||
export const VALID_IMAGE_TYPES = [
|
||||
'image/apng',
|
||||
'image/avif',
|
||||
'image/gif',
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/svg+xml',
|
||||
'image/webp'
|
||||
];
|
||||
|
||||
export type ImageCropperRootProps = WritableBoxedValues<{
|
||||
src: string;
|
||||
}> &
|
||||
ReadableBoxedValues<{
|
||||
id: string;
|
||||
onCropped: (url: string) => void;
|
||||
onUnsupportedFile: (file: File) => 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) {
|
||||
if (!VALID_IMAGE_TYPES.includes(file.type)) {
|
||||
this.opts.onUnsupportedFile.current(file);
|
||||
return;
|
||||
}
|
||||
|
||||
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.current(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);
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
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 };
|
||||
|
||||
export type * from './types';
|
||||
@@ -0,0 +1,44 @@
|
||||
import type {
|
||||
AvatarRootProps,
|
||||
DialogContentProps,
|
||||
WithChildren,
|
||||
WithoutChild,
|
||||
WithoutChildren
|
||||
} from 'bits-ui';
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { CropperProps } from 'svelte-easy-crop';
|
||||
import type { HTMLAttributes, HTMLInputAttributes } from 'svelte/elements';
|
||||
|
||||
export type ImageCropperRootPropsWithoutHTML = WithChildren<{
|
||||
id?: string;
|
||||
src?: string;
|
||||
onCropped?: (url: string) => void;
|
||||
onUnsupportedFile?: (file: File) => void;
|
||||
}>;
|
||||
|
||||
export type ImageCropperRootProps = ImageCropperRootPropsWithoutHTML & HTMLInputAttributes;
|
||||
|
||||
export type ImageCropperDialogProps = DialogContentProps;
|
||||
|
||||
export type ImageCropperCropperProps = Omit<Partial<CropperProps>, 'oncropcomplete' | 'image'>;
|
||||
|
||||
export type ImageCropperControlsWithoutHTML = WithChildren<{
|
||||
ref?: HTMLDivElement | null;
|
||||
}>;
|
||||
|
||||
export type ImageCropperControlsProps = ImageCropperControlsWithoutHTML &
|
||||
WithoutChildren<HTMLAttributes<HTMLDivElement>>;
|
||||
|
||||
export type ImageCropperPreviewPropsWithoutHTML = {
|
||||
child?: Snippet<[{ src: string }]>;
|
||||
};
|
||||
|
||||
export type ImageCropperPreviewProps = ImageCropperPreviewPropsWithoutHTML &
|
||||
WithoutChild<AvatarRootProps>;
|
||||
|
||||
export type ImageCropperUploadTriggerPropsWithoutHTML = WithChildren<{
|
||||
ref?: HTMLLabelElement | null;
|
||||
}>;
|
||||
|
||||
export type ImageCropperUploadTriggerProps = ImageCropperUploadTriggerPropsWithoutHTML &
|
||||
WithoutChildren<HTMLAttributes<HTMLLabelElement>>;
|
||||
@@ -0,0 +1,85 @@
|
||||
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');
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user