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'; import { useSpaceJoin } from '../../hooks/useSpaceJoin'; import { useT } from '../../i18n'; export function ExplorePage() { const t = useT(); const navigate = useNavigate(); const setCurrentSpace = useSpaceStore((s) => s.setCurrentSpace); const spaces = useExploreStore((s) => s.spaces); 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 */}
{t('explore.title')}
handleSearchChange(e.target.value)} className="input-search w-full" /> {searchQuery && ( )}
{/* Content */}
{!discoveryEnabled && (
{t('explore.disabled')}
)} {isLoading && spaces.length === 0 ? (
) : error ? (
{error}
) : !hasAnySpaces ? ( /* True empty state — no discoverable spaces at all */

{searchQuery ? t('explore.empty.search') : t('explore.empty.none')}

) : (
{/* All-joined success banner (only when no unjoined spaces remain) */} {!hasUnjoined && hasJoined && !searchQuery && (
{t('explore.allJoined')}
)} {/* Unjoined spaces grid */} {hasUnjoined && (
{unjoinedSpaces.map((space) => ( ))}
)} {/* Joined spaces section */} {hasJoined && (
{!joinedCollapsed && (
{joinedSpaces.map((space) => ( ))}
)}
)}
)}
); } function SpaceCard({ space, onJoinSuccess, }: { space: TaggedExploreSpace; onJoinSuccess: (spaceId: string) => void; }) { const t = useT(); const { isJoined, isPublic, isPending, joining, joinError, showRequestForm, requestMessage, setRequestMessage, openRequestForm, cancelRequestForm, join, sendRequest, } = useSpaceJoin(space); const [iconGradient, setIconGradient] = useState(null); const fallbackGradient = getSpaceGradient(space.id, space.name, space.avatarColor).gradient; 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 () => { const full = await join(); if (full) onJoinSuccess(full.id); }; 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 ? t('explore.badge.public') : t('explore.badge.request')}
{/* Overlapping icon */}
{iconUrl ? ( {space.name} ) : (
{space.name.charAt(0).toUpperCase()}
)}
{/* Content */}

{space.name}

{space.description ? (

{space.description}

) : (

{t('explore.noDescription')}

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