36 changed files with 2929 additions and 1430 deletions
@ -0,0 +1,286 @@
@@ -0,0 +1,286 @@
|
||||
<script lang="ts"> |
||||
import { sessionManager } from '../../services/auth/session-manager.js'; |
||||
import { uploadFileToServer, buildImetaTag } from '../../services/nostr/file-upload.js'; |
||||
import { insertTextAtCursor } from '../../services/text-utils.js'; |
||||
import MentionsAutocomplete from './MentionsAutocomplete.svelte'; |
||||
import GifPicker from './GifPicker.svelte'; |
||||
import EmojiPicker from './EmojiPicker.svelte'; |
||||
|
||||
interface Props { |
||||
value: string; |
||||
placeholder?: string; |
||||
rows?: number; |
||||
disabled?: boolean; |
||||
showToolbar?: boolean; |
||||
uploadContext?: string; // For logging purposes |
||||
onValueChange?: (value: string) => void; |
||||
onFilesUploaded?: (files: Array<{ url: string; imetaTag: string[] }>) => void; |
||||
} |
||||
|
||||
let { |
||||
value = $bindable(''), |
||||
placeholder = 'Enter text...', |
||||
rows = 4, |
||||
disabled = false, |
||||
showToolbar = true, |
||||
uploadContext = 'RichTextEditor', |
||||
onValueChange, |
||||
onFilesUploaded |
||||
}: Props = $props(); |
||||
|
||||
let textareaRef: HTMLTextAreaElement | null = $state(null); |
||||
let fileInputRef: HTMLInputElement | null = $state(null); |
||||
let showGifPicker = $state(false); |
||||
let showEmojiPicker = $state(false); |
||||
let uploading = $state(false); |
||||
let uploadedFiles: Array<{ url: string; imetaTag: string[] }> = $state([]); |
||||
|
||||
// Generate unique ID for file input |
||||
const fileInputId = `rich-text-file-upload-${Math.random().toString(36).substring(7)}`; |
||||
|
||||
// Sync uploaded files to parent |
||||
$effect(() => { |
||||
if (uploadedFiles.length > 0 && onFilesUploaded) { |
||||
onFilesUploaded(uploadedFiles); |
||||
} |
||||
}); |
||||
|
||||
function handleGifSelect(gifUrl: string) { |
||||
if (!textareaRef) return; |
||||
insertTextAtCursor(textareaRef, gifUrl); |
||||
showGifPicker = false; |
||||
} |
||||
|
||||
function handleEmojiSelect(emoji: string) { |
||||
if (!textareaRef) return; |
||||
insertTextAtCursor(textareaRef, emoji); |
||||
showEmojiPicker = false; |
||||
} |
||||
|
||||
async function handleFileUpload(event: Event) { |
||||
const input = event.target as HTMLInputElement; |
||||
const files = input.files; |
||||
if (!files || files.length === 0) return; |
||||
|
||||
// Filter valid file types |
||||
const validFiles: File[] = []; |
||||
for (const file of Array.from(files)) { |
||||
const isImage = file.type.startsWith('image/'); |
||||
const isVideo = file.type.startsWith('video/'); |
||||
const isAudio = file.type.startsWith('audio/'); |
||||
|
||||
if (!isImage && !isVideo && !isAudio) { |
||||
alert(`${file.name} is not an image, video, or audio file`); |
||||
continue; |
||||
} |
||||
validFiles.push(file); |
||||
} |
||||
|
||||
if (validFiles.length === 0) { |
||||
return; |
||||
} |
||||
|
||||
if (!sessionManager.isLoggedIn()) { |
||||
alert('Please log in to upload files'); |
||||
return; |
||||
} |
||||
|
||||
uploading = true; |
||||
const uploadPromises: Promise<void>[] = []; |
||||
|
||||
// Process all files |
||||
for (const file of validFiles) { |
||||
const uploadPromise = (async () => { |
||||
try { |
||||
// Upload file to media server |
||||
const uploadResult = await uploadFileToServer(file, uploadContext); |
||||
console.log(`[${uploadContext}] Uploaded ${file.name} to ${uploadResult.url}`); |
||||
|
||||
// Build imeta tag from upload response (NIP-92 format) |
||||
const imetaTag = buildImetaTag(file, uploadResult); |
||||
|
||||
// Store file with imeta tag |
||||
uploadedFiles.push({ |
||||
url: uploadResult.url, |
||||
imetaTag |
||||
}); |
||||
|
||||
// Insert file URL into textarea (plain URL for all file types) |
||||
if (textareaRef) { |
||||
insertTextAtCursor(textareaRef, `${uploadResult.url}\n`); |
||||
} |
||||
} catch (error) { |
||||
console.error(`[${uploadContext}] File upload failed for ${file.name}:`, error); |
||||
const errorMessage = error instanceof Error ? error.message : String(error); |
||||
alert(`Failed to upload ${file.name}: ${errorMessage}`); |
||||
} |
||||
})(); |
||||
|
||||
uploadPromises.push(uploadPromise); |
||||
} |
||||
|
||||
// Wait for all uploads to complete |
||||
try { |
||||
await Promise.all(uploadPromises); |
||||
} finally { |
||||
uploading = false; |
||||
// Reset file input |
||||
if (fileInputRef) { |
||||
fileInputRef.value = ''; |
||||
} |
||||
} |
||||
} |
||||
|
||||
// Expose method to clear uploaded files (useful when form is cleared) |
||||
export function clearUploadedFiles() { |
||||
uploadedFiles = []; |
||||
} |
||||
|
||||
// Expose method to get uploaded files |
||||
export function getUploadedFiles() { |
||||
return uploadedFiles; |
||||
} |
||||
</script> |
||||
|
||||
<div class="textarea-wrapper"> |
||||
<textarea |
||||
bind:this={textareaRef} |
||||
bind:value |
||||
{placeholder} |
||||
class="w-full p-3 border border-fog-border dark:border-fog-dark-border rounded bg-fog-post dark:bg-fog-dark-post text-fog-text dark:text-fog-dark-text {showToolbar ? 'has-buttons' : ''}" |
||||
{rows} |
||||
{disabled} |
||||
oninput={(e) => { |
||||
if (onValueChange) { |
||||
onValueChange(e.currentTarget.value); |
||||
} |
||||
}} |
||||
></textarea> |
||||
|
||||
{#if textareaRef} |
||||
<MentionsAutocomplete textarea={textareaRef} /> |
||||
{/if} |
||||
|
||||
{#if showToolbar} |
||||
<div class="textarea-buttons"> |
||||
<button |
||||
type="button" |
||||
onclick={() => { |
||||
showGifPicker = !showGifPicker; |
||||
showEmojiPicker = false; |
||||
}} |
||||
class="toolbar-button" |
||||
title="Insert GIF" |
||||
aria-label="Insert GIF" |
||||
{disabled} |
||||
> |
||||
GIF |
||||
</button> |
||||
<button |
||||
type="button" |
||||
onclick={() => { showEmojiPicker = !showEmojiPicker; showGifPicker = false; }} |
||||
class="toolbar-button" |
||||
title="Insert emoji" |
||||
aria-label="Insert emoji" |
||||
{disabled} |
||||
> |
||||
😀 |
||||
</button> |
||||
<input |
||||
type="file" |
||||
bind:this={fileInputRef} |
||||
accept="image/*,video/*,audio/*" |
||||
multiple |
||||
onchange={handleFileUpload} |
||||
class="hidden" |
||||
id={fileInputId} |
||||
disabled={disabled || uploading} |
||||
/> |
||||
<label |
||||
for={fileInputId} |
||||
class="toolbar-button upload-button" |
||||
title="Upload file (image, video, or audio)" |
||||
aria-label="Upload file" |
||||
> |
||||
📤 |
||||
</label> |
||||
</div> |
||||
{/if} |
||||
</div> |
||||
|
||||
<GifPicker open={showGifPicker} onSelect={handleGifSelect} onClose={() => showGifPicker = false} /> |
||||
<EmojiPicker open={showEmojiPicker} onSelect={handleEmojiSelect} onClose={() => showEmojiPicker = false} /> |
||||
|
||||
<style> |
||||
.textarea-wrapper { |
||||
position: relative; |
||||
} |
||||
|
||||
textarea { |
||||
resize: vertical; |
||||
min-height: 100px; |
||||
} |
||||
|
||||
/* Add padding to bottom when buttons are visible to prevent text overlap */ |
||||
textarea.has-buttons { |
||||
padding-bottom: 3rem; /* Increased padding to accommodate buttons */ |
||||
} |
||||
|
||||
textarea:focus { |
||||
outline: none; |
||||
border-color: var(--fog-accent, #64748b); |
||||
} |
||||
|
||||
.textarea-buttons { |
||||
position: absolute; |
||||
bottom: 0.5rem; |
||||
left: 0.75rem; |
||||
display: flex; |
||||
gap: 0.25rem; |
||||
z-index: 10; |
||||
padding-top: 0.5rem; /* Add padding above buttons to separate from text */ |
||||
} |
||||
|
||||
.upload-button { |
||||
cursor: pointer; |
||||
user-select: none; |
||||
} |
||||
|
||||
.toolbar-button { |
||||
padding: 0.25rem 0.5rem; |
||||
font-size: 0.75rem; |
||||
border: 1px solid var(--fog-border, #e5e7eb); |
||||
border-radius: 0.25rem; |
||||
background: var(--fog-highlight, #f3f4f6); |
||||
color: var(--fog-text-light, #6b7280); |
||||
cursor: pointer; |
||||
transition: all 0.2s; |
||||
} |
||||
|
||||
.toolbar-button:hover:not(:disabled) { |
||||
background: var(--fog-border, #e5e7eb); |
||||
border-color: var(--fog-accent, #64748b); |
||||
color: var(--fog-text, #475569); |
||||
} |
||||
|
||||
.toolbar-button:disabled { |
||||
opacity: 0.5; |
||||
cursor: not-allowed; |
||||
} |
||||
|
||||
:global(.dark) .toolbar-button { |
||||
background: var(--fog-dark-highlight, #374151); |
||||
border-color: var(--fog-dark-border, #475569); |
||||
color: var(--fog-dark-text-light, #9ca3af); |
||||
} |
||||
|
||||
:global(.dark) .toolbar-button:hover:not(:disabled) { |
||||
background: var(--fog-dark-border, #475569); |
||||
border-color: var(--fog-dark-accent, #64748b); |
||||
color: var(--fog-dark-text, #cbd5e1); |
||||
} |
||||
|
||||
.hidden { |
||||
display: none; |
||||
} |
||||
</style> |
||||
@ -0,0 +1,279 @@
@@ -0,0 +1,279 @@
|
||||
<script lang="ts"> |
||||
import { nip19 } from 'nostr-tools'; |
||||
import { getProfile, getProfiles } from '../../services/cache/profile-cache.js'; |
||||
import { parseProfile } from '../../services/user-data.js'; |
||||
|
||||
interface Props { |
||||
value: string; |
||||
onInput: (value: string) => void; |
||||
placeholder?: string; |
||||
} |
||||
|
||||
let { value, onInput, placeholder = 'Search by pubkey, p, q tags, or content...' }: Props = $props(); |
||||
|
||||
// Resolved pubkey from NIP-05 or other formats |
||||
let resolvedPubkey = $state<string | null>(null); |
||||
let resolving = $state(false); |
||||
|
||||
function handleInput(e: Event) { |
||||
const input = e.target as HTMLInputElement; |
||||
onInput(input.value); |
||||
// Trigger async resolution |
||||
resolvePubkey(input.value); |
||||
} |
||||
|
||||
// Check if input looks like NIP-05 (user@domain.com) |
||||
function isNIP05(input: string): boolean { |
||||
const trimmed = input.trim(); |
||||
// Simple check: contains @ and has at least one character before and after |
||||
return /^[^@]+@[^@]+\.[^@]+$/.test(trimmed); |
||||
} |
||||
|
||||
// Search cache for profiles with matching NIP-05 |
||||
async function searchCacheForNIP05(nip05: string): Promise<string | null> { |
||||
try { |
||||
// Get all profiles from cache (we need to iterate through them) |
||||
// Since we don't have an index on nip05, we'll need to get all profiles |
||||
// This is not ideal but necessary for now |
||||
const db = await import('../../services/cache/indexeddb-store.js').then(m => m.getDB()); |
||||
const tx = db.transaction('profiles', 'readonly'); |
||||
const store = tx.store; |
||||
const profiles: any[] = []; |
||||
|
||||
// Get all profiles (this could be slow with many profiles, but it's cached) |
||||
let cursor = await store.openCursor(); |
||||
while (cursor) { |
||||
profiles.push(cursor.value); |
||||
cursor = await cursor.continue(); |
||||
} |
||||
await tx.done; |
||||
|
||||
// Search for matching NIP-05 |
||||
const normalizedNIP05 = nip05.toLowerCase(); |
||||
for (const cached of profiles) { |
||||
const profile = parseProfile(cached.event); |
||||
if (profile.nip05) { |
||||
for (const profileNip05 of profile.nip05) { |
||||
if (profileNip05.toLowerCase() === normalizedNIP05) { |
||||
return cached.pubkey; |
||||
} |
||||
} |
||||
} |
||||
} |
||||
} catch (error) { |
||||
console.debug('Error searching cache for NIP-05:', error); |
||||
} |
||||
return null; |
||||
} |
||||
|
||||
// Resolve NIP-05 from well-known.json |
||||
async function resolveNIP05FromWellKnown(nip05: string): Promise<string | null> { |
||||
try { |
||||
const [localPart, domain] = nip05.split('@'); |
||||
if (!localPart || !domain) return null; |
||||
|
||||
const wellKnownUrl = `https://${domain}/.well-known/nostr.json?name=${encodeURIComponent(localPart)}`; |
||||
|
||||
const controller = new AbortController(); |
||||
const timeout = setTimeout(() => controller.abort(), 5000); // 5 second timeout |
||||
|
||||
try { |
||||
const response = await fetch(wellKnownUrl, { signal: controller.signal }); |
||||
clearTimeout(timeout); |
||||
|
||||
if (!response.ok) { |
||||
return null; |
||||
} |
||||
|
||||
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(); |
||||
} |
||||
} catch (fetchError) { |
||||
clearTimeout(timeout); |
||||
if (fetchError instanceof Error && fetchError.name !== 'AbortError') { |
||||
console.debug('Error fetching well-known.json:', fetchError); |
||||
} |
||||
} |
||||
} catch (error) { |
||||
console.debug('Error resolving NIP-05:', error); |
||||
} |
||||
return null; |
||||
} |
||||
|
||||
// Helper to normalize input to hex pubkey (sync for hex/npub/nprofile, async for NIP-05) |
||||
function normalizeToHexSync(input: string): string | null { |
||||
const trimmed = input.trim(); |
||||
if (!trimmed) return null; |
||||
|
||||
// If it's already a hex pubkey (64 hex chars) |
||||
if (/^[a-fA-F0-9]{64}$/.test(trimmed)) { |
||||
return trimmed.toLowerCase(); |
||||
} |
||||
|
||||
// Try to decode as bech32 (npub or nprofile) |
||||
try { |
||||
const decoded = nip19.decode(trimmed); |
||||
if (decoded.type === 'npub') { |
||||
return decoded.data as string; |
||||
} else if (decoded.type === 'nprofile') { |
||||
return (decoded.data as any).pubkey; |
||||
} |
||||
} catch { |
||||
// Not a valid bech32 |
||||
} |
||||
|
||||
return null; |
||||
} |
||||
|
||||
// Async resolution for NIP-05 |
||||
async function resolvePubkey(input: string) { |
||||
const trimmed = input.trim(); |
||||
if (!trimmed) { |
||||
resolvedPubkey = null; |
||||
return; |
||||
} |
||||
|
||||
// Try sync resolution first |
||||
const syncResult = normalizeToHexSync(trimmed); |
||||
if (syncResult) { |
||||
resolvedPubkey = syncResult; |
||||
return; |
||||
} |
||||
|
||||
// If it looks like NIP-05, resolve it |
||||
if (isNIP05(trimmed)) { |
||||
resolving = true; |
||||
try { |
||||
// First check cache |
||||
const cachedPubkey = await searchCacheForNIP05(trimmed); |
||||
if (cachedPubkey) { |
||||
resolvedPubkey = cachedPubkey; |
||||
resolving = false; |
||||
return; |
||||
} |
||||
|
||||
// Then check well-known.json |
||||
const wellKnownPubkey = await resolveNIP05FromWellKnown(trimmed); |
||||
if (wellKnownPubkey) { |
||||
resolvedPubkey = wellKnownPubkey; |
||||
} else { |
||||
resolvedPubkey = null; |
||||
} |
||||
} catch (error) { |
||||
console.debug('Error resolving NIP-05:', error); |
||||
resolvedPubkey = null; |
||||
} finally { |
||||
resolving = false; |
||||
} |
||||
} else { |
||||
resolvedPubkey = null; |
||||
} |
||||
} |
||||
|
||||
// Resolve when value changes |
||||
let resolveTimeout: ReturnType<typeof setTimeout> | null = null; |
||||
$effect(() => { |
||||
// Debounce resolution to avoid too many requests |
||||
if (resolveTimeout) clearTimeout(resolveTimeout); |
||||
resolveTimeout = setTimeout(() => { |
||||
resolvePubkey(value); |
||||
}, 300); // 300ms debounce |
||||
|
||||
return () => { |
||||
if (resolveTimeout) clearTimeout(resolveTimeout); |
||||
}; |
||||
}); |
||||
|
||||
// Expose resolved pubkey for parent components |
||||
export function getNormalizedPubkey(): string | null { |
||||
return resolvedPubkey; |
||||
} |
||||
|
||||
// Expose resolving state |
||||
export function isResolving(): boolean { |
||||
return resolving; |
||||
} |
||||
</script> |
||||
|
||||
<div class="pubkey-filter"> |
||||
<input |
||||
type="text" |
||||
value={value} |
||||
oninput={handleInput} |
||||
placeholder={placeholder} |
||||
class="filter-input" |
||||
class:resolving={resolving} |
||||
/> |
||||
{#if resolving} |
||||
<span class="resolving-indicator" title="Resolving NIP-05...">⟳</span> |
||||
{/if} |
||||
</div> |
||||
|
||||
<style> |
||||
.pubkey-filter { |
||||
margin-bottom: 1rem; |
||||
} |
||||
|
||||
.filter-input { |
||||
width: 100%; |
||||
max-width: 500px; |
||||
padding: 0.75rem; |
||||
border: 1px solid var(--fog-border, #e5e7eb); |
||||
border-radius: 0.375rem; |
||||
background: var(--fog-post, #ffffff); |
||||
color: var(--fog-text, #1f2937); |
||||
font-size: 0.875rem; |
||||
font-family: monospace; |
||||
} |
||||
|
||||
:global(.dark) .filter-input { |
||||
border-color: var(--fog-dark-border, #374151); |
||||
background: var(--fog-dark-post, #1f2937); |
||||
color: var(--fog-dark-text, #f9fafb); |
||||
} |
||||
|
||||
.filter-input:focus { |
||||
outline: none; |
||||
border-color: var(--fog-accent, #64748b); |
||||
box-shadow: 0 0 0 3px rgba(100, 116, 139, 0.1); |
||||
} |
||||
|
||||
:global(.dark) .filter-input:focus { |
||||
border-color: var(--fog-dark-accent, #94a3b8); |
||||
box-shadow: 0 0 0 3px rgba(148, 163, 184, 0.1); |
||||
} |
||||
|
||||
.filter-input.resolving { |
||||
border-color: var(--fog-accent, #64748b); |
||||
} |
||||
|
||||
.pubkey-filter { |
||||
position: relative; |
||||
display: flex; |
||||
align-items: center; |
||||
gap: 0.5rem; |
||||
} |
||||
|
||||
.resolving-indicator { |
||||
color: var(--fog-accent, #64748b); |
||||
font-size: 1rem; |
||||
animation: spin 1s linear infinite; |
||||
} |
||||
|
||||
:global(.dark) .resolving-indicator { |
||||
color: var(--fog-dark-accent, #94a3b8); |
||||
} |
||||
|
||||
@keyframes spin { |
||||
from { |
||||
transform: rotate(0deg); |
||||
} |
||||
to { |
||||
transform: rotate(360deg); |
||||
} |
||||
} |
||||
</style> |
||||
@ -0,0 +1,938 @@
@@ -0,0 +1,938 @@
|
||||
<script lang="ts"> |
||||
import { nostrClient } from '../../services/nostr/nostr-client.js'; |
||||
import { relayManager } from '../../services/nostr/relay-manager.js'; |
||||
import { getEvent, getEventsByKind } from '../../services/cache/event-cache.js'; |
||||
import { cacheEvent } from '../../services/cache/event-cache.js'; |
||||
import { nip19 } from 'nostr-tools'; |
||||
import { goto } from '$app/navigation'; |
||||
import { KIND, KIND_LOOKUP } from '../../types/kind-lookup.js'; |
||||
import type { NostrEvent } from '../../types/nostr.js'; |
||||
import { parseProfile } from '../../services/user-data.js'; |
||||
|
||||
interface Props { |
||||
mode?: 'search' | 'filter'; // 'search' shows dropdown, 'filter' filters page content |
||||
placeholder?: string; |
||||
onFilterChange?: (result: { type: 'event' | 'pubkey' | 'text' | null; value: string | null; kind?: number | null }) => void; |
||||
showKindFilter?: boolean; // Show kind filter dropdown |
||||
selectedKind?: number | null; // Selected kind for filtering |
||||
onKindChange?: (kind: number | null) => void; // Callback when kind filter changes |
||||
hideDropdownResults?: boolean; // If true, don't show dropdown results (for /find page) |
||||
onSearchResults?: (results: { events: NostrEvent[]; profiles: string[] }) => void; // Callback for search results (events and profile pubkeys) |
||||
} |
||||
|
||||
let { mode = 'search', placeholder = 'Search events, profiles, pubkeys, or enter event ID...', onFilterChange, showKindFilter = false, selectedKind = null, onKindChange, hideDropdownResults = false, onSearchResults }: Props = $props(); |
||||
|
||||
let searchQuery = $state(''); |
||||
let searching = $state(false); |
||||
let resolving = $state(false); |
||||
let searchResults = $state<Array<{ event: NostrEvent; matchType: string }>>([]); |
||||
let showResults = $state(false); |
||||
let searchInput: HTMLInputElement | null = $state(null); |
||||
let searchTimeout: ReturnType<typeof setTimeout> | null = null; |
||||
|
||||
// For collecting results when hideDropdownResults is true |
||||
let foundEvents: NostrEvent[] = []; |
||||
let foundProfiles: string[] = []; |
||||
|
||||
// For filter mode: resolved search result |
||||
let filterResult = $state<{ type: 'event' | 'pubkey' | 'text' | null; value: string | null; kind?: number | null }>({ type: null, value: null, kind: null }); |
||||
|
||||
// Note: filterResult kind is updated in performSearch, not here to avoid loops |
||||
|
||||
// Check if input looks like NIP-05 (user@domain.com) |
||||
function isNIP05(input: string): boolean { |
||||
const trimmed = input.trim(); |
||||
return /^[^@]+@[^@]+\.[^@]+$/.test(trimmed); |
||||
} |
||||
|
||||
// Search cache for profiles with matching NIP-05 |
||||
async function searchCacheForNIP05(nip05: string): Promise<string | null> { |
||||
try { |
||||
const db = await import('../../services/cache/indexeddb-store.js').then(m => m.getDB()); |
||||
const tx = db.transaction('profiles', 'readonly'); |
||||
const store = tx.store; |
||||
const profiles: any[] = []; |
||||
|
||||
let cursor = await store.openCursor(); |
||||
while (cursor) { |
||||
profiles.push(cursor.value); |
||||
cursor = await cursor.continue(); |
||||
} |
||||
await tx.done; |
||||
|
||||
const normalizedNIP05 = nip05.toLowerCase(); |
||||
for (const cached of profiles) { |
||||
const profile = parseProfile(cached.event); |
||||
if (profile.nip05) { |
||||
for (const profileNip05 of profile.nip05) { |
||||
if (profileNip05.toLowerCase() === normalizedNIP05) { |
||||
return cached.pubkey; |
||||
} |
||||
} |
||||
} |
||||
} |
||||
} catch (error) { |
||||
console.debug('Error searching cache for NIP-05:', error); |
||||
} |
||||
return null; |
||||
} |
||||
|
||||
// Resolve NIP-05 from well-known.json |
||||
async function resolveNIP05FromWellKnown(nip05: string): Promise<string | null> { |
||||
try { |
||||
const [localPart, domain] = nip05.split('@'); |
||||
if (!localPart || !domain) return null; |
||||
|
||||
const wellKnownUrl = `https://${domain}/.well-known/nostr.json?name=${encodeURIComponent(localPart)}`; |
||||
|
||||
const controller = new AbortController(); |
||||
const timeout = setTimeout(() => controller.abort(), 5000); |
||||
|
||||
try { |
||||
const response = await fetch(wellKnownUrl, { signal: controller.signal }); |
||||
clearTimeout(timeout); |
||||
|
||||
if (!response.ok) { |
||||
return null; |
||||
} |
||||
|
||||
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(); |
||||
} |
||||
} catch (fetchError) { |
||||
clearTimeout(timeout); |
||||
if (fetchError instanceof Error && fetchError.name !== 'AbortError') { |
||||
console.debug('Error fetching well-known.json:', fetchError); |
||||
} |
||||
} |
||||
} catch (error) { |
||||
console.debug('Error resolving NIP-05:', error); |
||||
} |
||||
return null; |
||||
} |
||||
|
||||
async function performSearch() { |
||||
if (!searchQuery.trim()) { |
||||
searchResults = []; |
||||
showResults = false; |
||||
filterResult = { type: null, value: null, kind: selectedKind }; |
||||
if (onFilterChange) onFilterChange(filterResult); |
||||
return; |
||||
} |
||||
|
||||
await nostrClient.initialize(); |
||||
searching = true; |
||||
resolving = true; |
||||
searchResults = []; |
||||
showResults = true; |
||||
filterResult = { type: null, value: null, kind: selectedKind }; |
||||
|
||||
try { |
||||
const query = searchQuery.trim(); |
||||
|
||||
// 1. Check if it's a hex event ID (64 hex chars) |
||||
if (/^[0-9a-f]{64}$/i.test(query)) { |
||||
const hexId = query.toLowerCase(); |
||||
let event: NostrEvent | undefined = await getEvent(hexId); |
||||
|
||||
if (!event) { |
||||
const relays = relayManager.getFeedReadRelays(); |
||||
const events = await nostrClient.fetchEvents( |
||||
[{ ids: [hexId] }], |
||||
relays, |
||||
{ useCache: false, cacheResults: true } |
||||
); |
||||
|
||||
if (events.length > 0) { |
||||
event = events[0]; |
||||
await cacheEvent(event); |
||||
} |
||||
} |
||||
|
||||
if (event) { |
||||
if (mode === 'search') { |
||||
if (hideDropdownResults && onSearchResults) { |
||||
foundEvents = [event]; |
||||
onSearchResults({ events: foundEvents, profiles: [] }); |
||||
} else { |
||||
searchResults = [{ event, matchType: 'Event ID' }]; |
||||
showResults = true; |
||||
} |
||||
} else { |
||||
filterResult = { type: 'event', value: event.id }; |
||||
if (onFilterChange) onFilterChange(filterResult); |
||||
} |
||||
searching = false; |
||||
resolving = false; |
||||
return; |
||||
} |
||||
|
||||
// Event not found, try as pubkey (step 2) |
||||
const hexPubkey = hexId.toLowerCase(); |
||||
// If kind is selected, search for events with this pubkey in p/q tags |
||||
if (selectedKind !== null && mode === 'search' && hideDropdownResults && onSearchResults) { |
||||
const relays = relayManager.getProfileReadRelays(); |
||||
const filters: any[] = [{ |
||||
kinds: [selectedKind], |
||||
limit: 100 |
||||
}]; |
||||
|
||||
// Search for events with pubkey in p or q tags |
||||
const eventsWithP = await nostrClient.fetchEvents( |
||||
filters.map(f => ({ ...f, '#p': [hexPubkey] })), |
||||
relays, |
||||
{ useCache: true, cacheResults: true, timeout: 10000 } |
||||
); |
||||
|
||||
const eventsWithQ = await nostrClient.fetchEvents( |
||||
filters.map(f => ({ ...f, '#q': [hexPubkey] })), |
||||
relays, |
||||
{ useCache: true, cacheResults: true, timeout: 10000 } |
||||
); |
||||
|
||||
// Also search by author |
||||
const eventsByAuthor = await nostrClient.fetchEvents( |
||||
filters.map(f => ({ ...f, authors: [hexPubkey] })), |
||||
relays, |
||||
{ useCache: true, cacheResults: true, timeout: 10000 } |
||||
); |
||||
|
||||
// Combine and deduplicate |
||||
const allEvents = new Map<string, NostrEvent>(); |
||||
for (const event of [...eventsWithP, ...eventsWithQ, ...eventsByAuthor]) { |
||||
allEvents.set(event.id, event); |
||||
} |
||||
|
||||
foundEvents = Array.from(allEvents.values()); |
||||
onSearchResults({ events: foundEvents, profiles: [] }); |
||||
searching = false; |
||||
resolving = false; |
||||
return; |
||||
} else if (mode === 'search') { |
||||
if (hideDropdownResults && onSearchResults) { |
||||
foundProfiles = [hexPubkey]; |
||||
onSearchResults({ events: [], profiles: foundProfiles }); |
||||
} else { |
||||
// For search mode, navigate to profile |
||||
handleProfileClick(hexPubkey); |
||||
} |
||||
searching = false; |
||||
resolving = false; |
||||
return; |
||||
} else { |
||||
filterResult = { type: 'pubkey', value: hexPubkey, kind: selectedKind }; |
||||
if (onFilterChange) onFilterChange(filterResult); |
||||
searching = false; |
||||
resolving = false; |
||||
return; |
||||
} |
||||
} |
||||
|
||||
// 3. Check npub, nprofile (resolve to hex) |
||||
if (/^(npub|nprofile)1[a-z0-9]+$/i.test(query)) { |
||||
try { |
||||
const decoded = nip19.decode(query); |
||||
let pubkey: string | null = null; |
||||
|
||||
if (decoded.type === 'npub') { |
||||
pubkey = String(decoded.data); |
||||
} else if (decoded.type === 'nprofile') { |
||||
if (decoded.data && typeof decoded.data === 'object' && 'pubkey' in decoded.data) { |
||||
pubkey = String(decoded.data.pubkey); |
||||
} |
||||
} |
||||
|
||||
if (pubkey) { |
||||
const normalizedPubkey = pubkey.toLowerCase(); |
||||
// If kind is selected, search for events with this pubkey in p/q tags |
||||
if (selectedKind !== null && mode === 'search' && hideDropdownResults && onSearchResults) { |
||||
const relays = relayManager.getProfileReadRelays(); |
||||
const filters: any[] = [{ |
||||
kinds: [selectedKind], |
||||
limit: 100 |
||||
}]; |
||||
|
||||
// Search for events with pubkey in p or q tags |
||||
const eventsWithP = await nostrClient.fetchEvents( |
||||
filters.map(f => ({ ...f, '#p': [normalizedPubkey] })), |
||||
relays, |
||||
{ useCache: true, cacheResults: true, timeout: 10000 } |
||||
); |
||||
|
||||
const eventsWithQ = await nostrClient.fetchEvents( |
||||
filters.map(f => ({ ...f, '#q': [normalizedPubkey] })), |
||||
relays, |
||||
{ useCache: true, cacheResults: true, timeout: 10000 } |
||||
); |
||||
|
||||
// Also search by author |
||||
const eventsByAuthor = await nostrClient.fetchEvents( |
||||
filters.map(f => ({ ...f, authors: [normalizedPubkey] })), |
||||
relays, |
||||
{ useCache: true, cacheResults: true, timeout: 10000 } |
||||
); |
||||
|
||||
// Combine and deduplicate |
||||
const allEvents = new Map<string, NostrEvent>(); |
||||
for (const event of [...eventsWithP, ...eventsWithQ, ...eventsByAuthor]) { |
||||
allEvents.set(event.id, event); |
||||
} |
||||
|
||||
foundEvents = Array.from(allEvents.values()); |
||||
onSearchResults({ events: foundEvents, profiles: [] }); |
||||
searching = false; |
||||
resolving = false; |
||||
return; |
||||
} else if (mode === 'search') { |
||||
if (hideDropdownResults && onSearchResults) { |
||||
foundProfiles = [normalizedPubkey]; |
||||
onSearchResults({ events: [], profiles: foundProfiles }); |
||||
} else { |
||||
handleProfileClick(normalizedPubkey); |
||||
} |
||||
searching = false; |
||||
resolving = false; |
||||
return; |
||||
} else { |
||||
filterResult = { type: 'pubkey', value: normalizedPubkey, kind: selectedKind }; |
||||
if (onFilterChange) onFilterChange(filterResult); |
||||
searching = false; |
||||
resolving = false; |
||||
return; |
||||
} |
||||
} |
||||
} catch (error) { |
||||
console.debug('Error decoding npub/nprofile:', error); |
||||
} |
||||
} |
||||
|
||||
// 4. Check NIP-05 (resolve to hex) |
||||
if (isNIP05(query)) { |
||||
resolving = true; |
||||
try { |
||||
// First check cache |
||||
const cachedPubkey = await searchCacheForNIP05(query); |
||||
if (cachedPubkey) { |
||||
const normalizedPubkey = cachedPubkey.toLowerCase(); |
||||
// If kind is selected, search for events with this pubkey in p/q tags |
||||
if (selectedKind !== null && mode === 'search' && hideDropdownResults && onSearchResults) { |
||||
const relays = relayManager.getProfileReadRelays(); |
||||
const filters: any[] = [{ |
||||
kinds: [selectedKind], |
||||
limit: 100 |
||||
}]; |
||||
|
||||
// Search for events with pubkey in p or q tags |
||||
const eventsWithP = await nostrClient.fetchEvents( |
||||
filters.map(f => ({ ...f, '#p': [normalizedPubkey] })), |
||||
relays, |
||||
{ useCache: true, cacheResults: true, timeout: 10000 } |
||||
); |
||||
|
||||
const eventsWithQ = await nostrClient.fetchEvents( |
||||
filters.map(f => ({ ...f, '#q': [normalizedPubkey] })), |
||||
relays, |
||||
{ useCache: true, cacheResults: true, timeout: 10000 } |
||||
); |
||||
|
||||
// Also search by author |
||||
const eventsByAuthor = await nostrClient.fetchEvents( |
||||
filters.map(f => ({ ...f, authors: [normalizedPubkey] })), |
||||
relays, |
||||
{ useCache: true, cacheResults: true, timeout: 10000 } |
||||
); |
||||
|
||||
// Combine and deduplicate |
||||
const allEvents = new Map<string, NostrEvent>(); |
||||
for (const event of [...eventsWithP, ...eventsWithQ, ...eventsByAuthor]) { |
||||
allEvents.set(event.id, event); |
||||
} |
||||
|
||||
foundEvents = Array.from(allEvents.values()); |
||||
onSearchResults({ events: foundEvents, profiles: [] }); |
||||
searching = false; |
||||
resolving = false; |
||||
return; |
||||
} else if (mode === 'search') { |
||||
if (hideDropdownResults && onSearchResults) { |
||||
foundProfiles = [normalizedPubkey]; |
||||
onSearchResults({ events: [], profiles: foundProfiles }); |
||||
} else { |
||||
handleProfileClick(normalizedPubkey); |
||||
} |
||||
searching = false; |
||||
resolving = false; |
||||
return; |
||||
} else { |
||||
filterResult = { type: 'pubkey', value: normalizedPubkey, kind: selectedKind }; |
||||
if (onFilterChange) onFilterChange(filterResult); |
||||
searching = false; |
||||
resolving = false; |
||||
return; |
||||
} |
||||
} |
||||
|
||||
// Then check well-known.json |
||||
const wellKnownPubkey = await resolveNIP05FromWellKnown(query); |
||||
if (wellKnownPubkey) { |
||||
const normalizedPubkey = wellKnownPubkey.toLowerCase(); |
||||
// If kind is selected, search for events with this pubkey in p/q tags |
||||
if (selectedKind !== null && mode === 'search' && hideDropdownResults && onSearchResults) { |
||||
const relays = relayManager.getProfileReadRelays(); |
||||
const filters: any[] = [{ |
||||
kinds: [selectedKind], |
||||
limit: 100 |
||||
}]; |
||||
|
||||
// Search for events with pubkey in p or q tags |
||||
const eventsWithP = await nostrClient.fetchEvents( |
||||
filters.map(f => ({ ...f, '#p': [normalizedPubkey] })), |
||||
relays, |
||||
{ useCache: true, cacheResults: true, timeout: 10000 } |
||||
); |
||||
|
||||
const eventsWithQ = await nostrClient.fetchEvents( |
||||
filters.map(f => ({ ...f, '#q': [normalizedPubkey] })), |
||||
relays, |
||||
{ useCache: true, cacheResults: true, timeout: 10000 } |
||||
); |
||||
|
||||
// Also search by author |
||||
const eventsByAuthor = await nostrClient.fetchEvents( |
||||
filters.map(f => ({ ...f, authors: [normalizedPubkey] })), |
||||
relays, |
||||
{ useCache: true, cacheResults: true, timeout: 10000 } |
||||
); |
||||
|
||||
// Combine and deduplicate |
||||
const allEvents = new Map<string, NostrEvent>(); |
||||
for (const event of [...eventsWithP, ...eventsWithQ, ...eventsByAuthor]) { |
||||
allEvents.set(event.id, event); |
||||
} |
||||
|
||||
foundEvents = Array.from(allEvents.values()); |
||||
onSearchResults({ events: foundEvents, profiles: [] }); |
||||
searching = false; |
||||
resolving = false; |
||||
return; |
||||
} else if (mode === 'search') { |
||||
if (hideDropdownResults && onSearchResults) { |
||||
foundProfiles = [normalizedPubkey]; |
||||
onSearchResults({ events: [], profiles: foundProfiles }); |
||||
} else { |
||||
handleProfileClick(normalizedPubkey); |
||||
} |
||||
searching = false; |
||||
resolving = false; |
||||
return; |
||||
} else { |
||||
filterResult = { type: 'pubkey', value: normalizedPubkey, kind: selectedKind }; |
||||
if (onFilterChange) onFilterChange(filterResult); |
||||
searching = false; |
||||
resolving = false; |
||||
return; |
||||
} |
||||
} |
||||
} catch (error) { |
||||
console.debug('Error resolving NIP-05:', error); |
||||
} |
||||
resolving = false; |
||||
} |
||||
|
||||
// 5. Check note, nevent, naddr (resolve to hex event ID) |
||||
if (/^(note|nevent|naddr)1[a-z0-9]+$/i.test(query)) { |
||||
try { |
||||
const decoded = nip19.decode(query); |
||||
let eventId: string | null = null; |
||||
|
||||
if (decoded.type === 'note') { |
||||
eventId = String(decoded.data); |
||||
} else if (decoded.type === 'nevent') { |
||||
if (decoded.data && typeof decoded.data === 'object' && 'id' in decoded.data) { |
||||
eventId = String(decoded.data.id); |
||||
} |
||||
} else if (decoded.type === 'naddr') { |
||||
// naddr is more complex, would need kind+pubkey+d to fetch |
||||
// For now, we'll skip it and treat as text search |
||||
} |
||||
|
||||
if (eventId) { |
||||
let event: NostrEvent | undefined = await getEvent(eventId); |
||||
|
||||
if (!event) { |
||||
const relays = relayManager.getFeedReadRelays(); |
||||
const events = await nostrClient.fetchEvents( |
||||
[{ ids: [eventId] }], |
||||
relays, |
||||
{ useCache: false, cacheResults: true } |
||||
); |
||||
|
||||
if (events.length > 0) { |
||||
event = events[0]; |
||||
await cacheEvent(event); |
||||
} |
||||
} |
||||
|
||||
if (event) { |
||||
if (mode === 'search') { |
||||
if (hideDropdownResults && onSearchResults) { |
||||
foundEvents = [event]; |
||||
onSearchResults({ events: foundEvents, profiles: [] }); |
||||
} else { |
||||
searchResults = [{ event, matchType: 'Event ID' }]; |
||||
showResults = true; |
||||
} |
||||
} else { |
||||
filterResult = { type: 'event', value: event.id, kind: selectedKind }; |
||||
if (onFilterChange) onFilterChange(filterResult); |
||||
} |
||||
searching = false; |
||||
resolving = false; |
||||
return; |
||||
} |
||||
} |
||||
} catch (error) { |
||||
console.debug('Error decoding note/nevent/naddr:', error); |
||||
} |
||||
} |
||||
|
||||
// 6. Anything else is a full-text search |
||||
if (mode === 'search') { |
||||
// Text search in cached events (title, summary, content) |
||||
const allCached: NostrEvent[] = []; |
||||
|
||||
// If kind filter is selected, only search that kind |
||||
if (selectedKind !== null) { |
||||
const kindEvents = await getEventsByKind(selectedKind, 100); |
||||
allCached.push(...kindEvents); |
||||
} else { |
||||
// Search all kinds we handle |
||||
const kindsToSearch = Object.keys(KIND_LOOKUP).map(k => parseInt(k)).filter(k => !KIND_LOOKUP[k].isSecondaryKind); |
||||
for (const kind of kindsToSearch) { |
||||
try { |
||||
const kindEvents = await getEventsByKind(kind, 50); |
||||
allCached.push(...kindEvents); |
||||
} catch (e) { |
||||
// Skip kinds that fail |
||||
} |
||||
} |
||||
} |
||||
|
||||
const queryLower = query.toLowerCase(); |
||||
const matches = allCached.filter(event => { |
||||
const contentMatch = event.content.toLowerCase().includes(queryLower); |
||||
|
||||
const titleTag = event.tags.find(t => t[0] === 'title'); |
||||
const titleMatch = titleTag?.[1]?.toLowerCase().includes(queryLower) || false; |
||||
|
||||
const summaryTag = event.tags.find(t => t[0] === 'summary'); |
||||
const summaryMatch = summaryTag?.[1]?.toLowerCase().includes(queryLower) || false; |
||||
|
||||
return contentMatch || titleMatch || summaryMatch; |
||||
}); |
||||
|
||||
const sorted = matches.sort((a, b) => { |
||||
const aExact = a.content.toLowerCase() === queryLower; |
||||
const bExact = b.content.toLowerCase() === queryLower; |
||||
if (aExact && !bExact) return -1; |
||||
if (!aExact && bExact) return 1; |
||||
return b.created_at - a.created_at; |
||||
}); |
||||
|
||||
const limitedResults = sorted.slice(0, 20); |
||||
|
||||
if (hideDropdownResults && onSearchResults) { |
||||
foundEvents = limitedResults; |
||||
onSearchResults({ events: foundEvents, profiles: [] }); |
||||
} else { |
||||
searchResults = limitedResults.map(e => ({ event: e, matchType: 'Content' })); |
||||
showResults = true; |
||||
} |
||||
} else { |
||||
// Filter mode: treat as text search |
||||
filterResult = { type: 'text', value: query, kind: selectedKind }; |
||||
if (onFilterChange) onFilterChange(filterResult); |
||||
} |
||||
} catch (error) { |
||||
console.error('Search error:', error); |
||||
} finally { |
||||
searching = false; |
||||
resolving = false; |
||||
} |
||||
} |
||||
|
||||
function handleSearchInput(e: Event) { |
||||
const target = e.target as HTMLInputElement; |
||||
const newValue = target.value; |
||||
searchQuery = newValue; |
||||
|
||||
if (searchTimeout) { |
||||
clearTimeout(searchTimeout); |
||||
} |
||||
|
||||
if (newValue.trim()) { |
||||
searching = true; |
||||
searchTimeout = setTimeout(() => { |
||||
performSearch(); |
||||
}, 300); |
||||
} else { |
||||
searchResults = []; |
||||
showResults = false; |
||||
searching = false; |
||||
filterResult = { type: null, value: null, kind: selectedKind }; |
||||
if (onFilterChange) onFilterChange(filterResult); |
||||
} |
||||
} |
||||
|
||||
function handleKeyDown(e: KeyboardEvent) { |
||||
if (e.key === 'Enter') { |
||||
if (searchTimeout) { |
||||
clearTimeout(searchTimeout); |
||||
} |
||||
performSearch(); |
||||
} else if (e.key === 'Escape') { |
||||
showResults = false; |
||||
searchQuery = ''; |
||||
filterResult = { type: null, value: null, kind: selectedKind }; |
||||
if (onFilterChange) onFilterChange(filterResult); |
||||
} |
||||
} |
||||
|
||||
function handleResultClick(event: NostrEvent) { |
||||
showResults = false; |
||||
searchQuery = ''; |
||||
goto(`/event/${event.id}`); |
||||
} |
||||
|
||||
function handleProfileClick(pubkey: string) { |
||||
showResults = false; |
||||
searchQuery = ''; |
||||
goto(`/profile/${pubkey}`); |
||||
} |
||||
|
||||
// Close results when clicking outside |
||||
$effect(() => { |
||||
if (showResults) { |
||||
const handleClickOutside = (e: MouseEvent) => { |
||||
const target = e.target as HTMLElement; |
||||
if (!target.closest('.unified-search-container')) { |
||||
showResults = false; |
||||
} |
||||
}; |
||||
|
||||
document.addEventListener('click', handleClickOutside, true); |
||||
return () => { |
||||
document.removeEventListener('click', handleClickOutside, true); |
||||
}; |
||||
} |
||||
}); |
||||
|
||||
// Cleanup timeout on unmount |
||||
$effect(() => { |
||||
return () => { |
||||
if (searchTimeout) { |
||||
clearTimeout(searchTimeout); |
||||
} |
||||
}; |
||||
}); |
||||
|
||||
// Expose filter result for parent components (filter mode) |
||||
export function getFilterResult(): { type: 'event' | 'pubkey' | 'text' | null; value: string | null; kind?: number | null } { |
||||
return filterResult; |
||||
} |
||||
|
||||
// Expose performSearch for manual trigger |
||||
export function triggerSearch() { |
||||
performSearch(); |
||||
} |
||||
|
||||
// Note: filterResult kind is updated in performSearch, not here to avoid loops |
||||
|
||||
// Get all kinds for dropdown (sorted by number) |
||||
let allKinds = $derived(Object.values(KIND_LOOKUP).sort((a, b) => a.number - b.number)); |
||||
|
||||
function handleKindChange(e: Event) { |
||||
const select = e.target as HTMLSelectElement; |
||||
const kind = select.value === '' ? null : parseInt(select.value); |
||||
if (onKindChange) { |
||||
onKindChange(kind); |
||||
} |
||||
// Update filter result |
||||
filterResult = { ...filterResult, kind }; |
||||
if (onFilterChange) { |
||||
onFilterChange(filterResult); |
||||
} |
||||
// Re-run search if there's a query |
||||
if (searchQuery.trim()) { |
||||
performSearch(); |
||||
} |
||||
} |
||||
</script> |
||||
|
||||
<div class="unified-search-container"> |
||||
<div class="search-input-wrapper"> |
||||
{#if showKindFilter} |
||||
<select |
||||
value={selectedKind?.toString() || ''} |
||||
onchange={handleKindChange} |
||||
class="kind-filter-select" |
||||
aria-label="Filter by kind" |
||||
> |
||||
<option value="">All Kinds</option> |
||||
{#each allKinds as kindInfo} |
||||
<option value={kindInfo.number}>{kindInfo.number}: {kindInfo.description}</option> |
||||
{/each} |
||||
</select> |
||||
{/if} |
||||
<input |
||||
bind:this={searchInput} |
||||
type="text" |
||||
placeholder={placeholder} |
||||
value={searchQuery} |
||||
oninput={handleSearchInput} |
||||
onkeydown={handleKeyDown} |
||||
class="search-input" |
||||
class:resolving={resolving} |
||||
class:with-kind-filter={showKindFilter} |
||||
aria-label="Search" |
||||
/> |
||||
{#if searching || resolving} |
||||
<span class="search-loading">⟳</span> |
||||
{/if} |
||||
</div> |
||||
|
||||
{#if mode === 'search' && !hideDropdownResults && showResults && searchResults.length > 0} |
||||
<div class="search-results"> |
||||
{#each searchResults as { event, matchType }} |
||||
<button |
||||
onclick={() => { |
||||
if (event.kind === KIND.METADATA) { |
||||
handleProfileClick(event.pubkey); |
||||
} else { |
||||
handleResultClick(event); |
||||
} |
||||
}} |
||||
class="search-result-item" |
||||
> |
||||
<div class="search-result-header"> |
||||
<span class="search-result-type">{matchType}</span> |
||||
<span class="search-result-id">{event.id.substring(0, 16)}...</span> |
||||
</div> |
||||
<div class="search-result-content"> |
||||
{event.content.substring(0, 100)}{event.content.length > 100 ? '...' : ''} |
||||
</div> |
||||
<div class="search-result-meta"> |
||||
Kind {event.kind} • {new Date(event.created_at * 1000).toLocaleDateString()} |
||||
</div> |
||||
</button> |
||||
{/each} |
||||
</div> |
||||
{:else if mode === 'search' && !hideDropdownResults && showResults && !searching && searchQuery.trim()} |
||||
<div class="search-results"> |
||||
<div class="search-no-results">No results found</div> |
||||
</div> |
||||
{/if} |
||||
</div> |
||||
|
||||
<style> |
||||
.unified-search-container { |
||||
position: relative; |
||||
width: 100%; |
||||
max-width: 600px; |
||||
} |
||||
|
||||
.search-input-wrapper { |
||||
position: relative; |
||||
display: flex; |
||||
align-items: center; |
||||
gap: 0.5rem; |
||||
} |
||||
|
||||
.kind-filter-select { |
||||
padding: 0.75rem; |
||||
border: 1px solid var(--fog-border, #e5e7eb); |
||||
border-radius: 0.375rem; |
||||
background: var(--fog-post, #ffffff); |
||||
color: var(--fog-text, #1f2937); |
||||
font-size: 0.875rem; |
||||
font-family: monospace; |
||||
cursor: pointer; |
||||
min-width: 150px; |
||||
} |
||||
|
||||
.kind-filter-select:focus { |
||||
outline: none; |
||||
border-color: var(--fog-accent, #64748b); |
||||
box-shadow: 0 0 0 3px rgba(100, 116, 139, 0.1); |
||||
} |
||||
|
||||
:global(.dark) .kind-filter-select { |
||||
border-color: var(--fog-dark-border, #374151); |
||||
background: var(--fog-dark-post, #1f2937); |
||||
color: var(--fog-dark-text, #f9fafb); |
||||
} |
||||
|
||||
:global(.dark) .kind-filter-select:focus { |
||||
border-color: var(--fog-dark-accent, #94a3b8); |
||||
box-shadow: 0 0 0 3px rgba(148, 163, 184, 0.1); |
||||
} |
||||
|
||||
.search-input.with-kind-filter { |
||||
flex: 1; |
||||
} |
||||
|
||||
.search-input { |
||||
width: 100%; |
||||
padding: 0.75rem 1rem; |
||||
border: 1px solid var(--fog-border, #e5e7eb); |
||||
border-radius: 0.375rem; |
||||
background: var(--fog-post, #ffffff); |
||||
color: var(--fog-text, #1f2937); |
||||
font-size: 0.875rem; |
||||
transition: all 0.2s; |
||||
} |
||||
|
||||
.search-input:focus { |
||||
outline: none; |
||||
border-color: var(--fog-accent, #64748b); |
||||
box-shadow: 0 0 0 3px rgba(100, 116, 139, 0.1); |
||||
} |
||||
|
||||
.search-input.resolving { |
||||
border-color: var(--fog-accent, #64748b); |
||||
} |
||||
|
||||
:global(.dark) .search-input { |
||||
background: var(--fog-dark-post, #1f2937); |
||||
border-color: var(--fog-dark-border, #374151); |
||||
color: var(--fog-dark-text, #f9fafb); |
||||
} |
||||
|
||||
:global(.dark) .search-input:focus { |
||||
border-color: var(--fog-dark-accent, #94a3b8); |
||||
box-shadow: 0 0 0 3px rgba(148, 163, 184, 0.1); |
||||
} |
||||
|
||||
.search-loading { |
||||
position: absolute; |
||||
right: 1rem; |
||||
color: var(--fog-text-light, #9ca3af); |
||||
animation: spin 1s linear infinite; |
||||
} |
||||
|
||||
@keyframes spin { |
||||
from { transform: rotate(0deg); } |
||||
to { transform: rotate(360deg); } |
||||
} |
||||
|
||||
.search-results { |
||||
position: absolute; |
||||
top: 100%; |
||||
left: 0; |
||||
right: 0; |
||||
margin-top: 0.25rem; |
||||
background: var(--fog-post, #ffffff); |
||||
border: 1px solid var(--fog-border, #e5e7eb); |
||||
border-radius: 0.375rem; |
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); |
||||
max-height: 400px; |
||||
overflow-y: auto; |
||||
z-index: 1000; |
||||
} |
||||
|
||||
:global(.dark) .search-results { |
||||
background: var(--fog-dark-post, #1f2937); |
||||
border-color: var(--fog-dark-border, #374151); |
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); |
||||
} |
||||
|
||||
.search-result-item { |
||||
width: 100%; |
||||
padding: 0.75rem; |
||||
border: none; |
||||
background: transparent; |
||||
text-align: left; |
||||
cursor: pointer; |
||||
transition: background 0.2s; |
||||
border-bottom: 1px solid var(--fog-border, #e5e7eb); |
||||
} |
||||
|
||||
.search-result-item:last-child { |
||||
border-bottom: none; |
||||
} |
||||
|
||||
.search-result-item:hover { |
||||
background: var(--fog-highlight, #f3f4f6); |
||||
} |
||||
|
||||
:global(.dark) .search-result-item { |
||||
border-bottom-color: var(--fog-dark-border, #374151); |
||||
} |
||||
|
||||
:global(.dark) .search-result-item:hover { |
||||
background: var(--fog-dark-highlight, #374151); |
||||
} |
||||
|
||||
.search-result-header { |
||||
display: flex; |
||||
justify-content: space-between; |
||||
align-items: center; |
||||
margin-bottom: 0.25rem; |
||||
} |
||||
|
||||
.search-result-type { |
||||
font-size: 0.75rem; |
||||
font-weight: 600; |
||||
color: var(--fog-accent, #64748b); |
||||
text-transform: uppercase; |
||||
} |
||||
|
||||
:global(.dark) .search-result-type { |
||||
color: var(--fog-dark-accent, #94a3b8); |
||||
} |
||||
|
||||
.search-result-id { |
||||
font-size: 0.75rem; |
||||
font-family: monospace; |
||||
color: var(--fog-text-light, #9ca3af); |
||||
} |
||||
|
||||
:global(.dark) .search-result-id { |
||||
color: var(--fog-dark-text-light, #6b7280); |
||||
} |
||||
|
||||
.search-result-content { |
||||
font-size: 0.875rem; |
||||
color: var(--fog-text, #1f2937); |
||||
margin-bottom: 0.25rem; |
||||
line-height: 1.4; |
||||
} |
||||
|
||||
:global(.dark) .search-result-content { |
||||
color: var(--fog-dark-text, #f9fafb); |
||||
} |
||||
|
||||
.search-result-meta { |
||||
font-size: 0.75rem; |
||||
color: var(--fog-text-light, #9ca3af); |
||||
} |
||||
|
||||
:global(.dark) .search-result-meta { |
||||
color: var(--fog-dark-text-light, #6b7280); |
||||
} |
||||
|
||||
.search-no-results { |
||||
padding: 1rem; |
||||
text-align: center; |
||||
color: var(--fog-text-light, #9ca3af); |
||||
font-size: 0.875rem; |
||||
} |
||||
|
||||
:global(.dark) .search-no-results { |
||||
color: var(--fog-dark-text-light, #6b7280); |
||||
} |
||||
</style> |
||||
@ -1,561 +0,0 @@
@@ -1,561 +0,0 @@
|
||||
<script lang="ts"> |
||||
import FeedPost from './FeedPost.svelte'; |
||||
import CommentThread from '../comments/CommentThread.svelte'; |
||||
import { nostrClient } from '../../services/nostr/nostr-client.js'; |
||||
import { relayManager } from '../../services/nostr/relay-manager.js'; |
||||
import { config } from '../../services/nostr/config.js'; |
||||
import { buildEventHierarchy, getHierarchyChain } from '../../services/nostr/event-hierarchy.js'; |
||||
import { onMount } from 'svelte'; |
||||
import type { NostrEvent } from '../../types/nostr.js'; |
||||
import { KIND } from '../../types/kind-lookup.js'; |
||||
|
||||
interface Props { |
||||
opEvent: NostrEvent | null; |
||||
isOpen: boolean; |
||||
onClose: () => void; |
||||
} |
||||
|
||||
let { opEvent, isOpen, onClose }: Props = $props(); |
||||
|
||||
let drawerElement: HTMLElement | null = $state(null); |
||||
let loading = $state(false); |
||||
let subscriptionId: string | null = $state(null); |
||||
let isInitialized = $state(false); |
||||
let hierarchyChain = $state<NostrEvent[]>([]); |
||||
let currentLoadEventId = $state<string | null>(null); // Track which event is currently being loaded |
||||
let lastOpEventId = $state<string | null>(null); // Track the last event ID to prevent reactive loops |
||||
let loadFullThread = $state(false); // Only load full thread when "View thread" is clicked |
||||
|
||||
// Batch-loaded reactions and zaps for all events in thread |
||||
let reactionsByEventId = $state<Map<string, NostrEvent[]>>(new Map()); |
||||
let zapsByEventId = $state<Map<string, NostrEvent[]>>(new Map()); |
||||
let loadingReactions = $state(false); |
||||
|
||||
// Derive event ID separately to avoid reactive loops from object reference changes |
||||
let opEventId = $derived(opEvent?.id || null); |
||||
|
||||
// Initialize nostr client once |
||||
onMount(async () => { |
||||
if (!isInitialized) { |
||||
await nostrClient.initialize(); |
||||
isInitialized = true; |
||||
} |
||||
}); |
||||
|
||||
// Build event hierarchy when drawer opens |
||||
async function loadHierarchy(abortSignal: AbortSignal, eventId: string) { |
||||
if (!opEvent || !isInitialized) { |
||||
console.warn('loadHierarchy: opEvent or isInitialized missing', { opEvent: !!opEvent, isInitialized }); |
||||
loading = false; |
||||
return; |
||||
} |
||||
|
||||
// If we're already loading this event, don't start another load |
||||
if (currentLoadEventId === eventId && loading) { |
||||
console.log('loadHierarchy: Already loading this event', eventId); |
||||
return; |
||||
} |
||||
|
||||
currentLoadEventId = eventId; |
||||
loading = true; |
||||
console.log('loadHierarchy: Starting load for event', eventId); |
||||
|
||||
try { |
||||
// Add timeout to prevent hanging - use longer timeout for hierarchy building |
||||
// Hierarchy building can take time on slow connections as it fetches parent events recursively |
||||
// Use singleRelayTimeout (15s) as hierarchy building is similar in complexity |
||||
const timeoutPromise = new Promise((_, reject) => { |
||||
setTimeout(() => reject(new Error('buildEventHierarchy timeout - loading thread hierarchy took too long')), config.singleRelayTimeout); |
||||
}); |
||||
|
||||
const hierarchy = await Promise.race([ |
||||
buildEventHierarchy(opEvent), |
||||
timeoutPromise |
||||
]) as Awaited<ReturnType<typeof buildEventHierarchy>>; |
||||
|
||||
// Check if operation was aborted or event changed |
||||
if (abortSignal.aborted || currentLoadEventId !== eventId) { |
||||
console.log('loadHierarchy: Aborted or event changed', { aborted: abortSignal.aborted, currentId: currentLoadEventId, eventId }); |
||||
return; |
||||
} |
||||
|
||||
const chain = getHierarchyChain(hierarchy); |
||||
console.log('loadHierarchy: Got hierarchy chain', chain.length, 'events'); |
||||
|
||||
// Check again before final state update |
||||
if (abortSignal.aborted || currentLoadEventId !== eventId) { |
||||
console.log('loadHierarchy: Aborted before setting chain'); |
||||
return; |
||||
} |
||||
|
||||
hierarchyChain = chain; |
||||
|
||||
// After hierarchy is loaded, batch fetch reactions and zaps for all events |
||||
if (!abortSignal.aborted && currentLoadEventId === eventId) { |
||||
batchLoadReactionsAndZaps(chain); |
||||
} |
||||
} catch (error) { |
||||
// Only update state if not aborted and still loading this event |
||||
if (abortSignal.aborted || currentLoadEventId !== eventId) { |
||||
console.log('loadHierarchy: Error but aborted or event changed'); |
||||
return; |
||||
} |
||||
|
||||
console.error('Error building event hierarchy:', error); |
||||
hierarchyChain = [opEvent]; // Fallback to just the event |
||||
|
||||
// Still try to batch load reactions/zaps for the single event |
||||
if (!abortSignal.aborted && currentLoadEventId === eventId) { |
||||
batchLoadReactionsAndZaps([opEvent]); |
||||
} |
||||
} finally { |
||||
// Only update loading state if not aborted and still loading this event |
||||
if (!abortSignal.aborted && currentLoadEventId === eventId) { |
||||
loading = false; |
||||
console.log('loadHierarchy: Finished loading', eventId); |
||||
} else { |
||||
console.log('loadHierarchy: Not updating loading state', { aborted: abortSignal.aborted, currentId: currentLoadEventId, eventId }); |
||||
} |
||||
} |
||||
} |
||||
|
||||
// Batch fetch reactions and zaps for all events in the thread |
||||
async function batchLoadReactionsAndZaps(events: NostrEvent[]) { |
||||
if (loadingReactions || events.length === 0) return; |
||||
|
||||
loadingReactions = true; |
||||
try { |
||||
// Collect all event IDs from hierarchy |
||||
const allEventIds = new Set<string>(); |
||||
for (const event of events) { |
||||
allEventIds.add(event.id); |
||||
} |
||||
|
||||
await batchLoadReactionsForEvents(Array.from(allEventIds)); |
||||
} catch (error) { |
||||
console.error('[ThreadDrawer] Error batch loading reactions/zaps:', error); |
||||
} finally { |
||||
loadingReactions = false; |
||||
} |
||||
} |
||||
|
||||
// Batch fetch reactions and zaps for a set of event IDs |
||||
async function batchLoadReactionsForEvents(eventIds: string[]) { |
||||
if (eventIds.length === 0) return; |
||||
|
||||
const allEventIds = new Set([...Array.from(reactionsByEventId.keys()), ...eventIds]); |
||||
const eventIdsArray = Array.from(allEventIds); |
||||
|
||||
const reactionRelays = relayManager.getProfileReadRelays(); |
||||
|
||||
// Batch fetch reactions for all events |
||||
const reactionsWithLowerE = await nostrClient.fetchEvents( |
||||
[{ kinds: [KIND.REACTION], '#e': eventIdsArray, limit: config.feedLimit }], |
||||
reactionRelays, |
||||
{ useCache: true, cacheResults: true, timeout: config.mediumTimeout, priority: 'low' } |
||||
); |
||||
const reactionsWithUpperE = await nostrClient.fetchEvents( |
||||
[{ kinds: [KIND.REACTION], '#E': eventIdsArray, limit: config.feedLimit }], |
||||
reactionRelays, |
||||
{ useCache: true, cacheResults: true, timeout: config.mediumTimeout, priority: 'low' } |
||||
); |
||||
|
||||
// Group reactions by event ID |
||||
const reactionsMap = new Map(reactionsByEventId); |
||||
for (const reaction of [...reactionsWithLowerE, ...reactionsWithUpperE]) { |
||||
// Find which event this reaction is for |
||||
const eTag = reaction.tags.find(t => t[0] === 'e' || t[0] === 'E'); |
||||
if (eTag && eTag[1] && allEventIds.has(eTag[1])) { |
||||
const eventId = eTag[1]; |
||||
if (!reactionsMap.has(eventId)) { |
||||
reactionsMap.set(eventId, []); |
||||
} |
||||
reactionsMap.get(eventId)!.push(reaction); |
||||
} |
||||
} |
||||
|
||||
// Batch fetch zap receipts for all events |
||||
const zapReceipts = await nostrClient.fetchEvents( |
||||
[{ kinds: [KIND.ZAP_RECEIPT], '#e': eventIdsArray, limit: config.feedLimit }], |
||||
reactionRelays, |
||||
{ useCache: true, cacheResults: true, timeout: config.mediumTimeout, priority: 'low' } |
||||
); |
||||
|
||||
// Group zap receipts by event ID |
||||
const zapsMap = new Map(zapsByEventId); |
||||
for (const zap of zapReceipts) { |
||||
const eTag = zap.tags.find(t => t[0] === 'e'); |
||||
if (eTag && eTag[1] && allEventIds.has(eTag[1])) { |
||||
const eventId = eTag[1]; |
||||
if (!zapsMap.has(eventId)) { |
||||
zapsMap.set(eventId, []); |
||||
} |
||||
zapsMap.get(eventId)!.push(zap); |
||||
} |
||||
} |
||||
|
||||
reactionsByEventId = reactionsMap; |
||||
zapsByEventId = zapsMap; |
||||
} |
||||
|
||||
// Function to trigger full thread loading |
||||
function loadFullThreadHierarchy() { |
||||
if (!opEvent || !isInitialized || loadFullThread) return; |
||||
|
||||
loadFullThread = true; |
||||
const eventId = opEvent.id; |
||||
|
||||
// Create abort controller to track operation lifecycle |
||||
const abortController = new AbortController(); |
||||
|
||||
// Load hierarchy with abort signal |
||||
loadHierarchy(abortController.signal, eventId).catch((error) => { |
||||
// Handle any unhandled promise rejections |
||||
if (!abortController.signal.aborted) { |
||||
console.error('ThreadDrawer: Unhandled error in loadHierarchy', error); |
||||
loading = false; |
||||
hierarchyChain = opEvent ? [opEvent] : []; |
||||
} |
||||
}); |
||||
} |
||||
|
||||
// Handle drawer open/close - reset state when opening/closing |
||||
$effect(() => { |
||||
const eventId = opEventId; |
||||
|
||||
// Only proceed if event ID actually changed or drawer state changed |
||||
if (!isOpen || !eventId || !isInitialized || !opEvent) { |
||||
// Drawer closed or no event - cleanup |
||||
if (!isOpen || !eventId) { |
||||
currentLoadEventId = null; |
||||
lastOpEventId = null; |
||||
loadFullThread = false; |
||||
if (subscriptionId) { |
||||
nostrClient.unsubscribe(subscriptionId); |
||||
subscriptionId = null; |
||||
} |
||||
hierarchyChain = []; |
||||
loading = false; |
||||
reactionsByEventId.clear(); |
||||
zapsByEventId.clear(); |
||||
} else if (!opEvent) { |
||||
// Event ID exists but opEvent is null - might be loading, set loading to false |
||||
console.warn('ThreadDrawer: opEvent is null but eventId exists', eventId); |
||||
loading = false; |
||||
} |
||||
return; |
||||
} |
||||
|
||||
// Reset loadFullThread when event changes |
||||
if (lastOpEventId !== eventId) { |
||||
loadFullThread = false; |
||||
hierarchyChain = []; |
||||
lastOpEventId = eventId; |
||||
|
||||
// Load reactions for the single event when drawer opens |
||||
if (opEvent) { |
||||
batchLoadReactionsForEvents([opEvent.id]); |
||||
} |
||||
} |
||||
|
||||
// Don't auto-load hierarchy - only load when "View thread" is clicked |
||||
// The effect just handles cleanup and state reset |
||||
}); |
||||
|
||||
// Handle keyboard events |
||||
function handleKeyDown(e: KeyboardEvent) { |
||||
if (e.key === 'Escape' && isOpen) { |
||||
onClose(); |
||||
} |
||||
} |
||||
|
||||
// Handle backdrop click |
||||
function handleBackdropClick(e: MouseEvent) { |
||||
if (e.target === e.currentTarget) { |
||||
onClose(); |
||||
} |
||||
} |
||||
|
||||
// Prevent body scroll when drawer is open |
||||
$effect(() => { |
||||
if (isOpen) { |
||||
document.body.style.overflow = 'hidden'; |
||||
return () => { |
||||
document.body.style.overflow = ''; |
||||
}; |
||||
} |
||||
}); |
||||
</script> |
||||
|
||||
{#if isOpen && opEvent} |
||||
<div |
||||
class="drawer-backdrop" |
||||
onclick={handleBackdropClick} |
||||
onkeydown={handleKeyDown} |
||||
role="button" |
||||
tabindex="0" |
||||
aria-label="Close thread drawer" |
||||
></div> |
||||
<div |
||||
class="thread-drawer drawer-right" |
||||
bind:this={drawerElement} |
||||
onkeydown={handleKeyDown} |
||||
role="dialog" |
||||
aria-modal="true" |
||||
aria-label="Thread drawer" |
||||
tabindex="-1" |
||||
> |
||||
<div class="drawer-header"> |
||||
<h3 class="drawer-title">Thread</h3> |
||||
<button |
||||
onclick={onClose} |
||||
class="drawer-close" |
||||
aria-label="Close thread drawer" |
||||
title="Close" |
||||
> |
||||
× |
||||
</button> |
||||
</div> |
||||
|
||||
<div class="drawer-content"> |
||||
{#if loading} |
||||
<div class="loading-state"> |
||||
<p class="text-fog-text-light dark:text-fog-dark-text-light">Loading thread...</p> |
||||
</div> |
||||
{:else} |
||||
<div class="thread-content"> |
||||
{#if !loadFullThread} |
||||
<!-- Single event view - show just the event with "View thread" button --> |
||||
<div class="op-post"> |
||||
<FeedPost |
||||
post={opEvent} |
||||
fullView={true} |
||||
preloadedReactions={reactionsByEventId.get(opEvent.id)} |
||||
/> |
||||
</div> |
||||
<div class="view-thread-prompt"> |
||||
<button |
||||
onclick={loadFullThreadHierarchy} |
||||
class="view-thread-btn text-fog-accent dark:text-fog-dark-accent hover:underline" |
||||
style="font-size: 1em; padding: 0.5rem 1rem; margin: 1rem 0;" |
||||
> |
||||
View thread |
||||
</button> |
||||
</div> |
||||
{:else if hierarchyChain.length > 0} |
||||
<!-- Display full event hierarchy (root to leaf) --> |
||||
{#each hierarchyChain as parentEvent, index (parentEvent.id)} |
||||
<div class="hierarchy-post"> |
||||
{#if index > 0} |
||||
<div class="hierarchy-divider"> |
||||
<span class="hierarchy-label">Replying to:</span> |
||||
</div> |
||||
{/if} |
||||
<FeedPost |
||||
post={parentEvent} |
||||
fullView={true} |
||||
preloadedReactions={reactionsByEventId.get(parentEvent.id)} |
||||
/> |
||||
</div> |
||||
{/each} |
||||
{:else} |
||||
<!-- Fallback: just show the event --> |
||||
<div class="op-post"> |
||||
<FeedPost |
||||
post={opEvent} |
||||
fullView={true} |
||||
preloadedReactions={reactionsByEventId.get(opEvent.id)} |
||||
/> |
||||
</div> |
||||
{/if} |
||||
|
||||
<!-- Display comments/replies only when full thread is loaded --> |
||||
{#if loadFullThread} |
||||
<div class="comments-section"> |
||||
<CommentThread |
||||
threadId={opEvent.id} |
||||
event={opEvent} |
||||
preloadedReactions={reactionsByEventId} |
||||
onCommentsLoaded={(commentEventIds) => { |
||||
// When comments are loaded, also batch fetch reactions for them |
||||
if (commentEventIds.length > 0) { |
||||
batchLoadReactionsForEvents(commentEventIds); |
||||
} |
||||
}} |
||||
/> |
||||
</div> |
||||
{/if} |
||||
</div> |
||||
{/if} |
||||
</div> |
||||
</div> |
||||
{/if} |
||||
|
||||
<style> |
||||
.drawer-backdrop { |
||||
position: fixed; |
||||
top: 0; |
||||
left: 0; |
||||
right: 0; |
||||
bottom: 0; |
||||
background: rgba(0, 0, 0, 0.5); |
||||
z-index: 999; |
||||
animation: fadeIn 0.3s ease-out; |
||||
} |
||||
|
||||
@keyframes fadeIn { |
||||
from { |
||||
opacity: 0; |
||||
} |
||||
to { |
||||
opacity: 1; |
||||
} |
||||
} |
||||
|
||||
.thread-drawer { |
||||
position: fixed; |
||||
top: 0; |
||||
right: 0; |
||||
bottom: 0; |
||||
width: min(600px, 90vw); |
||||
max-width: 600px; |
||||
background: var(--fog-post, #ffffff); |
||||
border-left: 2px solid var(--fog-border, #cbd5e1); |
||||
box-shadow: -2px 0 10px rgba(0, 0, 0, 0.2); |
||||
padding: 0; |
||||
z-index: 1000; |
||||
display: flex; |
||||
flex-direction: column; |
||||
overflow: hidden; |
||||
animation: slideInRight 0.3s ease-out; |
||||
transform: translateX(0); |
||||
} |
||||
|
||||
:global(.dark) .thread-drawer { |
||||
background: var(--fog-dark-post, #1f2937); |
||||
border-left-color: var(--fog-dark-border, #475569); |
||||
box-shadow: -2px 0 10px rgba(0, 0, 0, 0.5); |
||||
} |
||||
|
||||
.drawer-header { |
||||
display: flex; |
||||
justify-content: space-between; |
||||
align-items: center; |
||||
padding: 1rem; |
||||
border-bottom: 1px solid var(--fog-border, #e5e7eb); |
||||
flex-shrink: 0; |
||||
} |
||||
|
||||
:global(.dark) .drawer-header { |
||||
border-bottom-color: var(--fog-dark-border, #374151); |
||||
} |
||||
|
||||
.drawer-title { |
||||
margin: 0; |
||||
font-size: 1.125rem; |
||||
font-weight: 600; |
||||
color: var(--fog-text, #1f2937); |
||||
} |
||||
|
||||
:global(.dark) .drawer-title { |
||||
color: var(--fog-dark-text, #f9fafb); |
||||
} |
||||
|
||||
.drawer-close { |
||||
background: transparent; |
||||
border: none; |
||||
font-size: 1.5rem; |
||||
line-height: 1; |
||||
cursor: pointer; |
||||
color: var(--fog-text-light, #9ca3af); |
||||
padding: 0.25rem 0.5rem; |
||||
border-radius: 0.25rem; |
||||
transition: all 0.2s; |
||||
} |
||||
|
||||
.drawer-close:hover { |
||||
background: var(--fog-highlight, #f3f4f6); |
||||
color: var(--fog-text, #1f2937); |
||||
} |
||||
|
||||
:global(.dark) .drawer-close { |
||||
color: var(--fog-dark-text-light, #6b7280); |
||||
} |
||||
|
||||
:global(.dark) .drawer-close:hover { |
||||
background: var(--fog-dark-highlight, #374151); |
||||
color: var(--fog-dark-text, #f9fafb); |
||||
} |
||||
|
||||
@keyframes slideInRight { |
||||
from { |
||||
transform: translateX(100%); |
||||
} |
||||
to { |
||||
transform: translateX(0); |
||||
} |
||||
} |
||||
|
||||
.drawer-content { |
||||
overflow-y: auto; |
||||
overflow-x: hidden; |
||||
flex: 1; |
||||
padding: 0; |
||||
} |
||||
|
||||
.loading-state { |
||||
padding: 2rem; |
||||
text-align: center; |
||||
} |
||||
|
||||
.thread-content { |
||||
display: flex; |
||||
flex-direction: column; |
||||
} |
||||
|
||||
.op-post { |
||||
padding: 1rem; |
||||
border-bottom: 2px solid var(--fog-border, #e5e7eb); |
||||
flex-shrink: 0; |
||||
} |
||||
|
||||
:global(.dark) .op-post { |
||||
border-bottom-color: var(--fog-dark-border, #374151); |
||||
} |
||||
|
||||
.hierarchy-post { |
||||
padding: 1rem; |
||||
border-bottom: 1px solid var(--fog-border, #e5e7eb); |
||||
flex-shrink: 0; |
||||
} |
||||
|
||||
:global(.dark) .hierarchy-post { |
||||
border-bottom-color: var(--fog-dark-border, #374151); |
||||
} |
||||
|
||||
.hierarchy-divider { |
||||
padding: 0.5rem 0; |
||||
margin-bottom: 0.5rem; |
||||
border-bottom: 1px dashed var(--fog-border, #e5e7eb); |
||||
} |
||||
|
||||
:global(.dark) .hierarchy-divider { |
||||
border-bottom-color: var(--fog-dark-border, #374151); |
||||
} |
||||
|
||||
.hierarchy-label { |
||||
font-size: 0.875rem; |
||||
color: var(--fog-text-light, #6b7280); |
||||
font-style: italic; |
||||
} |
||||
|
||||
:global(.dark) .hierarchy-label { |
||||
color: var(--fog-dark-text-light, #9ca3af); |
||||
} |
||||
|
||||
.comments-section { |
||||
padding: 1rem; |
||||
flex: 1; |
||||
min-height: 0; |
||||
} |
||||
</style> |
||||
Loading…
Reference in new issue