feat(web): compact ExploreSpacePreviewCard for join modal
This commit is contained in:
@@ -0,0 +1,55 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
|
||||||
|
// Stub AudioManager to avoid AudioWorkletNode reference error in jsdom.
|
||||||
|
// Reached transitively via exploreStore → spaceStore → chatStore → useWebSocket → voiceStore.
|
||||||
|
vi.mock('../../audio/AudioManager', () => ({
|
||||||
|
AudioManager: {
|
||||||
|
getInstance: vi.fn().mockReturnValue({
|
||||||
|
setOutputDevice: vi.fn(),
|
||||||
|
setVolume: vi.fn(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { ExploreSpacePreviewCard } from './ExploreSpacePreviewCard';
|
||||||
|
import { useExploreStore, type TaggedExploreSpace } from '../../stores/exploreStore';
|
||||||
|
|
||||||
|
function makeSpace(overrides: Partial<TaggedExploreSpace> = {}): TaggedExploreSpace {
|
||||||
|
return {
|
||||||
|
id: 's1', name: 'Design Guild', icon: null, banner: null, avatarColor: null,
|
||||||
|
description: null, visibility: 'public', memberCount: 5, createdAt: 0,
|
||||||
|
joined: false, _instanceOrigin: '', ...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
useExploreStore.setState({
|
||||||
|
myRequests: [],
|
||||||
|
publicJoin: vi.fn().mockResolvedValue({ id: 's1', name: 'Design Guild' }),
|
||||||
|
requestJoin: vi.fn().mockResolvedValue({ id: 'r1', spaceId: 's1', status: 'pending' }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ExploreSpacePreviewCard', () => {
|
||||||
|
it('renders name, member count and a Join button for public spaces', () => {
|
||||||
|
render(<ExploreSpacePreviewCard space={makeSpace()} onJoinSuccess={vi.fn()} />);
|
||||||
|
expect(screen.getByText('Design Guild')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/5 members/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: /join/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('joins a public space and reports success', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onJoinSuccess = vi.fn();
|
||||||
|
render(<ExploreSpacePreviewCard space={makeSpace()} onJoinSuccess={onJoinSuccess} />);
|
||||||
|
await user.click(screen.getByRole('button', { name: /join/i }));
|
||||||
|
await waitFor(() => expect(onJoinSuccess).toHaveBeenCalledWith('s1'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows Request for request-visibility spaces', () => {
|
||||||
|
render(<ExploreSpacePreviewCard space={makeSpace({ visibility: 'request' })} onJoinSuccess={vi.fn()} />);
|
||||||
|
expect(screen.getByRole('button', { name: /request/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useSpaceJoin } from '../../hooks/useSpaceJoin';
|
||||||
|
import { getSpaceGradient } from '../../utils/gradients';
|
||||||
|
import type { TaggedExploreSpace } from '../../stores/exploreStore';
|
||||||
|
|
||||||
|
export function ExploreSpacePreviewCard({
|
||||||
|
space,
|
||||||
|
onJoinSuccess,
|
||||||
|
}: {
|
||||||
|
space: TaggedExploreSpace;
|
||||||
|
onJoinSuccess: (spaceId: string) => void;
|
||||||
|
}) {
|
||||||
|
const {
|
||||||
|
isPublic,
|
||||||
|
isPending,
|
||||||
|
joining,
|
||||||
|
joinError,
|
||||||
|
showRequestForm,
|
||||||
|
requestMessage,
|
||||||
|
setRequestMessage,
|
||||||
|
openRequestForm,
|
||||||
|
cancelRequestForm,
|
||||||
|
join,
|
||||||
|
sendRequest,
|
||||||
|
} = useSpaceJoin(space);
|
||||||
|
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
|
||||||
|
const fallbackGradient = getSpaceGradient(space.id, space.name, space.avatarColor).gradient;
|
||||||
|
const iconUrl = space.icon
|
||||||
|
? (space.icon.startsWith('http') || space.icon.startsWith('/') ? space.icon : `/api/uploads/${space.icon}`)
|
||||||
|
: null;
|
||||||
|
const originLabel = space._instanceOrigin
|
||||||
|
? (() => { try { return new URL(space._instanceOrigin).host; } catch { return space._instanceOrigin; } })()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const handleJoin = async () => {
|
||||||
|
const full = await join();
|
||||||
|
if (full) onJoinSuccess(full.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg bg-surface-channel border border-border-soft hover:border-border-hard transition-colors px-3 py-2.5">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{/* Icon */}
|
||||||
|
{iconUrl ? (
|
||||||
|
<img src={iconUrl} alt={space.name} className="w-10 h-10 rounded-lg object-cover flex-shrink-0" />
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="w-10 h-10 rounded-lg flex-shrink-0 flex items-center justify-center text-sm font-bold text-white/90"
|
||||||
|
style={{ background: fallbackGradient }}
|
||||||
|
>
|
||||||
|
{space.name.charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Name + meta */}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="text-sm font-semibold text-txt-primary truncate">{space.name}</div>
|
||||||
|
<div className="flex items-center gap-2 text-[12px] text-txt-tertiary">
|
||||||
|
<span>{space.memberCount} {space.memberCount === 1 ? 'member' : 'members'}</span>
|
||||||
|
{originLabel && <span className="truncate text-txt-tertiary/70">· {originLabel}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action */}
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
{isPublic ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleJoin}
|
||||||
|
disabled={joining}
|
||||||
|
className="px-3 py-1.5 bg-accent-primary hover:bg-accent-primary-hover text-white text-xs font-medium rounded-full transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{joining ? 'Joining…' : 'Join'}
|
||||||
|
</button>
|
||||||
|
) : isPending ? (
|
||||||
|
<span className="px-3 py-1.5 text-xs font-medium text-txt-tertiary">Pending</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { openRequestForm(); setExpanded(true); }}
|
||||||
|
className="px-3 py-1.5 bg-accent-amber/20 hover:bg-accent-amber/30 text-accent-amber text-xs font-medium rounded-full transition-colors"
|
||||||
|
>
|
||||||
|
Request
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Inline request form */}
|
||||||
|
{showRequestForm && expanded && (
|
||||||
|
<div className="mt-2.5 space-y-2">
|
||||||
|
<textarea
|
||||||
|
value={requestMessage}
|
||||||
|
onChange={(e) => setRequestMessage(e.target.value.slice(0, 200))}
|
||||||
|
placeholder="Why do you want to join? (optional)"
|
||||||
|
rows={2}
|
||||||
|
className="input-standard w-full resize-none text-sm"
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={sendRequest}
|
||||||
|
disabled={joining}
|
||||||
|
className="flex-1 py-1.5 bg-accent-amber hover:bg-accent-amber/80 text-[#13131a] text-xs font-medium rounded transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{joining ? 'Sending…' : 'Send Request'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { cancelRequestForm(); setExpanded(false); }}
|
||||||
|
className="px-3 py-1.5 text-xs text-txt-tertiary hover:text-txt-secondary transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{joinError && <div className="mt-2 text-[12px] text-txt-danger">{joinError}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user