import React, { useEffect, useRef, useState, useCallback, useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; import { useExploreStore, type TaggedExploreSpace } from '../../stores/exploreStore'; import { useSpaceStore } from '../../stores/spaceStore'; import { LoadingSpinner } from '../ui/LoadingSpinner'; import { Mascot } from '../ui/Mascot'; import { getSpaceGradient } from '../../utils/gradients'; import { extractDominantColors, colorsToGradient } from '../../utils/colorExtractor'; import { MemberListToggleButton } from '../layout/MemberListToggleButton'; export function ExplorePage() { const navigate = useNavigate(); const setCurrentSpace = useSpaceStore((s) => s.setCurrentSpace); const spaces = useExploreStore((s) => s.spaces); const myRequests = useExploreStore((s) => s.myRequests); const isLoading = useExploreStore((s) => s.isLoading); const discoveryEnabled = useExploreStore((s) => s.discoveryEnabled); const error = useExploreStore((s) => s.error); const searchQuery = useExploreStore((s) => s.searchQuery); const setSearchQuery = useExploreStore((s) => s.setSearchQuery); const fetchSpaces = useExploreStore((s) => s.fetchSpaces); const fetchMyRequests = useExploreStore((s) => s.fetchMyRequests); const [joinedCollapsed, setJoinedCollapsed] = useState(false); const debounceRef = useRef | null>(null); // Fetch on mount useEffect(() => { fetchSpaces(); fetchMyRequests(); }, [fetchSpaces, fetchMyRequests]); // Debounced search const handleSearchChange = useCallback((value: string) => { setSearchQuery(value); if (debounceRef.current) clearTimeout(debounceRef.current); debounceRef.current = setTimeout(() => { fetchSpaces(value || undefined); }, 300); }, [setSearchQuery, fetchSpaces]); useEffect(() => { return () => { if (debounceRef.current) clearTimeout(debounceRef.current); }; }, []); const handleJoinSuccess = (spaceId: string) => { setCurrentSpace(spaceId); navigate(`/channels/${spaceId}`); }; const unjoinedSpaces = useMemo( () => spaces.filter(s => !s.joined), [spaces], ); const joinedSpaces = useMemo( () => spaces.filter(s => s.joined), [spaces], ); const hasAnySpaces = spaces.length > 0; const hasUnjoined = unjoinedSpaces.length > 0; const hasJoined = joinedSpaces.length > 0; return (
{/* Header */}
Explore
handleSearchChange(e.target.value)} className="input-search w-full" /> {searchQuery && ( )}
{/* Content */}
{!discoveryEnabled && (
Space discovery is disabled by the instance administrator.
)} {isLoading && spaces.length === 0 ? (
) : error ? (
{error}
) : !hasAnySpaces ? ( /* True empty state — no discoverable spaces at all */

{searchQuery ? 'No spaces match your search.' : 'No discoverable spaces yet.'}

) : (
{/* All-joined success banner (only when no unjoined spaces remain) */} {!hasUnjoined && hasJoined && !searchQuery && (
You're in all discoverable spaces
)} {/* Unjoined spaces grid */} {hasUnjoined && (
{unjoinedSpaces.map((space) => ( r.spaceId === space.id && r.status === 'pending')} onJoinSuccess={handleJoinSuccess} /> ))}
)} {/* Joined spaces section */} {hasJoined && (
{!joinedCollapsed && (
{joinedSpaces.map((space) => ( ))}
)}
)}
)}
); } function SpaceCard({ space, isPending, onJoinSuccess, }: { space: TaggedExploreSpace; isPending: boolean; onJoinSuccess: (spaceId: string) => void; }) { const publicJoin = useExploreStore((s) => s.publicJoin); const requestJoin = useExploreStore((s) => s.requestJoin); const [joining, setJoining] = useState(false); const [showRequestForm, setShowRequestForm] = useState(false); const [requestMessage, setRequestMessage] = useState(''); const [requestSent, setRequestSent] = useState(isPending); const [joinError, setJoinError] = useState(''); const [iconGradient, setIconGradient] = useState(null); const fallbackGradient = getSpaceGradient(space.id, space.name, space.avatarColor).gradient; const isPublic = space.visibility === 'public'; const isJoined = space.joined === true; const originLabel = space._instanceOrigin ? (() => { try { return new URL(space._instanceOrigin).host; } catch { return space._instanceOrigin; } })() : null; const iconUrl = space.icon ? (space.icon.startsWith('http') || space.icon.startsWith('/') ? space.icon : `/api/uploads/${space.icon}`) : null; const bannerUrl = space.banner ? (space.banner.startsWith('http') || space.banner.startsWith('/') ? space.banner : `/api/uploads/${space.banner}`) : null; // Extract dominant colors from icon when no banner is set useEffect(() => { if (bannerUrl || !iconUrl) return; let cancelled = false; extractDominantColors(iconUrl) .then(colors => { if (!cancelled && colors.length > 0) setIconGradient(colorsToGradient(colors)); }) .catch(() => {}); return () => { cancelled = true; }; }, [iconUrl, bannerUrl]); const handlePublicJoin = async () => { setJoining(true); setJoinError(''); try { const fullSpace = await publicJoin(space); onJoinSuccess(fullSpace.id); } catch (err) { setJoinError(err instanceof Error ? err.message : 'Failed to join'); setJoining(false); } }; const handleRequestJoin = async () => { setJoining(true); setJoinError(''); try { await requestJoin(space, requestMessage.trim() || undefined); setRequestSent(true); setShowRequestForm(false); } catch (err) { setJoinError(err instanceof Error ? err.message : 'Failed to send request'); } finally { setJoining(false); } }; const handleViewSpace = () => { onJoinSuccess(space.id); }; return (
{/* Banner area */}
{/* Background layer */} {bannerUrl ? ( ) : (
)} {/* Frosted bottom fade — Aether Drift glass */}
{/* Joined badge (top-left) */} {isJoined && (
Joined
)} {/* Visibility badge */}
{isPublic ? 'Public' : 'Request'}
{/* Overlapping icon */}
{iconUrl ? ( {space.name} ) : (
{space.name.charAt(0).toUpperCase()}
)}
{/* Content */}

{space.name}

{space.description ? (

{space.description}

) : (

No description

)}
{space.memberCount} {space.memberCount === 1 ? 'member' : 'members'} {originLabel && ( {originLabel} )}
{/* Action area */} {joinError && (
{joinError}
)} {isJoined ? ( ) : isPublic ? ( ) : requestSent ? ( ) : showRequestForm ? (