16 changed files with 1026 additions and 69 deletions
@ -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> |
||||||
@ -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> |
||||||
@ -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> |
||||||
Loading…
Reference in new issue