import React, { useState, useRef, useEffect, useMemo } from 'react'; import { ImageCropModal } from '../../ui/ImageCropModal'; import { getSpaceGradient } from '../../../utils/gradients'; import { useSpaceStore } from '../../../stores/spaceStore'; import { useAuthStore } from '../../../stores/authStore'; import { useUIStore } from '../../../stores/uiStore'; import { useNavigate } from 'react-router-dom'; import { api } from '../../../api/client'; import { getApiForOrigin, getMyUserIdForOrigin } from '../../../stores/spaceStore'; import { hasPermissionBit, PermissionBits } from '../../../utils/permissions'; interface OverviewPanelProps { spaceId: string; } export function OverviewPanel({ spaceId }: OverviewPanelProps) { const spaces = useSpaceStore((s) => s.spaces); const updateSpace = useSpaceStore((s) => s.updateSpace); const deleteSpace = useSpaceStore((s) => s.deleteSpace); const currentUser = useAuthStore((s) => s.user); const closeModal = useUIStore((s) => s.closeModal); const spacePermissions = useSpaceStore((s) => s.spacePermissions); const navigate = useNavigate(); const space = spaces.find((s) => s.id === spaceId); const isOwner = space?.ownerId === getMyUserIdForOrigin((space as any)?._instanceOrigin ?? ''); const myPerms = spacePermissions.get(spaceId); const canManageSpace = hasPermissionBit(myPerms, PermissionBits.MANAGE_SPACE); const [spaceName, setSpaceName] = useState(space?.name ?? ''); // null = no change, '' = remove, 'filename.png' = new uploaded const [iconFilename, setIconFilename] = useState(null); const [iconPreview, setIconPreview] = useState(null); const [uploadingIcon, setUploadingIcon] = useState(false); const [cropSrc, setCropSrc] = useState(null); // Banner state (same pattern as icon) const [bannerFilename, setBannerFilename] = useState(null); const [bannerPreview, setBannerPreview] = useState(null); const [uploadingBanner, setUploadingBanner] = useState(false); const [bannerCropSrc, setBannerCropSrc] = useState(null); const bannerFileInputRef = useRef(null); const [saving, setSaving] = useState(false); const [saveError, setSaveError] = useState(''); const [saveSuccess, setSaveSuccess] = useState(false); const [confirmDelete, setConfirmDelete] = useState(false); const fileInputRef = useRef(null); // Transfer ownership state const members = useSpaceStore((s) => s.members); const transferOwnership = useSpaceStore((s) => s.transferOwnership); const addToast = useUIStore((s) => s.addToast); const [showTransfer, setShowTransfer] = useState(false); const [transferSearch, setTransferSearch] = useState(''); const [transferTargetId, setTransferTargetId] = useState(null); const [transferring, setTransferring] = useState(false); useEffect(() => { if (space) { setSpaceName(space.name); setIconFilename(null); if (iconPreview) { URL.revokeObjectURL(iconPreview); setIconPreview(null); } setBannerFilename(null); if (bannerPreview) { URL.revokeObjectURL(bannerPreview); setBannerPreview(null); } } }, [space?.name, space?.icon, space?.banner]); if (!space) return null; const hasNameChange = spaceName.trim() !== space.name; const hasIconChange = iconFilename !== null; const hasBannerChange = bannerFilename !== null; const hasChanges = hasNameChange || hasIconChange || hasBannerChange; const currentIconUrl = space.icon ? (space.icon.startsWith('http') ? space.icon : api.uploads.url(space.icon)) : null; const currentBannerUrl = space.banner ? (space.banner.startsWith('http') ? space.banner : api.uploads.url(space.banner)) : null; // ─── Icon handlers ──────────────────────────────────────────────────────── 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); if (fileInputRef.current) fileInputRef.current.value = ''; }; const handleCropComplete = async (blob: Blob) => { if (iconPreview) URL.revokeObjectURL(iconPreview); const previewUrl = URL.createObjectURL(blob); setIconPreview(previewUrl); setCropSrc(null); const file = new File([blob], 'icon.png', { type: 'image/png' }); setUploadingIcon(true); try { const spaceApi = getApiForOrigin(space._instanceOrigin); const attachment = await spaceApi.uploads.upload(file); setIconFilename(attachment.filename); } catch { setSaveError('Failed to upload icon'); setIconPreview(null); URL.revokeObjectURL(previewUrl); } finally { setUploadingIcon(false); } }; const handleRemoveIcon = () => { if (iconPreview) URL.revokeObjectURL(iconPreview); setIconPreview(null); setIconFilename(''); }; // ─── Banner handlers ────────────────────────────────────────────────────── const handleBannerSelect = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; const reader = new FileReader(); reader.onload = () => setBannerCropSrc(reader.result as string); reader.readAsDataURL(file); if (bannerFileInputRef.current) bannerFileInputRef.current.value = ''; }; const handleBannerCropComplete = async (blob: Blob) => { if (bannerPreview) URL.revokeObjectURL(bannerPreview); const previewUrl = URL.createObjectURL(blob); setBannerPreview(previewUrl); setBannerCropSrc(null); const file = new File([blob], 'banner.png', { type: 'image/png' }); setUploadingBanner(true); try { const spaceApi = getApiForOrigin(space._instanceOrigin); const attachment = await spaceApi.uploads.upload(file); setBannerFilename(attachment.filename); } catch { setSaveError('Failed to upload banner'); setBannerPreview(null); URL.revokeObjectURL(previewUrl); } finally { setUploadingBanner(false); } }; const handleRemoveBanner = () => { if (bannerPreview) URL.revokeObjectURL(bannerPreview); setBannerPreview(null); setBannerFilename(''); }; // ─── Save / Discard / Delete ────────────────────────────────────────────── const handleSave = async () => { setSaving(true); setSaveError(''); setSaveSuccess(false); try { const updates: { name?: string; icon?: string; banner?: string } = {}; if (hasNameChange) updates.name = spaceName.trim(); if (hasIconChange) { updates.icon = iconFilename === '' ? '' : iconFilename!; } if (hasBannerChange) { updates.banner = bannerFilename === '' ? '' : bannerFilename!; } await updateSpace(spaceId, updates); setIconFilename(null); if (iconPreview) { URL.revokeObjectURL(iconPreview); setIconPreview(null); } setBannerFilename(null); if (bannerPreview) { URL.revokeObjectURL(bannerPreview); setBannerPreview(null); } setSaveSuccess(true); setTimeout(() => setSaveSuccess(false), 2000); } catch (err) { setSaveError(err instanceof Error ? err.message : 'Failed to save'); } finally { setSaving(false); } }; const handleDiscard = () => { setSpaceName(space.name); setIconFilename(null); if (iconPreview) { URL.revokeObjectURL(iconPreview); setIconPreview(null); } setBannerFilename(null); if (bannerPreview) { URL.revokeObjectURL(bannerPreview); setBannerPreview(null); } setSaveError(''); }; const handleDelete = async () => { if (!confirmDelete) { setConfirmDelete(true); return; } try { await deleteSpace(spaceId); closeModal(); navigate('/channels/@me'); } catch (err) { setSaveError(err instanceof Error ? err.message : 'Failed to delete space'); } }; // Transfer ownership logic const transferCandidates = useMemo(() => { const candidates = members.filter(m => m.userId !== currentUser?.id); if (!transferSearch.trim()) return candidates; const q = transferSearch.toLowerCase(); return candidates.filter(m => m.user.displayName?.toLowerCase().includes(q) || m.user.username.toLowerCase().includes(q) ); }, [members, currentUser?.id, transferSearch]); const transferTarget = transferTargetId ? members.find(m => m.userId === transferTargetId) : null; const handleTransfer = async () => { if (!transferTargetId) return; setTransferring(true); try { await transferOwnership(spaceId, transferTargetId); addToast(`Ownership transferred to ${transferTarget?.user.displayName || transferTarget?.user.username}`, 'success', 3000); setShowTransfer(false); setTransferTargetId(null); setTransferSearch(''); } catch (err) { addToast(err instanceof Error ? err.message : 'Failed to transfer ownership', 'warning', 3000); } finally { setTransferring(false); } }; const displayIconSrc = iconPreview ?? (iconFilename === '' ? null : currentIconUrl); const displayIconName = space.name; const displayBannerSrc = bannerPreview ?? (bannerFilename === '' ? null : currentBannerUrl); return ( <>
{/* Space Identity */}
Space Identity
{/* Space Icon */}
{canManageSpace && (displayIconSrc || space.icon) && iconFilename !== '' && ( )}
{/* Space Banner */}
{canManageSpace && (displayBannerSrc || space.banner) && bannerFilename !== '' && ( )}
{/* Space Name */}
setSpaceName(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" disabled={!canManageSpace} />
{/* Save / Discard */} {saveError && (
{saveError}
)} {saveSuccess && (
Settings saved
)} {/* Danger Zone */} {isOwner && (
Danger Zone
{/* Transfer Ownership */}

Transfer ownership to another member. You will become a regular member.

{showTransfer ? ( transferTargetId && transferTarget ? ( /* Confirm step */

Transfer ownership to{' '} {transferTarget.user.displayName || transferTarget.user.username}?

) : ( /* Member picker */
setTransferSearch(e.target.value)} placeholder="Search members..." className="w-full px-3 py-1.5 bg-surface-input rounded text-sm text-txt-primary placeholder-txt-tertiary outline-none focus:ring-1 focus:ring-accent-primary/50" autoFocus />
{transferCandidates.length === 0 ? (

No members found

) : ( transferCandidates.map((member) => { const avatarUrl = member.user.avatar ? (member.user.avatar.startsWith('http') ? member.user.avatar : `/api/uploads/${member.user.avatar}`) : null; return ( ); }) )}
) ) : ( )}
{/* Divider */}
{/* Delete Space */}

Permanently delete this space and all its data. This cannot be undone.

)} {canManageSpace && hasChanges && (
)}
setCropSrc(null)} imageSrc={cropSrc ?? ''} onCropComplete={handleCropComplete} title="Crop Space Icon" cropShape="round" aspectRatio={1} /> setBannerCropSrc(null)} imageSrc={bannerCropSrc ?? ''} onCropComplete={handleBannerCropComplete} title="Crop Space Banner" cropShape="rect" aspectRatio={16 / 9} /> ); }