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:
Jannis Braun
2026-03-08 22:08:58 +01:00
parent a7b5d819bf
commit b598236f82
5 changed files with 402 additions and 8 deletions
@@ -1,13 +1,30 @@
import React, { useState } from 'react';
import React, { useState, useRef } from 'react';
import { Modal } from '../ui/Modal';
import { ImageCropModal } from '../ui/ImageCropModal';
import { useSpaceStore } from '../../stores/spaceStore';
import { useUIStore } from '../../stores/uiStore';
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() {
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 [isLoading, setIsLoading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const createSpace = useSpaceStore((s) => s.createSpace);
const activeModal = useUIStore((s) => s.activeModal);
const closeModal = useUIStore((s) => s.closeModal);
@@ -15,6 +32,58 @@ export function CreateSpaceModal() {
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) => {
e.preventDefault();
setError('');
@@ -26,9 +95,13 @@ export function CreateSpaceModal() {
setIsLoading(true);
try {
const space = await createSpace(name.trim());
closeModal();
setName('');
const space = await createSpace({
name: name.trim(),
icon: iconFilename ?? undefined,
visibility,
description: description.trim() || undefined,
});
handleClose();
navigate(`/channels/${space.id}`);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create space');
@@ -38,13 +111,71 @@ export function CreateSpaceModal() {
};
return (
<Modal isOpen={isOpen} onClose={closeModal} title="Create a Space">
<>
<Modal isOpen={isOpen} onClose={handleClose} title="Create a Space">
<form onSubmit={handleSubmit}>
{error && (
<div className="mb-3 p-2 bg-accent-rose/10 border border-accent-rose/30 rounded text-txt-danger text-sm">
{error}
</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">
<label className="block text-xs font-bold text-txt-secondary uppercase mb-2">
Space Name
@@ -58,17 +189,66 @@ export function CreateSpaceModal() {
autoFocus
/>
</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">
<button
type="button"
onClick={closeModal}
onClick={handleClose}
className="px-4 py-2 text-sm text-txt-secondary hover:text-txt-primary transition-colors"
>
Cancel
</button>
<button
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"
>
{isLoading ? 'Creating...' : 'Create'}
@@ -76,5 +256,16 @@ export function CreateSpaceModal() {
</div>
</form>
</Modal>
<ImageCropModal
isOpen={cropSrc !== null}
onClose={() => setCropSrc(null)}
imageSrc={cropSrc ?? ''}
onCropComplete={handleCropComplete}
title="Crop Space Icon"
cropShape="round"
aspectRatio={1}
/>
</>
);
}