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