Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
132 changes: 132 additions & 0 deletions specs.md
Original file line number Diff line number Diff line change
@@ -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.
26 changes: 25 additions & 1 deletion src/lib/components/LoadingCard.svelte
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script lang="ts">
type LayoutType = 'article';
type LayoutType = 'article' | 'server-row';

let { layout = 'article' }: { layout?: LayoutType } = $props();
</script>
Expand Down Expand Up @@ -32,3 +32,27 @@
</div>
</div>
{/if}

{#if layout === 'server-row'}
<div
class="group block h-full min-h-[220px] overflow-hidden rounded-lg border border-border bg-card p-6 transition-all sm:min-h-[250px] lg:min-h-[282px] lg:p-8"
>
<div class="flex h-full min-w-0 flex-col justify-between gap-6">
<div class="space-y-4">
<div class="h-6 w-1/3 animate-pulse rounded bg-muted-foreground/40"></div>
<div class="h-4 w-full animate-pulse rounded bg-muted-foreground/30"></div>
<div class="h-4 w-11/12 animate-pulse rounded bg-muted-foreground/30"></div>
</div>
<div class="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div class="space-y-1">
<div class="flex items-center gap-2">
<span class="h-2 w-2 flex-shrink-0 rounded-full bg-green-500/30"></span>
<div class="h-3 w-10 animate-pulse rounded bg-muted-foreground/30"></div>
</div>
<div class="h-3 w-28 animate-pulse rounded bg-muted-foreground/30"></div>
</div>
<div class="h-3 w-20 animate-pulse rounded bg-muted-foreground/30"></div>
</div>
</div>
</div>
{/if}
65 changes: 27 additions & 38 deletions src/lib/components/ServerCard.svelte
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { formatUnixTimestamp, pubkeyToHexColor, truncateString } from '$lib/utils';
import { truncateString } from '$lib/utils';
import type { ServerAnnouncement } from '$lib/models/serverAnnouncements';

let {
Expand All @@ -11,54 +11,43 @@
serverIdentifier?: string;
} = $props();

const date = $derived(formatUnixTimestamp(server.created_at, true));
const activeSinceDate = $derived.by(() => {
const d = new Date(server.created_at * 1000);
const day = d.getDate();
const month = d.getMonth() + 1;
const year = d.getFullYear().toString().slice(-2);
return `${day}.${month}.${year}`;
});
const activeSinceDateTime = $derived.by(() => new Date(server.created_at * 1000).toISOString());
const serverHref = $derived<`/s/${string}`>(`/s/${serverIdentifier ?? server.pubkey}`);
</script>

<a
href={resolve(serverHref)}
class="group block h-full overflow-hidden rounded-lg border border-border bg-card transition-all hover:shadow-md hover:shadow-primary/10"
class="group block h-full min-h-[220px] overflow-hidden rounded-lg border border-border bg-card p-6 transition-all duration-200 hover:scale-[1.02] hover:shadow-lg hover:shadow-primary/20 active:scale-[0.98] active:shadow-sm sm:min-h-[250px] lg:min-h-[282px] lg:p-8"
>
<div class="grid h-full grid-rows-[auto_auto_auto_1fr]">
{#if server.picture}
<div class="aspect-video overflow-hidden bg-muted">
<img
src={server.picture}
alt={server.name}
class="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
/>
</div>
{:else}
<div
class="flex aspect-video items-center justify-center overflow-hidden bg-muted"
style="background-color: {pubkeyToHexColor(server.pubkey)}"
></div>
{/if}
<div class="p-6">
<div class="mb-2 flex items-center justify-between text-sm text-muted-foreground">
<time datetime={formatUnixTimestamp(server.created_at, true)}>{date}</time>
{#if server.supportsEncryption}
<span
class="rounded-full bg-green-100 px-2 py-1 text-xs font-medium text-green-800 dark:bg-green-900 dark:text-green-200"
>
🔒
</span>
{/if}
</div>
<h3
class="mb-2 text-xl font-semibold tracking-tight transition-colors group-hover:text-primary md:text-2xl"
>
{server.name}
</h3>
<div class="flex h-full min-w-0 flex-col justify-between gap-6">
<div class="min-w-0 space-y-4">
<h3 class="truncate text-xl font-semibold tracking-tight">{server.name}</h3>
{#if server.about}
<p class=" text-sm text-muted-foreground">
<p class="line-clamp-3 text-sm text-muted-foreground">
{truncateString(server.about)}
</p>
{/if}
<div class="flex items-center text-sm font-medium text-primary">
Visit server
<span class="ml-1 transition-transform group-hover:translate-x-1">→</span>
</div>
<div class="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div class="flex min-w-0 flex-col gap-1">
<div class="flex items-center gap-2">
<span class="h-2 w-2 flex-shrink-0 rounded-full bg-green-500"></span>
<span class="text-xs uppercase tracking-wide text-muted-foreground">Live</span>
</div>
<time class="text-xs tracking-wide text-muted-foreground" datetime={activeSinceDateTime}>
Active since {activeSinceDate}
</time>
</div>
<span class="inline-flex items-center gap-1 whitespace-nowrap text-sm font-medium text-primary sm:self-end">
Visit server <span class="inline-block transition-transform group-hover:translate-x-1">→</span>
</span>
</div>
</div>
</a>
37 changes: 34 additions & 3 deletions src/lib/queries/serverQueries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -28,6 +28,37 @@ interface ParsedRelayList {
hasPublishedRelayList: boolean;
}

let serverAnnouncementsSubscription: Subscription | null = null;
let serverAnnouncementsReadyPromise: Promise<boolean> | null = null;

function bootstrapServerAnnouncements(): Promise<boolean> {
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<ServerQueryResult | null>({
queryKey: serverKeys.announcement(pubkey),
Expand Down Expand Up @@ -242,10 +273,10 @@ async function fetchPromptsFromMCP(pubkey: string): Promise<Prompt[]> {
}

export function useServerAnnouncements() {
return createQuery<NostrEvent>({
return createQuery<boolean>({
queryKey: serverKeys.all,
queryFn: async () => {
return await lastValueFrom(createServerAnnouncementsLoader());
return await bootstrapServerAnnouncements();
}
});
}
Expand Down
5 changes: 3 additions & 2 deletions src/lib/stores/relay-store.svelte.ts
Original file line number Diff line number Diff line change
@@ -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
});

Expand Down
2 changes: 1 addition & 1 deletion src/routes/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
Loading