diff --git a/bun.lock b/bun.lock index 90831c8..e3a69b4 100644 --- a/bun.lock +++ b/bun.lock @@ -19,6 +19,7 @@ "applesauce-signers": "^5.2.0", "dompurify": "^3.3.3", "lean-qr": "^2.7.1", + "lucide-svelte": "^1.0.1", "marked": "^17.0.5", "mode-watcher": "^1.1.0", "nostr-tools": "^2.23.3", @@ -697,6 +698,8 @@ "lodash.startcase": ["lodash.startcase@4.4.0", "", {}, "sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg=="], + "lucide-svelte": ["lucide-svelte@1.0.1", "", { "peerDependencies": { "svelte": "^3 || ^4 || ^5.0.0-next.42" } }, "sha512-WvzZgk0pqzgda+AErLvgWxHkfg/+GgUwqKMRHvzt0IqyMdmyEDzDCk3Z+Wo/3y753oIgx8u9Q4eUbWkghFa8Jg=="], + "lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="], "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], diff --git a/package.json b/package.json index 2da256c..5cff5d1 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "applesauce-signers": "^5.2.0", "dompurify": "^3.3.3", "lean-qr": "^2.7.1", + "lucide-svelte": "^1.0.1", "marked": "^17.0.5", "mode-watcher": "^1.1.0", "nostr-tools": "^2.23.3", diff --git a/specs.md b/specs.md new file mode 100644 index 0000000..56324f9 --- /dev/null +++ b/specs.md @@ -0,0 +1,132 @@ +# Servers Page Redesign Specs (`/servers`) + +Reference design: Figma file `SlIJqN61r5pGbbLy1w7VC5`, page `final`, frame `Redesigned` ([link](https://www.figma.com/design/SlIJqN61r5pGbbLy1w7VC5/Context-Webpage-by-Varun?node-id=37-687&t=p1oHQuBtrWIUqKC9-1)). + +## Goal + +Implement the redesigned UI/UX for the servers listing experience while preserving existing content and search behavior, and fix the loading flow so cards do not stay in skeleton state indefinitely. + +## Scope + +In scope: +- `src/routes/servers/+page.svelte` +- `src/lib/components/ServerCard.svelte` +- `src/lib/components/LoadingCard.svelte` +- `src/lib/queries/serverQueries.ts` (loading-state reliability) +- `src/lib/stores/relay-store.svelte.ts` (default relay behavior relevant to loading) + +Shared usage impact: +- Any page using `ServerCard` should remain visually coherent after the new compact card layout. + +Out of scope: +- Changing server content fields or text copy. +- Introducing real server health checks. +- Reworking search/filter logic semantics. + +## Content Invariants (Must Stay Constant) + +Keep existing source text/information unchanged: +- Page title, subtitle, SEO title/description. +- Search semantics and identifier resolution behavior. +- Empty/no-result messages and CTA text. +- Server data fields used in cards (`name`, `about`, `created_at`, link destination). + +Only layout/styling/spacing/state presentation may change. + +## Required UI Changes + +### 1) Search Section +- Keep existing search logic and binding to `searchTerm`. +- Update visual styling to redesigned treatment: + - full-width input in the content container + - leading search icon + - compact spacing between search and result count label +- Placeholder remains functionally the same. + +### 2) Count Label +- Place a small left-aligned label directly under search. +- Format: `X Servers available`. +- Use subdued text style (`text-sm text-muted-foreground`). + +### 3) Card Grid + Density +- Responsive layout requirements: + - Mobile (`<640px`): `1` column + - Tablet (`>=640px and <1024px`): `2` columns + - Desktop (`>=1024px`): `3` columns +- Use compact spacing (`gap-3` / equivalent) to match redesigned density. + +### 4) Pagination +- Show first `6` cards by default. +- Add centered button: `Load more servers`. +- Each click increases visible cards by `6`. +- Hide button when all matched cards are shown. +- Applies to normal list and filtered results list. + +### 5) Server Card Layout +- Use redesigned compact layout (no large thumbnail block). +- Keep content fields the same: + - server name + - short description/about + - active since date from `created_at` + - visit-server link/CTA +- Status indicator: + - static green dot and `LIVE` label by default + - no runtime connectivity check yet (future enhancement) +- Keep hover affordance (`Visit server` arrow animation and subtle shadow). + +### 6) Loading Skeleton +- Add/keep a `server-row` skeleton variant that mirrors card structure. +- Use it in all loading states on `/servers`. +- Show `6` skeleton cards for loading placeholders. + +### 7) Empty + Resolve States +- Preserve existing behavior and text for: + - no servers found + - identifier resolution in progress + - resolved single server card + go-link flow +- Ensure these states also follow responsive spacing/layout. + +## Loading Reliability Requirements (Critical Fix) + +The page must not remain indefinitely in skeleton mode when relays are unreachable or no events arrive. + +Required behavior: +- Announcements query should avoid infinite pending state for timeline streams. +- UI loading branch should transition deterministically to one of: + - real cards + - empty/no-data message + - error-safe fallback + +Implementation requirements: +- Use a bounded initial wait for announcements (e.g. timeout) in query flow. +- Ensure loading condition depends on actual initial-load semantics + data presence. +- Avoid indefinite UI-only loading loops. + +## Relay Defaults + +Default selected relays should be production/public relays in development and production unless the user explicitly switches to local dev relay mode. + +Rationale: +- Prevent accidental default to `localhost` relay causing no data and persistent loading. + +## Validation Checklist + +1. Visual parity +- Search width/icon/spacing matches redesigned intent. +- Card grid density and spacing align with redesigned frame. +- Compact card hierarchy reads clearly at all breakpoints. + +2. Responsiveness +- Mobile: 1-column compact cards, full-width controls. +- Tablet: 2-column grid. +- Desktop: 3-column grid. +- No overlap/truncation regressions in card footer/meta row. + +3. Behavior +- Pagination starts at 6 and increments by 6. +- Search logic remains unchanged (only visuals changed). +- Loading skeleton transitions to real/empty states; no stuck infinite skeleton. + +4. Quality checks +- `bun run check` passes. +- Lints for touched files are clean. diff --git a/src/lib/components/LoadingCard.svelte b/src/lib/components/LoadingCard.svelte index 8196f3e..7093251 100644 --- a/src/lib/components/LoadingCard.svelte +++ b/src/lib/components/LoadingCard.svelte @@ -1,5 +1,5 @@ @@ -32,3 +32,27 @@ {/if} + +{#if layout === 'server-row'} +
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+{/if} diff --git a/src/lib/components/ServerCard.svelte b/src/lib/components/ServerCard.svelte index f74b58b..f788d5f 100644 --- a/src/lib/components/ServerCard.svelte +++ b/src/lib/components/ServerCard.svelte @@ -1,6 +1,6 @@ -
- {#if server.picture} -
- {server.name} -
- {:else} -
- {/if} -
-
- - {#if server.supportsEncryption} - - 🔒 - - {/if} -
-

- {server.name} -

+
+
+

{server.name}

{#if server.about} -

+

{truncateString(server.about)}

{/if} -
- Visit server - → +
+
+
+
+ + Live +
+
+ + Visit server → +
diff --git a/src/lib/queries/serverQueries.ts b/src/lib/queries/serverQueries.ts index d3aa462..1608355 100644 --- a/src/lib/queries/serverQueries.ts +++ b/src/lib/queries/serverQueries.ts @@ -12,7 +12,7 @@ import { createServerAnnouncementsLoader } from '$lib/services/loaders.svelte'; import type { Tool, Resource, ResourceTemplate, Prompt } from '@modelcontextprotocol/sdk/types.js'; -import { lastValueFrom } from 'rxjs'; +import { lastValueFrom, type Subscription } from 'rxjs'; import type { NostrEvent } from 'nostr-tools'; import { getSeenRelays, mergeRelaySets } from 'applesauce-core/helpers/relays'; import { relayStore } from '$lib/stores/relay-store.svelte'; @@ -28,6 +28,37 @@ interface ParsedRelayList { hasPublishedRelayList: boolean; } +let serverAnnouncementsSubscription: Subscription | null = null; +let serverAnnouncementsReadyPromise: Promise | null = null; + +function bootstrapServerAnnouncements(): Promise { + if (serverAnnouncementsReadyPromise) return serverAnnouncementsReadyPromise; + + serverAnnouncementsReadyPromise = new Promise((resolve) => { + let settled = false; + const settle = (value: boolean) => { + if (settled) return; + settled = true; + resolve(value); + }; + + // Keep one long-lived stream subscription so the event store can continue + // collecting announcements instead of stopping after the first emission. + serverAnnouncementsSubscription = createServerAnnouncementsLoader().subscribe({ + next: () => settle(true), + error: (error) => { + console.error('Failed to load server announcements stream:', error); + settle(false); + } + }); + + // Unblock UI if no events arrive quickly. + setTimeout(() => settle(false), 8000); + }); + + return serverAnnouncementsReadyPromise; +} + export function useServerAnnouncement(pubkey: string, relayHints: string[] = []) { return createQuery({ queryKey: serverKeys.announcement(pubkey), @@ -242,10 +273,10 @@ async function fetchPromptsFromMCP(pubkey: string): Promise { } export function useServerAnnouncements() { - return createQuery({ + return createQuery({ queryKey: serverKeys.all, queryFn: async () => { - return await lastValueFrom(createServerAnnouncementsLoader()); + return await bootstrapServerAnnouncements(); } }); } diff --git a/src/lib/stores/relay-store.svelte.ts b/src/lib/stores/relay-store.svelte.ts index db90bcd..cee72e1 100644 --- a/src/lib/stores/relay-store.svelte.ts +++ b/src/lib/stores/relay-store.svelte.ts @@ -1,9 +1,10 @@ -import { dev } from '$app/environment'; import { defaultRelays, devRelay } from '../services/relay-pool'; // Reactive relay store using Svelte 5 $state export const relayStore = $state({ - selectedRelays: dev ? devRelay : defaultRelays, + // Default to public relays in all environments. + // Localhost relay can still be enabled explicitly via relayActions.useDevRelay(). + selectedRelays: defaultRelays, relayChangeCallback: null as ((relays: string[]) => void) | null }); diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 61706bf..56fc8d9 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -26,7 +26,7 @@ return $serverAnnouncements?.slice(0, 4) ?? []; }); - const loading = $derived.by(() => $serverAnnouncementsQuery.isFetching); + const loading = $derived.by(() => $serverAnnouncementsQuery.isLoading && latestServers.length === 0); const faqsHref = $derived<`/faqs`>('/faqs'); const aboutHref = $derived<`/about`>('/about'); diff --git a/src/routes/servers/+page.svelte b/src/routes/servers/+page.svelte index f9e81fa..c218364 100644 --- a/src/routes/servers/+page.svelte +++ b/src/routes/servers/+page.svelte @@ -14,13 +14,17 @@ encodeServerIdentity, resolveServerIdentifier } from '$lib/utils'; + import { Search } from 'lucide-svelte'; const serverAnnouncements = eventStore.model(ServerAnnouncementsModel); const serverAnnouncementsQuery = useServerAnnouncements(); - let loading = $state($serverAnnouncementsQuery.isFetching); + const hasServers = $derived(($serverAnnouncements?.length ?? 0) > 0); + const loading = $derived($serverAnnouncementsQuery.isLoading && !hasServers); let searchTerm = $state(''); + let visibleCount = $state(6); + const resolvedSearchIdentifierQuery = $derived.by(() => { const trimmedSearchTerm = searchTerm.trim(); @@ -55,6 +59,15 @@ ); }); + const visibleServers = $derived(filteredServerAnnouncements?.slice(0, visibleCount) ?? []); + const hasMore = $derived((filteredServerAnnouncements?.length ?? 0) > visibleCount); + + $effect(() => { + // Reset pagination when search term changes + searchTerm; + visibleCount = 6; + }); + const decodedSearchIdentifier = $derived( $resolvedSearchIdentifierQuery.data ?? decodeServerIdentifier(searchTerm) ); @@ -87,38 +100,42 @@
-
+
+
{#if filteredServerAnnouncements?.length > 0} -
-

- Available MCP Servers - {#if searchTerm} - - ({filteredServerAnnouncements.length} results) - - {/if} -

-
-
- {#each filteredServerAnnouncements as server (server.id)} -
- -
+ +

+ {filteredServerAnnouncements.length} Servers available +

+ +
+ {#each visibleServers as server (server.id)} + {/each}
+ + {#if hasMore} +
+ +
+ {/if} {:else if searchTerm} {@const decodedIdentifier = decodedSearchIdentifier} {@const resolvedServer = $searchServerQuery?.data?.server} @@ -130,12 +147,10 @@

Resolved a server from this identifier using its relay hints.

-
- -
+ {:else}

No servers found matching "{searchTerm}"

{/if} @@ -146,25 +161,15 @@ {/if}
- {:else} -
- {#each Array(3) as _, i (i)} -
- -
- {/each} -
{/if} {:else if !$serverAnnouncements?.length && !loading}

No MCP servers found. Check back later for server announcements.

{:else if loading} -
- {#each Array(3) as _, i (i)} -
- -
+
+ {#each Array(6) as _, i (i)} + {/each}
{/if}