Browse Source

bug-fixes, new anon style

master
Silberengel 1 month ago
parent
commit
084b223a49
  1. 36
      src/app.css
  2. 12
      src/lib/components/content/GifPicker.svelte
  3. 68
      src/lib/components/content/MarkdownRenderer.svelte
  4. 16
      src/lib/components/layout/Header.svelte
  5. 28
      src/lib/modules/feed/FeedPage.svelte
  6. 3
      src/lib/services/nostr/config.ts
  7. 10
      src/lib/services/nostr/event-index-loader.ts
  8. 35
      src/lib/services/nostr/nostr-client.ts
  9. 18
      src/lib/services/nostr/relay-manager.ts
  10. 2
      src/routes/+page.svelte
  11. 2
      src/routes/cache/+page.svelte
  12. 19
      src/routes/feed/+page.svelte
  13. 270
      src/routes/find/+page.svelte
  14. 316
      src/routes/relay/+page.svelte
  15. 232
      src/routes/topics/+page.svelte
  16. 28
      src/routes/write/+page.svelte

36
src/app.css

@ -67,12 +67,46 @@ body { @@ -67,12 +67,46 @@ body {
background-color: #f1f5f9;
color: #475569; /* WCAG AA compliant: 5.2:1 contrast ratio */
transition: background-color 0.3s ease, color 0.3s ease;
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', 'Droid Sans Mono', 'Source Code Pro', monospace;
}
/* Secret supercoder vibe - subtle terminal aesthetic */
body::before {
content: '';
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background:
repeating-linear-gradient(
0deg,
transparent,
transparent 2px,
rgba(0, 0, 0, 0.03) 2px,
rgba(0, 0, 0, 0.03) 4px
);
pointer-events: none;
z-index: 9999;
opacity: 0.5;
}
.dark body::before {
background:
repeating-linear-gradient(
0deg,
transparent,
transparent 2px,
rgba(255, 255, 255, 0.02) 2px,
rgba(255, 255, 255, 0.02) 4px
);
}
/* Dark mode body styles */
.dark body {
background-color: #0f172a;
background-color: #0a0e1a;
color: #cbd5e1; /* WCAG AA compliant: 13.5:1 contrast ratio */
text-shadow: 0 0 1px rgba(0, 255, 0, 0.1);
}
/* Fog aesthetic base styles */

12
src/lib/components/content/GifPicker.svelte

@ -274,10 +274,8 @@ @@ -274,10 +274,8 @@
let errorCount = 0;
try {
const relays = relayManager.getPublishRelays(
[...relayManager.getThreadReadRelays(), ...relayManager.getFeedReadRelays()],
true
);
// For kind 1063, use file metadata publish relays (includes GIF relays)
const relays = relayManager.getFileMetadataPublishRelays();
// Process each selected file
for (const file of Array.from(files)) {
@ -408,10 +406,8 @@ @@ -408,10 +406,8 @@
uploadError = null;
try {
const relays = relayManager.getPublishRelays(
[...relayManager.getThreadReadRelays(), ...relayManager.getFeedReadRelays()],
true
);
// For kind 1063, use file metadata publish relays (includes GIF relays)
const relays = relayManager.getFileMetadataPublishRelays();
// Build tags array with metadata
const tags: string[][] = [

68
src/lib/components/content/MarkdownRenderer.svelte

@ -221,10 +221,38 @@ @@ -221,10 +221,38 @@
});
}
// Convert greentext (>text with no space) to styled spans
function convertGreentext(text: string): string {
// Split by lines and process each line
const lines = text.split('\n');
const processedLines = lines.map(line => {
// Check if line starts with > followed immediately by non-whitespace (greentext)
// Must match: >text (no space after >)
// Must NOT match: > text (space after >, normal blockquote)
// Also handle HTML-escaped > (>)
const greentextPattern = /^(>|>)([^\s>].*)$/;
const match = line.match(greentextPattern);
if (match) {
// This is greentext - wrap in span with greentext class
// Use > character (not >) since we're inserting HTML
const greentextContent = escapeHtml(match[2]);
return `<span class="greentext">&gt;${greentextContent}</span>`;
}
return line;
});
return processedLines.join('\n');
}
// Process content: replace nostr URIs with HTML span elements and convert media URLs
function processContent(text: string): string {
// First, replace emoji shortcodes with images if resolved
let processed = replaceEmojis(text);
// First, convert greentext (must be before markdown processing)
let processed = convertGreentext(text);
// Then, replace emoji shortcodes with images if resolved
processed = replaceEmojis(processed);
// Convert hashtags to links
processed = convertHashtags(processed);
@ -368,6 +396,20 @@ @@ -368,6 +396,20 @@
}
});
// Post-process HTML to convert blockquotes that are actually greentext
function postProcessGreentext(html: string): string {
// Find blockquotes that match greentext pattern (>text with no space)
// These are blockquotes that markdown created from greentext lines
// Pattern: <blockquote><p>&gt;text</p></blockquote> where there's no space after &gt;
const greentextBlockquotePattern = /<blockquote[^>]*>\s*<p[^>]*>&gt;([^\s<].*?)<\/p>\s*<\/blockquote>/g;
return html.replace(greentextBlockquotePattern, (match, content) => {
// Convert to greentext span
const escapedContent = escapeHtml(content);
return `<span class="greentext">&gt;${escapedContent}</span>`;
});
}
// Render markdown or AsciiDoc to HTML
function renderMarkdown(text: string): string {
if (!content) return '';
@ -394,6 +436,9 @@ @@ -394,6 +436,9 @@
}
}
// Post-process to fix any greentext that markdown converted to blockquotes
html = postProcessGreentext(html);
// Sanitize HTML (but preserve our data attributes and image src)
const sanitized = sanitizeMarkdown(html);
@ -691,6 +736,25 @@ @@ -691,6 +736,25 @@
color: var(--fog-dark-text-light, #9ca3af);
}
/* Greentext styling - 4chan style */
:global(.markdown-content .greentext) {
color: #789922;
display: block;
margin: 0.25rem 0;
font-family: inherit;
}
:global(.dark .markdown-content .greentext) {
color: #8fbc8f;
}
/* Ensure greentext lines appear on their own line even if markdown processes them */
:global(.markdown-content p .greentext),
:global(.markdown-content .greentext) {
display: block;
margin: 0.25rem 0;
}
/* Profile badges in markdown content should align with text baseline */
:global(.markdown-content [data-nostr-profile]),
:global(.markdown-content .profile-badge) {

16
src/lib/components/layout/Header.svelte

@ -34,14 +34,20 @@ @@ -34,14 +34,20 @@
<!-- Navigation -->
<nav class="bg-fog-surface/95 dark:bg-fog-dark-surface/95 backdrop-blur-sm border-b border-fog-border dark:border-fog-dark-border px-4 py-3">
<div class="flex flex-wrap items-center justify-between gap-2 max-w-7xl mx-auto">
<div class="flex flex-wrap gap-2 sm:gap-4 items-center text-sm">
<div class="flex flex-wrap gap-2 sm:gap-4 items-center text-sm font-mono">
<a href="/" class="text-xl font-semibold text-fog-text dark:text-fog-dark-text hover:text-fog-text-light dark:hover:text-fog-dark-text-light transition-colors">Aitherboard</a>
<a href="/" class="text-fog-text dark:text-fog-dark-text hover:text-fog-accent dark:hover:text-fog-dark-accent transition-colors">Discussions</a>
<a href="/feed" class="text-fog-text dark:text-fog-dark-text hover:text-fog-accent dark:hover:text-fog-dark-accent transition-colors">Feed</a>
<a href="/" class="text-fog-text dark:text-fog-dark-text hover:text-fog-accent dark:hover:text-fog-dark-accent transition-colors">/Discussions</a>
<a href="/feed" class="text-fog-text dark:text-fog-dark-text hover:text-fog-accent dark:hover:text-fog-dark-accent transition-colors">/Feed</a>
{#if isLoggedIn}
<a href="/write" class="text-fog-text dark:text-fog-dark-text hover:text-fog-accent dark:hover:text-fog-dark-accent transition-colors">/Write</a>
{/if}
<a href="/find" class="text-fog-text dark:text-fog-dark-text hover:text-fog-accent dark:hover:text-fog-dark-accent transition-colors">/Find</a>
{#if isLoggedIn}
<a href="/write" class="text-fog-text dark:text-fog-dark-text hover:text-fog-accent dark:hover:text-fog-dark-accent transition-colors">Write</a>
<a href="/rss" class="text-fog-text dark:text-fog-dark-text hover:text-fog-accent dark:hover:text-fog-dark-accent transition-colors">RSS</a>
<a href="/rss" class="text-fog-text dark:text-fog-dark-text hover:text-fog-accent dark:hover:text-fog-dark-accent transition-colors">/RSS</a>
{/if}
<a href="/relay" class="text-fog-text dark:text-fog-dark-text hover:text-fog-accent dark:hover:text-fog-dark-accent transition-colors">/Relay</a>
<a href="/topics" class="text-fog-text dark:text-fog-dark-text hover:text-fog-accent dark:hover:text-fog-dark-accent transition-colors">/Topics</a>
<a href="/cache" class="text-fog-text dark:text-fog-dark-text hover:text-fog-accent dark:hover:text-fog-dark-accent transition-colors">/Cache</a>
</div>
<div class="flex flex-wrap gap-2 sm:gap-4 items-center text-sm">
{#if isLoggedIn && currentPubkey}

28
src/lib/modules/feed/FeedPage.svelte

@ -361,6 +361,19 @@ @@ -361,6 +361,19 @@
// Use single relay if provided, otherwise use normal relay list
const relays = singleRelay ? [singleRelay] : relayManager.getFeedReadRelays();
// For single relay mode, ensure the relay is connected first
if (singleRelay) {
console.log(`[FeedPage] Single relay mode: ensuring ${singleRelay} is connected...`);
try {
// Force connection to the relay
await nostrClient.addRelay(singleRelay);
// Give it a moment to establish connection
await new Promise(resolve => setTimeout(resolve, 500));
} catch (error) {
console.warn(`[FeedPage] Failed to connect to relay ${singleRelay}:`, error);
}
}
// For single relay mode, disable cache completely
const useCache = !singleRelay;
const cacheResults = !singleRelay;
@ -374,23 +387,26 @@ @@ -374,23 +387,26 @@
useCache, // Disable cache for single relay mode
cacheResults, // Don't cache results for single relay mode
// Don't use onUpdate here - subscriptions handle updates
timeout: 10000
timeout: 15000 // Longer timeout for single relay mode
}
);
console.log(`[FeedPage] Loaded ${events.length} events from ${singleRelay ? 'single relay' : 'relays'}`);
// Also immediately query relays to ensure we get fresh data in background
// Skip this for single relay mode
if (!singleRelay) {
// For single relay mode, also do a background query to ensure we get results
if (!singleRelay || events.length === 0) {
nostrClient.fetchEvents(
filters,
relays,
{
useCache: false, // Force query relays
cacheResults: true,
cacheResults: !singleRelay, // Don't cache for single relay mode
// Don't use onUpdate - let subscriptions handle it
timeout: 10000
timeout: 15000
}
).then((newEvents) => {
console.log(`[FeedPage] Background query returned ${newEvents.length} events`);
// Only update if we got new events that aren't already in posts
if (newEvents.length > 0) {
const existingIds = new Set(posts.map(p => p.id));
@ -400,7 +416,7 @@ @@ -400,7 +416,7 @@
}
}
}).catch(error => {
console.debug('[FeedPage] Background relay query error:', error);
console.warn('[FeedPage] Background relay query error:', error);
});
}

3
src/lib/services/nostr/config.ts

@ -26,7 +26,8 @@ const THREAD_PUBLISH_RELAYS = [ @@ -26,7 +26,8 @@ const THREAD_PUBLISH_RELAYS = [
const GIF_RELAYS = [
"wss://relay.damus.io",
"wss://relay.primal.net",
"wss://relay.gifbuddy.lol"
"wss://relay.gifbuddy.lol",
"wss://thecitadel.nostr1.com"
];
const RELAY_TIMEOUT = 10000;

10
src/lib/services/nostr/event-index-loader.ts

@ -76,15 +76,7 @@ export async function loadEventIndex(opEvent: NostrEvent): Promise<EventIndexIte @@ -76,15 +76,7 @@ export async function loadEventIndex(opEvent: NostrEvent): Promise<EventIndexIte
const dTag = parts[2];
if (!isNaN(kind) && pubkey && dTag) {
// Check cache first
const cached = await getEvent(aTag);
if (cached) {
items.push({ event: cached, order: eTags.length + i });
loadedAddresses.add(aTag);
continue;
}
// Fetch from relays
// Fetch from relays (cache is checked inside fetchEvents)
const events = await nostrClient.fetchEvents(
[{ kinds: [kind], authors: [pubkey], '#d': [dTag], limit: 1 }],
relays,

35
src/lib/services/nostr/nostr-client.ts

@ -1004,21 +1004,48 @@ class NostrClient { @@ -1004,21 +1004,48 @@ class NostrClient {
// Like jumble, we gracefully handle failures - addRelay doesn't throw, it just doesn't add failed relays
const relaysToConnect = availableRelays.filter(url => !this.relays.has(url));
if (relaysToConnect.length > 0) {
if (relays.length === 1) {
console.log(`[nostr-client] Attempting to connect to relay ${relaysToConnect[0]}...`);
}
await Promise.allSettled(
relaysToConnect.map(url => this.addRelay(url))
);
// For single relay, wait a bit longer for connection to establish
if (relays.length === 1 && relaysToConnect.length > 0) {
await new Promise(resolve => setTimeout(resolve, 1000));
}
}
// Get list of actually connected relays
const connectedRelays = availableRelays.filter(url => this.relays.has(url));
if (connectedRelays.length === 0) {
// Only log at debug level to reduce console noise - cache will be used instead
console.debug(`[nostr-client] No connected relays available for fetch (${relays.length} requested, all failed or unavailable), will use cache if available`);
// Log at warn level for single relay queries (more important to know about failures)
const logLevel = relays.length === 1 ? 'warn' : 'debug';
const message = `[nostr-client] No connected relays available for fetch (${relays.length} requested: ${relays.join(', ')}, all failed or unavailable)`;
if (logLevel === 'warn') {
console.warn(message);
// For single relay, also log which relays were attempted and failure info
if (relays.length === 1) {
const failureInfo = this.failedRelays.get(relays[0]);
if (failureInfo) {
console.warn(`[nostr-client] Relay ${relays[0]} failure info:`, failureInfo);
}
if (relaysToConnect.length > 0) {
console.warn(`[nostr-client] Attempted to connect to: ${relaysToConnect.join(', ')}`);
}
console.warn(`[nostr-client] Available relays (after filtering): ${availableRelays.join(', ')}`);
console.warn(`[nostr-client] Currently connected relays: ${Array.from(this.relays.keys()).join(', ')}`);
}
} else {
console.debug(message);
}
return [];
}
// Only log if we're missing a significant number of relays
if (connectedRelays.length < relays.length * 0.5) {
// Log connection status for single relay queries
if (relays.length === 1 && connectedRelays.length === 1) {
console.log(`[nostr-client] Successfully connected to relay ${relays[0]}, fetching events...`);
} else if (connectedRelays.length < relays.length * 0.5) {
console.debug(`[nostr-client] Fetching from ${connectedRelays.length} connected relay(s) out of ${relays.length} requested`);
}

18
src/lib/services/nostr/relay-manager.ts

@ -302,6 +302,24 @@ class RelayManager { @@ -302,6 +302,24 @@ class RelayManager {
return this.getPublishRelays(config.defaultRelays);
}
/**
* Get relays for publishing file metadata (kind 1063)
* Includes GIF relays in addition to normal publish relays
*/
getFileMetadataPublishRelays(): string[] {
// Start with normal publish relays
let relays = this.getPublishRelays(config.defaultRelays);
// Add GIF relays
relays = [...relays, ...config.gifRelays];
// Normalize and deduplicate
relays = this.normalizeRelays(relays);
// Filter blocked relays
return this.filterBlocked(relays);
}
/**
* Update blocked relays (called when user preferences change)
*/

2
src/routes/+page.svelte

@ -15,7 +15,7 @@ @@ -15,7 +15,7 @@
<main class="container mx-auto px-4 py-8">
<div class="discussions-header mb-4">
<div>
<h1 class="text-2xl font-bold mb-4 text-fog-text dark:text-fog-dark-text">Discussions</h1>
<h1 class="text-2xl font-bold mb-4 text-fog-text dark:text-fog-dark-text font-mono">/Discussions</h1>
<p class="mb-4 text-fog-text dark:text-fog-dark-text">Decentralized discussion board on Nostr. Brought to you by <a href="/profile/fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1" class="text-fog-accent dark:text-fog-dark-accent hover:text-fog-text dark:hover:text-fog-dark-text underline transition-colors">Silberengel</a>.</p>
</div>
<a href="/write?kind=11" class="write-button" title="Write a new thread">

2
src/routes/cache/+page.svelte vendored

@ -425,7 +425,7 @@ @@ -425,7 +425,7 @@
<main class="container mx-auto px-4 py-8">
<div class="cache-page">
<h1 class="page-title">Cache Manager</h1>
<h1 class="page-title font-mono">/Cache</h1>
{#if loading && !stats}
<div class="loading-state">

19
src/routes/feed/+page.svelte

@ -14,12 +14,15 @@ @@ -14,12 +14,15 @@
<main class="container mx-auto px-4 py-8">
<div class="feed-header mb-6">
<div class="search-section">
<SearchBox />
<h1 class="text-2xl font-bold text-fog-text dark:text-fog-dark-text font-mono">/Feed</h1>
<div class="feed-controls">
<div class="search-section">
<SearchBox />
</div>
<a href="/write?kind=1" class="write-button" title="Write a new post">
<span class="emoji emoji-grayscale"></span>
</a>
</div>
<a href="/write?kind=1" class="write-button" title="Write a new post">
<span class="emoji emoji-grayscale"></span>
</a>
</div>
<FeedPage />
</main>
@ -31,6 +34,12 @@ @@ -31,6 +34,12 @@
}
.feed-header {
display: flex;
flex-direction: column;
gap: 1rem;
}
.feed-controls {
display: flex;
justify-content: space-between;
align-items: center;

270
src/routes/find/+page.svelte

@ -0,0 +1,270 @@ @@ -0,0 +1,270 @@
<script lang="ts">
import Header from '../../lib/components/layout/Header.svelte';
import FindEventForm from '../../lib/components/write/FindEventForm.svelte';
import { nip19 } from 'nostr-tools';
import { goto } from '$app/navigation';
let userInput = $state('');
let searching = $state(false);
let error = $state<string | null>(null);
/**
* Decode pubkey from various formats
*/
async function decodePubkey(input: string): Promise<string | null> {
const trimmed = input.trim();
// Check if it's already a hex pubkey (64 hex characters)
if (/^[0-9a-f]{64}$/i.test(trimmed)) {
return trimmed.toLowerCase();
}
// Check if it's a bech32 format (npub, nprofile)
if (/^(npub|nprofile)1[a-z0-9]+$/i.test(trimmed)) {
try {
const decoded = nip19.decode(trimmed);
if (decoded.type === 'npub') {
return String(decoded.data);
} else if (decoded.type === 'nprofile') {
if (decoded.data && typeof decoded.data === 'object' && 'pubkey' in decoded.data) {
return String(decoded.data.pubkey);
}
}
} catch (e) {
console.error('Error decoding bech32:', e);
return null;
}
}
// Check if it's a NIP-05 identifier (user@domain.com)
if (/^[^@]+@[^@]+\.[^@]+$/.test(trimmed)) {
return await resolveNIP05(trimmed);
}
return null;
}
/**
* Resolve NIP-05 identifier to pubkey
*/
async function resolveNIP05(identifier: string): Promise<string | null> {
try {
const [localPart, domain] = identifier.split('@');
if (!localPart || !domain) return null;
// Check cache first (we'd need to implement a cache for this)
// For now, just fetch from well-known
const wellKnownUrl = `https://${domain}/.well-known/nostr.json?name=${encodeURIComponent(localPart)}`;
const response = await fetch(wellKnownUrl);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
const names = data.names || {};
const pubkey = names[localPart];
if (pubkey && /^[0-9a-f]{64}$/i.test(pubkey)) {
return pubkey.toLowerCase();
}
return null;
} catch (err) {
console.error('Error resolving NIP-05:', err);
return null;
}
}
async function findUser() {
if (!userInput.trim()) return;
searching = true;
error = null;
try {
const pubkey = await decodePubkey(userInput.trim());
if (!pubkey) {
error = 'Could not decode user identifier. Supported: NIP-05, hex pubkey, npub, nprofile';
searching = false;
return;
}
// Navigate to profile page
await goto(`/profile/${pubkey}`);
} catch (err) {
console.error('Error finding user:', err);
error = 'Failed to find user';
searching = false;
}
}
</script>
<Header />
<main class="container mx-auto px-4 py-8">
<div class="find-page">
<h1 class="text-2xl font-bold mb-6 text-fog-text dark:text-fog-dark-text font-mono">/Find</h1>
<div class="find-sections">
<!-- Find Event Section -->
<section class="find-section">
<h2 class="section-title">Find Event</h2>
<p class="section-description">Enter an event ID (hex, note, nevent, or naddr)</p>
<FindEventForm />
</section>
<!-- Find User Section -->
<section class="find-section">
<h2 class="section-title">Find User</h2>
<p class="section-description">Enter a user ID (NIP-05, hex pubkey, npub, or nprofile)</p>
<div class="input-group">
<input
type="text"
bind:value={userInput}
placeholder="user@domain.com or npub1..."
class="user-input"
onkeydown={(e) => {
if (e.key === 'Enter') {
findUser();
}
}}
disabled={searching}
/>
<button
class="find-button"
onclick={findUser}
disabled={searching || !userInput.trim()}
>
{searching ? 'Searching...' : 'Find'}
</button>
</div>
{#if error}
<div class="error-message">{error}</div>
{/if}
</section>
</div>
</div>
</main>
<style>
main {
max-width: var(--content-width);
margin: 0 auto;
}
.find-page {
max-width: 800px;
margin: 0 auto;
}
.find-sections {
display: flex;
flex-direction: column;
gap: 3rem;
}
.find-section {
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.5rem;
padding: 2rem;
background: var(--fog-post, #ffffff);
}
:global(.dark) .find-section {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-post, #1f2937);
}
.section-title {
margin: 0 0 0.5rem 0;
font-size: 1.25rem;
font-weight: 600;
color: var(--fog-text, #1f2937);
font-family: monospace;
}
:global(.dark) .section-title {
color: var(--fog-dark-text, #f9fafb);
}
.section-description {
margin: 0 0 1.5rem 0;
color: var(--fog-text-light, #6b7280);
font-size: 0.875rem;
}
:global(.dark) .section-description {
color: var(--fog-dark-text-light, #9ca3af);
}
.input-group {
display: flex;
gap: 0.5rem;
}
.user-input {
flex: 1;
padding: 0.75rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem;
background: var(--fog-post, #ffffff);
color: var(--fog-text, #1f2937);
font-size: 0.875rem;
font-family: monospace;
}
:global(.dark) .user-input {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-post, #1f2937);
color: var(--fog-dark-text, #f9fafb);
}
.user-input:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.find-button {
padding: 0.75rem 1.5rem;
background: var(--fog-accent, #64748b);
color: white;
border: none;
border-radius: 0.25rem;
cursor: pointer;
font-size: 0.875rem;
font-weight: 500;
font-family: monospace;
}
:global(.dark) .find-button {
background: var(--fog-dark-accent, #94a3b8);
}
.find-button:hover:not(:disabled) {
opacity: 0.9;
}
.find-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.error-message {
margin-top: 1rem;
padding: 0.75rem;
background: var(--fog-danger-light, #fee2e2);
color: var(--fog-danger, #dc2626);
border-radius: 0.25rem;
font-size: 0.875rem;
}
:global(.dark) .error-message {
background: var(--fog-dark-danger-light, #7f1d1d);
color: var(--fog-dark-danger, #ef4444);
}
</style>

316
src/routes/relay/+page.svelte

@ -0,0 +1,316 @@ @@ -0,0 +1,316 @@
<script lang="ts">
import Header from '../../lib/components/layout/Header.svelte';
import { nostrClient } from '../../lib/services/nostr/nostr-client.js';
import { relayManager } from '../../lib/services/nostr/relay-manager.js';
import { config } from '../../lib/services/nostr/config.js';
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { sessionManager } from '../../lib/services/auth/session-manager.js';
interface RelayInfo {
url: string;
categories: string[];
connected: boolean;
}
let relays = $state<RelayInfo[]>([]);
let loading = $state(true);
function categorizeRelay(url: string): string[] {
const categories: string[] = [];
// Check all categories - a relay can belong to multiple
if (config.defaultRelays.includes(url)) {
categories.push('Default');
}
if (config.profileRelays.includes(url)) {
categories.push('Profile');
}
if (config.threadPublishRelays.includes(url)) {
categories.push('Thread Publish');
}
if (config.gifRelays.includes(url)) {
categories.push('GIF');
}
// If no categories found, mark as Other
if (categories.length === 0) {
categories.push('Other');
}
return categories;
}
async function loadRelays() {
loading = true;
// Collect all unique relays from all categories
const allRelays = new Set<string>();
// Add default relays
config.defaultRelays.forEach(r => allRelays.add(r));
// Add profile relays
config.profileRelays.forEach(r => allRelays.add(r));
// Add thread publish relays
config.threadPublishRelays.forEach(r => allRelays.add(r));
// Add gif relays
config.gifRelays.forEach(r => allRelays.add(r));
// Get connection status from nostrClient
const connectedRelays = nostrClient.getConnectedRelays();
const relayList: RelayInfo[] = Array.from(allRelays).map(url => ({
url,
categories: categorizeRelay(url),
connected: connectedRelays.includes(url)
}));
// Sort by first category, then by URL
relayList.sort((a, b) => {
const categoryA = a.categories[0] || '';
const categoryB = b.categories[0] || '';
if (categoryA !== categoryB) {
return categoryA.localeCompare(categoryB);
}
return a.url.localeCompare(b.url);
});
relays = relayList;
loading = false;
}
function handleRelayClick(url: string) {
// Extract hostname from relay URL (e.g., wss://thecitadel.nostr1.com -> thecitadel.nostr1.com)
// The route expects just the domain without protocol or port
let relayPath = url;
try {
const urlObj = new URL(url);
relayPath = urlObj.hostname;
} catch {
// If URL parsing fails, try to extract hostname manually
// Remove protocol (wss:// or ws://) and trailing slash
relayPath = url.replace(/^wss?:\/\//, '').replace(/\/$/, '');
// Remove port if present (route doesn't support ports in the parameter)
relayPath = relayPath.split(':')[0];
}
// Navigate to feed page with relay filter
goto(`/feed/relay/${relayPath}`);
}
onMount(async () => {
await nostrClient.initialize();
await loadRelays();
});
</script>
<Header />
<main class="container mx-auto px-4 py-8">
<div class="relay-page">
<h1 class="text-2xl font-bold mb-6 text-fog-text dark:text-fog-dark-text font-mono">/Relay</h1>
{#if loading}
<p class="text-fog-text dark:text-fog-dark-text">Loading relays...</p>
{:else}
<div class="relay-categories">
{#each ['Default', 'Profile', 'Thread Publish', 'GIF', 'Other'] as category}
{@const categoryRelays = relays.filter(r => r.categories.includes(category))}
{#if categoryRelays.length > 0}
<section class="relay-category">
<h2 class="category-title">{category}</h2>
<div class="relay-list">
{#each categoryRelays as relay}
<div
class="relay-item"
onclick={() => handleRelayClick(relay.url)}
role="button"
tabindex="0"
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleRelayClick(relay.url);
}
}}
>
<div class="relay-info">
<span class="relay-url">{relay.url}</span>
<div class="relay-meta">
{#if relay.categories.length > 1}
<span class="relay-categories-badge">
{relay.categories.length} categories
</span>
{/if}
<span class="relay-status" class:connected={relay.connected} class:disconnected={!relay.connected}>
{relay.connected ? '● Connected' : '○ Disconnected'}
</span>
</div>
</div>
<span class="relay-arrow"></span>
</div>
{/each}
</div>
</section>
{/if}
{/each}
</div>
{/if}
</div>
</main>
<style>
main {
max-width: var(--content-width);
margin: 0 auto;
}
.relay-page {
max-width: 1000px;
margin: 0 auto;
}
.relay-categories {
display: flex;
flex-direction: column;
gap: 2rem;
}
.relay-category {
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.5rem;
padding: 1.5rem;
background: var(--fog-post, #ffffff);
}
:global(.dark) .relay-category {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-post, #1f2937);
}
.category-title {
margin: 0 0 1rem 0;
font-size: 1.125rem;
font-weight: 600;
color: var(--fog-text, #1f2937);
font-family: monospace;
text-transform: uppercase;
letter-spacing: 0.05em;
}
:global(.dark) .category-title {
color: var(--fog-dark-text, #f9fafb);
}
.relay-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.relay-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem;
background: var(--fog-highlight, #f3f4f6);
cursor: pointer;
transition: all 0.2s;
font-family: monospace;
}
:global(.dark) .relay-item {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-highlight, #374151);
}
.relay-item:hover {
background: var(--fog-accent, #64748b);
color: white;
border-color: var(--fog-accent, #64748b);
}
:global(.dark) .relay-item:hover {
background: var(--fog-dark-accent, #94a3b8);
border-color: var(--fog-dark-accent, #94a3b8);
}
.relay-item:focus {
outline: 2px solid var(--fog-accent, #64748b);
outline-offset: 2px;
}
.relay-info {
display: flex;
flex-direction: column;
gap: 0.25rem;
flex: 1;
}
.relay-meta {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.relay-categories-badge {
font-size: 0.7rem;
opacity: 0.6;
color: var(--fog-text-light, #6b7280);
font-family: monospace;
}
:global(.dark) .relay-categories-badge {
color: var(--fog-dark-text-light, #9ca3af);
}
.relay-item:hover .relay-categories-badge {
color: white;
opacity: 0.8;
}
.relay-url {
font-size: 0.875rem;
color: var(--fog-text, #1f2937);
word-break: break-all;
}
:global(.dark) .relay-url {
color: var(--fog-dark-text, #f9fafb);
}
.relay-item:hover .relay-url {
color: white;
}
.relay-status {
font-size: 0.75rem;
opacity: 0.7;
}
.relay-status.connected {
color: #10b981;
}
.relay-status.disconnected {
color: #ef4444;
}
.relay-item:hover .relay-status {
color: white;
opacity: 1;
}
.relay-arrow {
font-size: 1.25rem;
opacity: 0.5;
margin-left: 1rem;
}
.relay-item:hover .relay-arrow {
opacity: 1;
}
</style>

232
src/routes/topics/+page.svelte

@ -0,0 +1,232 @@ @@ -0,0 +1,232 @@
<script lang="ts">
import Header from '../../lib/components/layout/Header.svelte';
import { nostrClient } from '../../lib/services/nostr/nostr-client.js';
import { relayManager } from '../../lib/services/nostr/relay-manager.js';
import { getEventsByKind } from '../../lib/services/cache/event-cache.js';
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { KIND } from '../../lib/types/kind-lookup.js';
import type { NostrEvent } from '../../lib/types/nostr.js';
interface TopicInfo {
name: string;
count: number;
isInterest: boolean;
}
let topics = $state<TopicInfo[]>([]);
let loading = $state(true);
let interestList = $state<string[]>([]);
function extractHashtags(event: NostrEvent): string[] {
const hashtags = new Set<string>();
// Extract from t-tags
for (const tag of event.tags) {
if (tag[0] === 't' && tag[1]) {
const topic = tag[1].toLowerCase().trim();
if (topic) {
hashtags.add(topic);
}
}
}
// Extract from content (hashtags like #topic)
const hashtagPattern = /#([a-zA-Z0-9_]+)/g;
let match;
while ((match = hashtagPattern.exec(event.content)) !== null) {
const topic = match[1].toLowerCase().trim();
if (topic) {
hashtags.add(topic);
}
}
return Array.from(hashtags);
}
async function loadTopics() {
loading = true;
try {
// Get interest list from user preferences (if logged in)
// For now, we'll use an empty list - you can extend this to load from user's kind 10001
interestList = [];
// Get all cached events that might have hashtags
const allEvents: NostrEvent[] = [];
// Get kind 1 (short text notes)
const kind1Events = await getEventsByKind(KIND.SHORT_TEXT_NOTE, 1000);
allEvents.push(...kind1Events);
// Get kind 11 (discussion threads)
const kind11Events = await getEventsByKind(KIND.DISCUSSION_THREAD, 1000);
allEvents.push(...kind11Events);
// Count hashtags
const topicCounts = new Map<string, number>();
for (const event of allEvents) {
const hashtags = extractHashtags(event);
for (const hashtag of hashtags) {
const current = topicCounts.get(hashtag) || 0;
topicCounts.set(hashtag, current + 1);
}
}
// Convert to array and sort
const topicList: TopicInfo[] = Array.from(topicCounts.entries()).map(([name, count]) => ({
name,
count,
isInterest: interestList.includes(name)
}));
// Sort: interest list first, then by count (descending)
topicList.sort((a, b) => {
if (a.isInterest && !b.isInterest) return -1;
if (!a.isInterest && b.isInterest) return 1;
return b.count - a.count;
});
topics = topicList;
} catch (error) {
console.error('Error loading topics:', error);
topics = [];
} finally {
loading = false;
}
}
function handleTopicClick(topic: string) {
goto(`/topics/${topic}`);
}
onMount(async () => {
await nostrClient.initialize();
await loadTopics();
});
</script>
<Header />
<main class="container mx-auto px-4 py-8">
<div class="topics-page">
<h1 class="text-2xl font-bold mb-6 text-fog-text dark:text-fog-dark-text font-mono">/Topics</h1>
{#if loading}
<p class="text-fog-text dark:text-fog-dark-text">Loading topics...</p>
{:else if topics.length === 0}
<p class="text-fog-text dark:text-fog-dark-text">No topics found.</p>
{:else}
<div class="topics-list">
{#each topics as topic (topic.name)}
<div
class="topic-item"
class:interest={topic.isInterest}
onclick={() => handleTopicClick(topic.name)}
role="button"
tabindex="0"
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleTopicClick(topic.name);
}
}}
>
<span class="topic-name">#{topic.name}</span>
<span class="topic-count">{topic.count}</span>
</div>
{/each}
</div>
{/if}
</div>
</main>
<style>
main {
max-width: var(--content-width);
margin: 0 auto;
}
.topics-page {
max-width: 1000px;
margin: 0 auto;
}
.topics-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 0.75rem;
}
.topic-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem;
background: var(--fog-post, #ffffff);
cursor: pointer;
transition: all 0.2s;
font-family: monospace;
}
:global(.dark) .topic-item {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-post, #1f2937);
}
.topic-item:hover {
background: var(--fog-accent, #64748b);
color: white;
border-color: var(--fog-accent, #64748b);
}
:global(.dark) .topic-item:hover {
background: var(--fog-dark-accent, #94a3b8);
border-color: var(--fog-dark-accent, #94a3b8);
}
.topic-item.interest {
border-left: 3px solid var(--fog-accent, #64748b);
}
:global(.dark) .topic-item.interest {
border-left-color: var(--fog-dark-accent, #94a3b8);
}
.topic-item:focus {
outline: 2px solid var(--fog-accent, #64748b);
outline-offset: 2px;
}
.topic-name {
font-size: 0.875rem;
color: var(--fog-text, #1f2937);
font-weight: 500;
}
:global(.dark) .topic-name {
color: var(--fog-dark-text, #f9fafb);
}
.topic-item:hover .topic-name {
color: white;
}
.topic-count {
font-size: 0.75rem;
opacity: 0.7;
color: var(--fog-text-light, #6b7280);
}
:global(.dark) .topic-count {
color: var(--fog-dark-text-light, #9ca3af);
}
.topic-item:hover .topic-count {
color: white;
opacity: 1;
}
</style>

28
src/routes/write/+page.svelte

@ -1,13 +1,11 @@ @@ -1,13 +1,11 @@
<script lang="ts">
import Header from '../../lib/components/layout/Header.svelte';
import FindEventForm from '../../lib/components/write/FindEventForm.svelte';
import CreateEventForm from '../../lib/components/write/CreateEventForm.svelte';
import { nostrClient } from '../../lib/services/nostr/nostr-client.js';
import { sessionManager } from '../../lib/services/auth/session-manager.js';
import { onMount } from 'svelte';
import { page } from '$app/stores';
let mode = $state<'select' | 'find' | 'create'>('select');
let initialKind = $state<number | null>(null);
const isLoggedIn = $derived(sessionManager.isLoggedIn());
@ -20,7 +18,6 @@ @@ -20,7 +18,6 @@
const kind = parseInt(kindParam, 10);
if (!isNaN(kind)) {
initialKind = kind;
mode = 'create';
}
}
});
@ -30,36 +27,15 @@ @@ -30,36 +27,15 @@
<main class="container mx-auto px-4 py-8">
<div class="write-page">
<h1 class="text-2xl font-bold mb-6 text-fog-text dark:text-fog-dark-text">Write</h1>
<h1 class="text-2xl font-bold mb-6 text-fog-text dark:text-fog-dark-text font-mono">/Write</h1>
{#if !isLoggedIn}
<div class="login-prompt">
<p class="text-fog-text dark:text-fog-dark-text mb-4">You must be logged in to write or edit events.</p>
<a href="/login" class="text-fog-accent dark:text-fog-dark-accent hover:underline">Login here</a>
</div>
{:else if mode === 'select'}
<div class="mode-selector">
<button
class="mode-button"
onclick={() => mode = 'find'}
>
Find an existing event to edit
</button>
<button
class="mode-button"
onclick={() => mode = 'create'}
>
Create a new event
</button>
</div>
{:else if mode === 'find'}
<div class="form-container">
<button class="back-button" onclick={() => mode = 'select'}> Back</button>
<FindEventForm />
</div>
{:else if mode === 'create'}
{:else}
<div class="form-container">
<button class="back-button" onclick={() => mode = 'select'}> Back</button>
<CreateEventForm initialKind={initialKind} />
</div>
{/if}

Loading…
Cancel
Save