feat: add image crop modal to space icon picker
Wire react-easy-crop into CreateSpace so users can crop and zoom before uploading a space icon. Adds reusable ImageCropModal component and canvas crop utility for future use in space settings and avatar editing.
This commit is contained in:
@@ -9,13 +9,14 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@livekit/components-react": "^2.7.4",
|
|
||||||
"@backspace/shared": "workspace:*",
|
"@backspace/shared": "workspace:*",
|
||||||
|
"@livekit/components-react": "^2.7.4",
|
||||||
"@sapphi-red/web-noise-suppressor": "^0.3.5",
|
"@sapphi-red/web-noise-suppressor": "^0.3.5",
|
||||||
"livekit-client": "^2.9.0",
|
"livekit-client": "^2.9.0",
|
||||||
"prism-react-renderer": "^2.4.1",
|
"prism-react-renderer": "^2.4.1",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
|
"react-easy-crop": "^5.5.6",
|
||||||
"react-markdown": "^9.0.1",
|
"react-markdown": "^9.0.1",
|
||||||
"react-router-dom": "^6.28.0",
|
"react-router-dom": "^6.28.0",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
|
|||||||
@@ -1,13 +1,30 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState, useRef } from 'react';
|
||||||
import { Modal } from '../ui/Modal';
|
import { Modal } from '../ui/Modal';
|
||||||
|
import { ImageCropModal } from '../ui/ImageCropModal';
|
||||||
import { useSpaceStore } from '../../stores/spaceStore';
|
import { useSpaceStore } from '../../stores/spaceStore';
|
||||||
import { useUIStore } from '../../stores/uiStore';
|
import { useUIStore } from '../../stores/uiStore';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { api } from '../../api/client';
|
||||||
|
import type { SpaceVisibility } from '@backspace/shared';
|
||||||
|
|
||||||
|
const visibilityOptions: { value: SpaceVisibility; label: string; desc: string }[] = [
|
||||||
|
{ value: 'private', label: 'Private', desc: 'Only people with an invite link can join' },
|
||||||
|
{ value: 'request', label: 'Request to Join', desc: 'Visible in Explore — people can request to join' },
|
||||||
|
{ value: 'public', label: 'Public', desc: 'Visible in Explore — anyone can join instantly' },
|
||||||
|
];
|
||||||
|
|
||||||
export function CreateSpaceModal() {
|
export function CreateSpaceModal() {
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
|
const [visibility, setVisibility] = useState<SpaceVisibility>('private');
|
||||||
|
const [description, setDescription] = useState('');
|
||||||
|
const [iconFilename, setIconFilename] = useState<string | null>(null);
|
||||||
|
const [iconPreview, setIconPreview] = useState<string | null>(null);
|
||||||
|
const [uploadingIcon, setUploadingIcon] = useState(false);
|
||||||
|
const [cropSrc, setCropSrc] = useState<string | null>(null);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const createSpace = useSpaceStore((s) => s.createSpace);
|
const createSpace = useSpaceStore((s) => s.createSpace);
|
||||||
const activeModal = useUIStore((s) => s.activeModal);
|
const activeModal = useUIStore((s) => s.activeModal);
|
||||||
const closeModal = useUIStore((s) => s.closeModal);
|
const closeModal = useUIStore((s) => s.closeModal);
|
||||||
@@ -15,6 +32,58 @@ export function CreateSpaceModal() {
|
|||||||
|
|
||||||
const isOpen = activeModal === 'createSpace';
|
const isOpen = activeModal === 'createSpace';
|
||||||
|
|
||||||
|
const handleIconSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => setCropSrc(reader.result as string);
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
|
||||||
|
// Reset the input so re-selecting the same file triggers onChange
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCropComplete = async (blob: Blob) => {
|
||||||
|
// Show cropped preview
|
||||||
|
if (iconPreview) URL.revokeObjectURL(iconPreview);
|
||||||
|
const previewUrl = URL.createObjectURL(blob);
|
||||||
|
setIconPreview(previewUrl);
|
||||||
|
setCropSrc(null);
|
||||||
|
|
||||||
|
// Upload the cropped image
|
||||||
|
const file = new File([blob], 'icon.png', { type: 'image/png' });
|
||||||
|
setUploadingIcon(true);
|
||||||
|
try {
|
||||||
|
const attachment = await api.uploads.upload(file);
|
||||||
|
setIconFilename(attachment.filename);
|
||||||
|
} catch {
|
||||||
|
setError('Failed to upload icon');
|
||||||
|
setIconPreview(null);
|
||||||
|
URL.revokeObjectURL(previewUrl);
|
||||||
|
} finally {
|
||||||
|
setUploadingIcon(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveIcon = () => {
|
||||||
|
if (iconPreview) URL.revokeObjectURL(iconPreview);
|
||||||
|
setIconFilename(null);
|
||||||
|
setIconPreview(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
closeModal();
|
||||||
|
setName('');
|
||||||
|
setVisibility('private');
|
||||||
|
setDescription('');
|
||||||
|
setIconFilename(null);
|
||||||
|
if (iconPreview) URL.revokeObjectURL(iconPreview);
|
||||||
|
setIconPreview(null);
|
||||||
|
setCropSrc(null);
|
||||||
|
setError('');
|
||||||
|
};
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError('');
|
setError('');
|
||||||
@@ -26,9 +95,13 @@ export function CreateSpaceModal() {
|
|||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
const space = await createSpace(name.trim());
|
const space = await createSpace({
|
||||||
closeModal();
|
name: name.trim(),
|
||||||
setName('');
|
icon: iconFilename ?? undefined,
|
||||||
|
visibility,
|
||||||
|
description: description.trim() || undefined,
|
||||||
|
});
|
||||||
|
handleClose();
|
||||||
navigate(`/channels/${space.id}`);
|
navigate(`/channels/${space.id}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Failed to create space');
|
setError(err instanceof Error ? err.message : 'Failed to create space');
|
||||||
@@ -38,13 +111,71 @@ export function CreateSpaceModal() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal isOpen={isOpen} onClose={closeModal} title="Create a Space">
|
<>
|
||||||
|
<Modal isOpen={isOpen} onClose={handleClose} title="Create a Space">
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
{error && (
|
{error && (
|
||||||
<div className="mb-3 p-2 bg-accent-rose/10 border border-accent-rose/30 rounded text-txt-danger text-sm">
|
<div className="mb-3 p-2 bg-accent-rose/10 border border-accent-rose/30 rounded text-txt-danger text-sm">
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Icon Picker */}
|
||||||
|
<div className="flex justify-center mb-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
disabled={uploadingIcon}
|
||||||
|
className="relative w-20 h-20 rounded-full bg-surface-input border-2 border-dashed border-border-subtle hover:border-accent-primary transition-colors flex items-center justify-center overflow-hidden group"
|
||||||
|
>
|
||||||
|
{iconPreview ? (
|
||||||
|
<>
|
||||||
|
<img src={iconPreview} alt="Icon preview" className="w-full h-full object-cover" />
|
||||||
|
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||||
|
<svg className="w-5 h-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center gap-1 text-txt-tertiary group-hover:text-accent-primary transition-colors">
|
||||||
|
{uploadingIcon ? (
|
||||||
|
<svg className="w-6 h-6 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
</svg>
|
||||||
|
<span className="text-[10px] font-medium">Icon</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={handleIconSelect}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
{iconPreview && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleRemoveIcon}
|
||||||
|
className="ml-2 self-start mt-1 text-txt-tertiary hover:text-txt-danger text-xs transition-colors"
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Space Name */}
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<label className="block text-xs font-bold text-txt-secondary uppercase mb-2">
|
<label className="block text-xs font-bold text-txt-secondary uppercase mb-2">
|
||||||
Space Name
|
Space Name
|
||||||
@@ -58,17 +189,66 @@ export function CreateSpaceModal() {
|
|||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Visibility */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<div className="text-[11px] text-txt-tertiary font-semibold uppercase tracking-wider mb-2">
|
||||||
|
Visibility
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{visibilityOptions.map((opt) => (
|
||||||
|
<label
|
||||||
|
key={opt.value}
|
||||||
|
className={`flex items-start gap-3 p-2.5 rounded cursor-pointer transition-colors ${
|
||||||
|
visibility === opt.value
|
||||||
|
? 'bg-interactive-selected'
|
||||||
|
: 'hover:bg-interactive-hover'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="create-visibility"
|
||||||
|
value={opt.value}
|
||||||
|
checked={visibility === opt.value}
|
||||||
|
onChange={() => setVisibility(opt.value)}
|
||||||
|
className="mt-0.5 accent-accent-primary"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-txt-primary">{opt.label}</div>
|
||||||
|
<div className="text-xs text-txt-tertiary">{opt.desc}</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<div className="text-[11px] text-txt-tertiary font-semibold uppercase tracking-wider mb-1.5">
|
||||||
|
Description
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value.slice(0, 200))}
|
||||||
|
placeholder="A short description for your space..."
|
||||||
|
rows={3}
|
||||||
|
className="w-full px-3 py-2 bg-surface-input rounded text-sm text-txt-primary outline-none focus:ring-1 focus:ring-accent-primary resize-none placeholder:text-txt-tertiary"
|
||||||
|
/>
|
||||||
|
<div className="text-[11px] text-txt-tertiary text-right">{description.length}/200</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={closeModal}
|
onClick={handleClose}
|
||||||
className="px-4 py-2 text-sm text-txt-secondary hover:text-txt-primary transition-colors"
|
className="px-4 py-2 text-sm text-txt-secondary hover:text-txt-primary transition-colors"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isLoading}
|
disabled={isLoading || uploadingIcon}
|
||||||
className="px-4 py-2 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded transition-colors disabled:opacity-50"
|
className="px-4 py-2 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded transition-colors disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{isLoading ? 'Creating...' : 'Create'}
|
{isLoading ? 'Creating...' : 'Create'}
|
||||||
@@ -76,5 +256,16 @@ export function CreateSpaceModal() {
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<ImageCropModal
|
||||||
|
isOpen={cropSrc !== null}
|
||||||
|
onClose={() => setCropSrc(null)}
|
||||||
|
imageSrc={cropSrc ?? ''}
|
||||||
|
onCropComplete={handleCropComplete}
|
||||||
|
title="Crop Space Icon"
|
||||||
|
cropShape="round"
|
||||||
|
aspectRatio={1}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import React, { useState, useCallback, useEffect } from 'react';
|
||||||
|
import Cropper from 'react-easy-crop';
|
||||||
|
import type { Area } from 'react-easy-crop';
|
||||||
|
import { cropImage } from '../../utils/cropImage';
|
||||||
|
|
||||||
|
interface ImageCropModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
imageSrc: string;
|
||||||
|
onCropComplete: (blob: Blob) => void;
|
||||||
|
title?: string;
|
||||||
|
aspectRatio?: number;
|
||||||
|
cropShape?: 'round' | 'rect';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ImageCropModal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
imageSrc,
|
||||||
|
onCropComplete,
|
||||||
|
title = 'Crop Image',
|
||||||
|
aspectRatio = 1,
|
||||||
|
cropShape = 'round',
|
||||||
|
}: ImageCropModalProps) {
|
||||||
|
const [crop, setCrop] = useState({ x: 0, y: 0 });
|
||||||
|
const [zoom, setZoom] = useState(1);
|
||||||
|
const [croppedAreaPixels, setCroppedAreaPixels] = useState<Area | null>(null);
|
||||||
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
|
|
||||||
|
const handleCropComplete = useCallback((_croppedArea: Area, croppedPixels: Area) => {
|
||||||
|
setCroppedAreaPixels(croppedPixels);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleApply = async () => {
|
||||||
|
if (!croppedAreaPixels) return;
|
||||||
|
setIsProcessing(true);
|
||||||
|
try {
|
||||||
|
const blob = await cropImage(imageSrc, croppedAreaPixels);
|
||||||
|
onCropComplete(blob);
|
||||||
|
onClose();
|
||||||
|
} catch {
|
||||||
|
// Silently fail — user can retry
|
||||||
|
} finally {
|
||||||
|
setIsProcessing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Reset state when opened with a new image
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
setCrop({ x: 0, y: 0 });
|
||||||
|
setZoom(1);
|
||||||
|
setCroppedAreaPixels(null);
|
||||||
|
setIsProcessing(false);
|
||||||
|
}
|
||||||
|
}, [isOpen, imageSrc]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onClose();
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [isOpen, onClose]);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-[210] flex items-center justify-center animate-fade-in">
|
||||||
|
<div className="absolute inset-0 bg-surface-overlay" onClick={onClose} />
|
||||||
|
<div className="relative max-w-md w-full mx-4 bg-surface-elevated rounded-lg shadow-xl animate-slide-up">
|
||||||
|
{/* Title bar */}
|
||||||
|
<div className="flex items-center justify-between px-4 pt-4">
|
||||||
|
<h2 className="text-xl font-bold text-txt-primary">{title}</h2>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-txt-tertiary hover:text-txt-primary transition-colors p-1"
|
||||||
|
>
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M18.4 4L12 10.4L5.6 4L4 5.6L10.4 12L4 18.4L5.6 20L12 13.6L18.4 20L20 18.4L13.6 12L20 5.6L18.4 4Z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Crop area */}
|
||||||
|
<div className="relative h-[350px] mx-4 mt-3 rounded-lg overflow-hidden bg-surface-base">
|
||||||
|
<Cropper
|
||||||
|
image={imageSrc}
|
||||||
|
crop={crop}
|
||||||
|
zoom={zoom}
|
||||||
|
aspect={aspectRatio}
|
||||||
|
cropShape={cropShape}
|
||||||
|
showGrid={false}
|
||||||
|
onCropChange={setCrop}
|
||||||
|
onZoomChange={setZoom}
|
||||||
|
onCropComplete={handleCropComplete}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Zoom slider */}
|
||||||
|
<div className="px-4 pt-3 flex items-center gap-3">
|
||||||
|
<svg className="w-4 h-4 text-txt-tertiary flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0zM10 7v3m0 0v3m0-3h3m-3 0H7" />
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={1}
|
||||||
|
max={3}
|
||||||
|
step={0.05}
|
||||||
|
value={zoom}
|
||||||
|
onChange={(e) => setZoom(Number(e.target.value))}
|
||||||
|
className="flex-1 h-1.5 rounded-full appearance-none bg-surface-input accent-accent-primary cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3.5 [&::-webkit-slider-thumb]:h-3.5 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-accent-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex justify-end gap-2 p-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 text-sm text-txt-secondary hover:text-txt-primary transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleApply}
|
||||||
|
disabled={isProcessing || !croppedAreaPixels}
|
||||||
|
className="px-4 py-2 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isProcessing ? 'Applying...' : 'Apply'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
export interface PixelCrop {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cropImage(imageSrc: string, pixelCrop: PixelCrop, outputType = 'image/png'): Promise<Blob> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const img = new Image();
|
||||||
|
img.crossOrigin = 'anonymous';
|
||||||
|
img.onload = () => {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = pixelCrop.width;
|
||||||
|
canvas.height = pixelCrop.height;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) {
|
||||||
|
reject(new Error('Failed to get canvas context'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ctx.drawImage(
|
||||||
|
img,
|
||||||
|
pixelCrop.x,
|
||||||
|
pixelCrop.y,
|
||||||
|
pixelCrop.width,
|
||||||
|
pixelCrop.height,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
pixelCrop.width,
|
||||||
|
pixelCrop.height,
|
||||||
|
);
|
||||||
|
canvas.toBlob(
|
||||||
|
(blob) => {
|
||||||
|
if (blob) resolve(blob);
|
||||||
|
else reject(new Error('Canvas toBlob returned null'));
|
||||||
|
},
|
||||||
|
outputType,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
img.onerror = () => reject(new Error('Failed to load image'));
|
||||||
|
img.src = imageSrc;
|
||||||
|
});
|
||||||
|
}
|
||||||
Generated
+21
@@ -123,6 +123,9 @@ importers:
|
|||||||
react-dom:
|
react-dom:
|
||||||
specifier: ^18.3.1
|
specifier: ^18.3.1
|
||||||
version: 18.3.1(react@18.3.1)
|
version: 18.3.1(react@18.3.1)
|
||||||
|
react-easy-crop:
|
||||||
|
specifier: ^5.5.6
|
||||||
|
version: 5.5.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
react-markdown:
|
react-markdown:
|
||||||
specifier: ^9.0.1
|
specifier: ^9.0.1
|
||||||
version: 9.1.0(@types/react@18.3.28)(react@18.3.1)
|
version: 9.1.0(@types/react@18.3.28)(react@18.3.1)
|
||||||
@@ -3145,6 +3148,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==}
|
resolution: {integrity: sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
normalize-wheel@1.0.1:
|
||||||
|
resolution: {integrity: sha512-1OnlAPZ3zgrk8B91HyRj+eVv+kS5u+Z0SCsak6Xil/kmgEia50ga7zfkumayonZrImffAxPU/5WcyGhzetHNPA==}
|
||||||
|
|
||||||
npmlog@6.0.2:
|
npmlog@6.0.2:
|
||||||
resolution: {integrity: sha512-/vBvz5Jfr9dT/aFWd0FIRf+T/Q2WBsLENygUaFUqstqsycmZAP/t5BvFJTK0viFmSUxiUKTUplWy5vt+rvKIxg==}
|
resolution: {integrity: sha512-/vBvz5Jfr9dT/aFWd0FIRf+T/Q2WBsLENygUaFUqstqsycmZAP/t5BvFJTK0viFmSUxiUKTUplWy5vt+rvKIxg==}
|
||||||
engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0}
|
engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0}
|
||||||
@@ -3397,6 +3403,12 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^18.3.1
|
react: ^18.3.1
|
||||||
|
|
||||||
|
react-easy-crop@5.5.6:
|
||||||
|
resolution: {integrity: sha512-Jw3/ozs8uXj3NpL511Suc4AHY+mLRO23rUgipXvNYKqezcFSYHxe4QXibBymkOoY6oOtLVMPO2HNPRHYvMPyTw==}
|
||||||
|
peerDependencies:
|
||||||
|
react: '>=16.4.0'
|
||||||
|
react-dom: '>=16.4.0'
|
||||||
|
|
||||||
react-is@17.0.2:
|
react-is@17.0.2:
|
||||||
resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
|
resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
|
||||||
|
|
||||||
@@ -7309,6 +7321,8 @@ snapshots:
|
|||||||
|
|
||||||
normalize-url@6.1.0: {}
|
normalize-url@6.1.0: {}
|
||||||
|
|
||||||
|
normalize-wheel@1.0.1: {}
|
||||||
|
|
||||||
npmlog@6.0.2:
|
npmlog@6.0.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
are-we-there-yet: 3.0.1
|
are-we-there-yet: 3.0.1
|
||||||
@@ -7560,6 +7574,13 @@ snapshots:
|
|||||||
react: 18.3.1
|
react: 18.3.1
|
||||||
scheduler: 0.23.2
|
scheduler: 0.23.2
|
||||||
|
|
||||||
|
react-easy-crop@5.5.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
||||||
|
dependencies:
|
||||||
|
normalize-wheel: 1.0.1
|
||||||
|
react: 18.3.1
|
||||||
|
react-dom: 18.3.1(react@18.3.1)
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
react-is@17.0.2: {}
|
react-is@17.0.2: {}
|
||||||
|
|
||||||
react-markdown@9.1.0(@types/react@18.3.28)(react@18.3.1):
|
react-markdown@9.1.0(@types/react@18.3.28)(react@18.3.1):
|
||||||
|
|||||||
Reference in New Issue
Block a user