frontend: list all profiles on Home, pin dev host, widen profiles card
- Home now shows every profile with its picture, an inline Active badge, and a working Select button (was only the active profile). - Remove the Relays card from Home (status lives on the Relays screen) and its unused connection-check logic/test. - Pin Vite dev server to 127.0.0.1 so Electron and the dev server agree on the address (fixes white-box window in dev mode). - Make the profiles card full-width to match the publication card below. - Badge now accepts className.
This commit is contained in:
parent
71e580f474
commit
835b0a02fd
5 changed files with 130 additions and 152 deletions
|
|
@ -2,6 +2,16 @@ import type { ReactNode } from 'react';
|
||||||
|
|
||||||
export type BadgeTone = 'neutral' | 'success' | 'warning' | 'danger' | 'info';
|
export type BadgeTone = 'neutral' | 'success' | 'warning' | 'danger' | 'info';
|
||||||
|
|
||||||
export function Badge({ tone = 'neutral', children }: { tone?: BadgeTone; children: ReactNode }) {
|
export function Badge({
|
||||||
return <span className={`badge badge-${tone}`}>{children}</span>;
|
tone = 'neutral',
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
tone?: BadgeTone;
|
||||||
|
className?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<span className={`badge badge-${tone}${className ? ` ${className}` : ''}`}>{children}</span>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Alert } from '../components/Alert';
|
import { Alert } from '../components/Alert';
|
||||||
import { Avatar } from '../components/Avatar';
|
import { Avatar } from '../components/Avatar';
|
||||||
import { Badge } from '../components/Badge';
|
import { Badge } from '../components/Badge';
|
||||||
|
|
@ -6,67 +6,27 @@ import { Button } from '../components/Button';
|
||||||
import { CopyButton } from '../components/CopyButton';
|
import { CopyButton } from '../components/CopyButton';
|
||||||
import { EmptyState } from '../components/EmptyState';
|
import { EmptyState } from '../components/EmptyState';
|
||||||
import { Icon } from '../components/Icon';
|
import { Icon } from '../components/Icon';
|
||||||
import { Spinner } from '../components/Spinner';
|
|
||||||
import { shortenNpub } from '../lib/format';
|
import { shortenNpub } from '../lib/format';
|
||||||
import type { Screen } from '../lib/navigation';
|
import type { Screen } from '../lib/navigation';
|
||||||
import { useApp } from '../state/AppProvider';
|
import { useApp } from '../state/AppProvider';
|
||||||
|
|
||||||
type RelayStatus = 'checking' | 'connected' | 'failed';
|
|
||||||
|
|
||||||
interface HomeScreenProps {
|
interface HomeScreenProps {
|
||||||
onNavigate: (screen: Screen) => void;
|
onNavigate: (screen: Screen) => void;
|
||||||
onCreateProfile: () => void;
|
onCreateProfile: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function HomeScreen({ onNavigate, onCreateProfile }: HomeScreenProps) {
|
export function HomeScreen({ onNavigate, onCreateProfile }: HomeScreenProps) {
|
||||||
const { state, relayTest, lastPublish } = useApp();
|
const { state, lastPublish, selectProfile } = useApp();
|
||||||
const [statuses, setStatuses] = useState<Record<string, { state: RelayStatus; detail?: string }>>(
|
const [selecting, setSelecting] = useState<string | null>(null);
|
||||||
{},
|
|
||||||
);
|
|
||||||
const [testing, setTesting] = useState(false);
|
|
||||||
|
|
||||||
const enabled = useMemo(
|
const onSelect = async (npub: string) => {
|
||||||
() => state?.settings.relays.filter((r) => r.enabled) ?? [],
|
setSelecting(npub);
|
||||||
[state?.settings.relays],
|
|
||||||
);
|
|
||||||
|
|
||||||
const runCheck = useCallback(async () => {
|
|
||||||
setTesting(true);
|
|
||||||
const entries: Record<string, { state: RelayStatus; detail?: string }> = {};
|
|
||||||
for (const relay of enabled) {
|
|
||||||
entries[relay.url] = { state: 'checking' };
|
|
||||||
}
|
|
||||||
setStatuses({ ...entries });
|
|
||||||
await Promise.all(
|
|
||||||
enabled.map(async (relay) => {
|
|
||||||
try {
|
try {
|
||||||
const result = await relayTest(relay.url);
|
await selectProfile(npub);
|
||||||
entries[relay.url] = {
|
} finally {
|
||||||
state: 'connected',
|
setSelecting(null);
|
||||||
detail: result.latency_ms != null ? `${result.latency_ms} ms` : 'connected',
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
entries[relay.url] = {
|
|
||||||
state: 'failed',
|
|
||||||
detail: error instanceof Error ? error.message : String(error),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}),
|
};
|
||||||
);
|
|
||||||
setStatuses({ ...entries });
|
|
||||||
setTesting(false);
|
|
||||||
}, [enabled, relayTest]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (enabled.length > 0) {
|
|
||||||
void runCheck();
|
|
||||||
} else {
|
|
||||||
setStatuses({});
|
|
||||||
}
|
|
||||||
}, [enabled, runCheck]);
|
|
||||||
|
|
||||||
const connectedCount = Object.values(statuses).filter((s) => s.state === 'connected').length;
|
|
||||||
const failedCount = Object.values(statuses).filter((s) => s.state === 'failed').length;
|
|
||||||
|
|
||||||
const active = state?.active_profile ?? null;
|
const active = state?.active_profile ?? null;
|
||||||
const shorten = state?.settings.shorten_npub ?? true;
|
const shorten = state?.settings.shorten_npub ?? true;
|
||||||
|
|
@ -119,94 +79,72 @@ export function HomeScreen({ onNavigate, onCreateProfile }: HomeScreenProps) {
|
||||||
<div className="home-grid">
|
<div className="home-grid">
|
||||||
<section className="card">
|
<section className="card">
|
||||||
<header className="card-header">
|
<header className="card-header">
|
||||||
<h2>Active profile</h2>
|
<h2>Your profiles</h2>
|
||||||
</header>
|
|
||||||
<div className="card-body">
|
|
||||||
{active ? (
|
|
||||||
<div className="active-profile-row">
|
|
||||||
<Avatar npub={active.npub} label={active.label} size="lg" />
|
|
||||||
<div className="active-profile-meta">
|
|
||||||
<span className="profile-name">{active.label}</span>
|
|
||||||
<code className="mono" title={active.npub}>
|
|
||||||
{shortenNpub(active.npub, shorten)}
|
|
||||||
</code>
|
|
||||||
</div>
|
|
||||||
<CopyButton text={active.npub} label="full npub" />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="muted">
|
|
||||||
No profile selected.{' '}
|
|
||||||
<button type="button" className="linklike" onClick={() => onNavigate('profiles')}>
|
|
||||||
Choose a profile
|
|
||||||
</button>{' '}
|
|
||||||
to get started.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="card">
|
|
||||||
<header className="card-header">
|
|
||||||
<h2>Relays</h2>
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => void runCheck()}
|
onClick={() => onNavigate('profiles')}
|
||||||
loading={testing}
|
aria-label="Manage profiles"
|
||||||
disabled={enabled.length === 0}
|
|
||||||
>
|
>
|
||||||
<Icon name="refresh" size={16} />
|
<Icon name="users" size={16} />
|
||||||
Refresh
|
Manage
|
||||||
</Button>
|
</Button>
|
||||||
</header>
|
</header>
|
||||||
<div className="card-body">
|
<div className="card-body">
|
||||||
{enabled.length === 0 ? (
|
<ul className="home-profile-list">
|
||||||
<p className="muted">
|
{state?.profiles.map((profile) => (
|
||||||
No relays enabled.{' '}
|
<li
|
||||||
<button type="button" className="linklike" onClick={() => onNavigate('relays')}>
|
key={profile.npub}
|
||||||
Enable a relay
|
className={`home-profile-row${profile.is_active ? ' is-active' : ''}`}
|
||||||
</button>{' '}
|
>
|
||||||
to publish notes.
|
<Avatar
|
||||||
</p>
|
npub={profile.npub}
|
||||||
) : testing && Object.keys(statuses).length === 0 ? (
|
label={profile.label}
|
||||||
<Spinner label="Testing connections…" />
|
size="lg"
|
||||||
) : (
|
picture={profile.picture}
|
||||||
<ul className="relay-status-list">
|
|
||||||
{enabled.map((relay) => {
|
|
||||||
const status = statuses[relay.url];
|
|
||||||
return (
|
|
||||||
<li key={relay.url} className="relay-status-row">
|
|
||||||
<span
|
|
||||||
className={`status-dot ${
|
|
||||||
status?.state === 'connected'
|
|
||||||
? 'is-ok'
|
|
||||||
: status?.state === 'failed'
|
|
||||||
? 'is-bad'
|
|
||||||
: 'is-pending'
|
|
||||||
}`}
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
/>
|
||||||
<span className="mono relay-url">{relay.url}</span>
|
<div className="active-profile-meta">
|
||||||
<span className="relay-status-text">
|
<span className="profile-name">
|
||||||
{status?.state === 'connected'
|
{profile.label}
|
||||||
? `Connected${status.detail ? ` · ${status.detail}` : ''}`
|
{profile.is_active && (
|
||||||
: status?.state === 'failed'
|
<Badge tone="success" className="home-active-badge">
|
||||||
? 'Unavailable'
|
Active
|
||||||
: 'Checking…'}
|
</Badge>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
|
<code className="mono" title={profile.npub}>
|
||||||
|
{shortenNpub(profile.npub, shorten)}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
<div className="home-profile-actions">
|
||||||
|
<CopyButton text={profile.npub} label="full npub" />
|
||||||
|
{profile.is_active ? (
|
||||||
|
<Button variant="secondary" size="sm" disabled>
|
||||||
|
Selected
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
loading={selecting === profile.npub}
|
||||||
|
onClick={() => void onSelect(profile.npub)}
|
||||||
|
>
|
||||||
|
Select
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</li>
|
</li>
|
||||||
);
|
))}
|
||||||
})}
|
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
<Button
|
||||||
{failedCount > 0 &&
|
variant="primary"
|
||||||
!testing &&
|
size="sm"
|
||||||
enabled.length > 0 &&
|
onClick={onCreateProfile}
|
||||||
Object.keys(statuses).length === enabled.length && (
|
className="home-add-profile"
|
||||||
<p className="muted small">
|
>
|
||||||
{connectedCount} of {enabled.length} enabled relays reachable.
|
<Icon name="plus" size={16} />
|
||||||
</p>
|
Add profile
|
||||||
)}
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1003,14 +1003,8 @@ select {
|
||||||
|
|
||||||
.home-grid {
|
.home-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
|
||||||
.home-grid {
|
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
gap: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.active-profile-row {
|
.active-profile-row {
|
||||||
|
|
@ -1067,6 +1061,50 @@ select {
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.home-profile-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-profile-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-profile-row.is-active {
|
||||||
|
background: var(--token-item-bg, rgba(0, 0, 0, 0.04));
|
||||||
|
border-color: var(--token-border, rgba(0, 0, 0, 0.12));
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-profile-row .active-profile-meta {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-profile-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-active-badge {
|
||||||
|
margin-left: 8px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-add-profile {
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
.relay-status-list {
|
.relay-status-list {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { screen, waitFor } from '@testing-library/react';
|
import { screen, waitFor, within } from '@testing-library/react';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import { HomeScreen } from '../screens/HomeScreen';
|
import { HomeScreen } from '../screens/HomeScreen';
|
||||||
import { renderWithApp } from './render';
|
import { renderWithApp } from './render';
|
||||||
|
|
@ -36,8 +36,8 @@ describe('HomeScreen', () => {
|
||||||
renderHome(backend);
|
renderHome(backend);
|
||||||
renderWithApp(<HomeScreen onNavigate={vi.fn()} onCreateProfile={vi.fn()} />);
|
renderWithApp(<HomeScreen onNavigate={vi.fn()} onCreateProfile={vi.fn()} />);
|
||||||
|
|
||||||
await screen.findByText('Alice');
|
const aliceRow = (await screen.findByText('Alice')).closest('.home-profile-row') as HTMLElement;
|
||||||
await userEvent.setup().click(screen.getByRole('button', { name: 'Copy full npub' }));
|
await userEvent.setup().click(within(aliceRow).getByRole('button', { name: 'Copy full npub' }));
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(backend.copied).toContain(ALICE);
|
expect(backend.copied).toContain(ALICE);
|
||||||
});
|
});
|
||||||
|
|
@ -54,13 +54,4 @@ describe('HomeScreen', () => {
|
||||||
.click(screen.getByRole('button', { name: /Create your first profile/i }));
|
.click(screen.getByRole('button', { name: /Create your first profile/i }));
|
||||||
expect(onCreateProfile).toHaveBeenCalled();
|
expect(onCreateProfile).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows live relay connection status from the backend', async () => {
|
|
||||||
const backend = createFakeBackend();
|
|
||||||
renderHome(backend);
|
|
||||||
renderWithApp(<HomeScreen onNavigate={vi.fn()} onCreateProfile={vi.fn()} />);
|
|
||||||
|
|
||||||
expect(await screen.findAllByText(/Connected/)).not.toHaveLength(0);
|
|
||||||
expect(screen.getAllByText('wss://relay.damus.io')).not.toHaveLength(0);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import type { UserConfig } from 'vite';
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
server: {
|
server: {
|
||||||
|
host: '127.0.0.1',
|
||||||
port: 5173,
|
port: 5173,
|
||||||
strictPort: true,
|
strictPort: true,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue