diff --git a/packages/web/package.json b/packages/web/package.json index aac0b5d7..e4ef22d9 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -9,13 +9,14 @@ "preview": "vite preview" }, "dependencies": { - "@livekit/components-react": "^2.7.4", "@backspace/shared": "workspace:*", + "@livekit/components-react": "^2.7.4", "@sapphi-red/web-noise-suppressor": "^0.3.5", "livekit-client": "^2.9.0", "prism-react-renderer": "^2.4.1", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-easy-crop": "^5.5.6", "react-markdown": "^9.0.1", "react-router-dom": "^6.28.0", "remark-gfm": "^4.0.1", diff --git a/packages/web/src/components/modals/CreateSpace.tsx b/packages/web/src/components/modals/CreateSpace.tsx index 11d0aca3..9c76d8a5 100644 --- a/packages/web/src/components/modals/CreateSpace.tsx +++ b/packages/web/src/components/modals/CreateSpace.tsx @@ -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('private'); + const [description, setDescription] = useState(''); + const [iconFilename, setIconFilename] = useState(null); + const [iconPreview, setIconPreview] = useState(null); + const [uploadingIcon, setUploadingIcon] = useState(false); + const [cropSrc, setCropSrc] = useState(null); const [error, setError] = useState(''); const [isLoading, setIsLoading] = useState(false); + const fileInputRef = useRef(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) => { + 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 ( - + <> +
{error && (
{error}
)} + + {/* Icon Picker */} +
+ + + {iconPreview && ( + + )} +
+ + {/* Space Name */}
+ + {/* Visibility */} +
+
+ Visibility +
+
+ {visibilityOptions.map((opt) => ( + + ))} +
+
+ + {/* Description */} +
+
+ Description +
+