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.
272 lines
10 KiB
TypeScript
272 lines
10 KiB
TypeScript
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);
|
|
const navigate = useNavigate();
|
|
|
|
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('');
|
|
|
|
if (!name.trim()) {
|
|
setError('Space name is required');
|
|
return;
|
|
}
|
|
|
|
setIsLoading(true);
|
|
try {
|
|
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');
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<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
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={name}
|
|
onChange={(e) => setName(e.target.value)}
|
|
className="w-full px-3 py-2 bg-surface-input rounded text-txt-primary outline-none focus:ring-2 focus:ring-accent-primary"
|
|
placeholder="My Awesome Space"
|
|
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={handleClose}
|
|
className="px-4 py-2 text-sm text-txt-secondary hover:text-txt-primary transition-colors"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
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'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</Modal>
|
|
|
|
<ImageCropModal
|
|
isOpen={cropSrc !== null}
|
|
onClose={() => setCropSrc(null)}
|
|
imageSrc={cropSrc ?? ''}
|
|
onCropComplete={handleCropComplete}
|
|
title="Crop Space Icon"
|
|
cropShape="round"
|
|
aspectRatio={1}
|
|
/>
|
|
</>
|
|
);
|
|
}
|