14 changed files with 1773 additions and 195 deletions
@ -0,0 +1,219 @@
@@ -0,0 +1,219 @@
|
||||
<script lang="ts"> |
||||
import { getBookmarkedEvents } from '../../services/user-actions.js'; |
||||
import { nostrClient } from '../../services/nostr/nostr-client.js'; |
||||
import { relayManager } from '../../services/nostr/relay-manager.js'; |
||||
import { sessionManager } from '../../services/auth/session-manager.js'; |
||||
import FeedPost from '../../modules/feed/FeedPost.svelte'; |
||||
import ThreadDrawer from '../../modules/feed/ThreadDrawer.svelte'; |
||||
import { onMount } from 'svelte'; |
||||
import type { NostrEvent } from '../../types/nostr.js'; |
||||
import { KIND } from '../../types/kind-lookup.js'; |
||||
|
||||
interface Props { |
||||
isOpen: boolean; |
||||
onClose: () => void; |
||||
} |
||||
|
||||
let { isOpen, onClose }: Props = $props(); |
||||
|
||||
let bookmarkedEvents = $state<NostrEvent[]>([]); |
||||
let loading = $state(true); |
||||
let drawerOpen = $state(false); |
||||
let drawerEvent = $state<NostrEvent | null>(null); |
||||
|
||||
function openDrawer(event: NostrEvent) { |
||||
drawerEvent = event; |
||||
drawerOpen = true; |
||||
} |
||||
|
||||
function closeDrawer() { |
||||
drawerOpen = false; |
||||
drawerEvent = null; |
||||
} |
||||
|
||||
$effect(() => { |
||||
if (isOpen) { |
||||
loadBookmarks(); |
||||
} |
||||
}); |
||||
|
||||
async function loadBookmarks() { |
||||
loading = true; |
||||
try { |
||||
// Get bookmarks from published kind 10003 event (from cache/relays) |
||||
const allBookmarkedIds = await getBookmarkedEvents(); |
||||
|
||||
console.log('[BookmarksPanel] Total bookmarked IDs to fetch:', allBookmarkedIds.size, Array.from(allBookmarkedIds)); |
||||
|
||||
if (allBookmarkedIds.size === 0) { |
||||
bookmarkedEvents = []; |
||||
loading = false; |
||||
return; |
||||
} |
||||
|
||||
const profileRelays = relayManager.getProfileReadRelays(); |
||||
const events = await nostrClient.fetchEvents( |
||||
[{ ids: Array.from(allBookmarkedIds), limit: 100 }], |
||||
profileRelays, |
||||
{ useCache: true, cacheResults: true, timeout: 10000 } |
||||
); |
||||
|
||||
console.log('[BookmarksPanel] Fetched', events.length, 'events out of', allBookmarkedIds.size, 'bookmarked IDs'); |
||||
|
||||
// Log which IDs were not found |
||||
const foundIds = new Set(events.map(e => e.id)); |
||||
const missingIds = Array.from(allBookmarkedIds).filter(id => !foundIds.has(id)); |
||||
if (missingIds.length > 0) { |
||||
console.warn('[BookmarksPanel] Could not find events for these bookmarked IDs:', missingIds); |
||||
} |
||||
|
||||
// Sort by created_at descending |
||||
bookmarkedEvents = events.sort((a, b) => b.created_at - a.created_at); |
||||
} catch (error) { |
||||
console.error('Error loading bookmarks:', error); |
||||
bookmarkedEvents = []; |
||||
} finally { |
||||
loading = false; |
||||
} |
||||
} |
||||
</script> |
||||
|
||||
{#if isOpen} |
||||
<div |
||||
class="bookmarks-panel-overlay" |
||||
onclick={(e) => { |
||||
// Only close if clicking directly on the overlay, not on the panel |
||||
if (e.target === e.currentTarget) { |
||||
onClose(); |
||||
} |
||||
}} |
||||
onkeydown={(e) => { |
||||
if (e.key === 'Escape') { |
||||
onClose(); |
||||
} |
||||
}} |
||||
role="dialog" |
||||
aria-modal="true" |
||||
aria-label="Bookmarks panel" |
||||
tabindex="-1" |
||||
> |
||||
<div |
||||
class="bookmarks-panel" |
||||
> |
||||
<div class="bookmarks-panel-header"> |
||||
<h2 class="bookmarks-panel-title">Bookmarks</h2> |
||||
<button class="bookmarks-panel-close" onclick={onClose} aria-label="Close bookmarks"> |
||||
✕ |
||||
</button> |
||||
</div> |
||||
|
||||
<div class="bookmarks-panel-content"> |
||||
{#if loading} |
||||
<p class="text-fog-text-light dark:text-fog-dark-text-light">Loading bookmarks...</p> |
||||
{:else if bookmarkedEvents.length === 0} |
||||
<p class="text-fog-text-light dark:text-fog-dark-text-light">No bookmarks yet.</p> |
||||
{:else} |
||||
<div class="bookmarks-list"> |
||||
{#each bookmarkedEvents as event (event.id)} |
||||
<FeedPost post={event} onOpenEvent={openDrawer} /> |
||||
{/each} |
||||
</div> |
||||
{/if} |
||||
</div> |
||||
</div> |
||||
</div> |
||||
{/if} |
||||
|
||||
<ThreadDrawer opEvent={drawerEvent} isOpen={drawerOpen} onClose={closeDrawer} /> |
||||
|
||||
<style> |
||||
.bookmarks-panel-overlay { |
||||
position: fixed; |
||||
top: 0; |
||||
left: 0; |
||||
right: 0; |
||||
bottom: 0; |
||||
background: rgba(0, 0, 0, 0.5); |
||||
z-index: 1000; |
||||
display: flex; |
||||
align-items: stretch; |
||||
} |
||||
|
||||
.bookmarks-panel { |
||||
width: 400px; |
||||
max-width: 90vw; |
||||
background: var(--fog-post, #ffffff); |
||||
border-right: 1px solid var(--fog-border, #e5e7eb); |
||||
display: flex; |
||||
flex-direction: column; |
||||
overflow: hidden; |
||||
} |
||||
|
||||
:global(.dark) .bookmarks-panel { |
||||
background: var(--fog-dark-post, #1f2937); |
||||
border-right-color: var(--fog-dark-border, #374151); |
||||
} |
||||
|
||||
.bookmarks-panel-header { |
||||
display: flex; |
||||
justify-content: space-between; |
||||
align-items: center; |
||||
padding: 1rem; |
||||
border-bottom: 1px solid var(--fog-border, #e5e7eb); |
||||
} |
||||
|
||||
:global(.dark) .bookmarks-panel-header { |
||||
border-bottom-color: var(--fog-dark-border, #374151); |
||||
} |
||||
|
||||
.bookmarks-panel-title { |
||||
margin: 0; |
||||
font-size: 1.25rem; |
||||
font-weight: 600; |
||||
color: var(--fog-text, #1f2937); |
||||
} |
||||
|
||||
:global(.dark) .bookmarks-panel-title { |
||||
color: var(--fog-dark-text, #f9fafb); |
||||
} |
||||
|
||||
.bookmarks-panel-close { |
||||
background: none; |
||||
border: none; |
||||
font-size: 1.5rem; |
||||
cursor: pointer; |
||||
color: var(--fog-text-light, #6b7280); |
||||
padding: 0; |
||||
width: 2rem; |
||||
height: 2rem; |
||||
display: flex; |
||||
align-items: center; |
||||
justify-content: center; |
||||
border-radius: 0.25rem; |
||||
transition: background 0.2s; |
||||
} |
||||
|
||||
:global(.dark) .bookmarks-panel-close { |
||||
color: var(--fog-dark-text-light, #9ca3af); |
||||
} |
||||
|
||||
.bookmarks-panel-close:hover { |
||||
background: var(--fog-highlight, #f3f4f6); |
||||
} |
||||
|
||||
:global(.dark) .bookmarks-panel-close:hover { |
||||
background: var(--fog-dark-highlight, #374151); |
||||
} |
||||
|
||||
.bookmarks-panel-content { |
||||
flex: 1; |
||||
overflow-y: auto; |
||||
padding: 1rem; |
||||
} |
||||
|
||||
.bookmarks-list { |
||||
display: flex; |
||||
flex-direction: column; |
||||
gap: 1rem; |
||||
} |
||||
</style> |
||||
@ -0,0 +1,458 @@
@@ -0,0 +1,458 @@
|
||||
<script lang="ts"> |
||||
import { nip19 } from 'nostr-tools'; |
||||
import { goto } from '$app/navigation'; |
||||
import { sessionManager } from '../../services/auth/session-manager.js'; |
||||
import { nostrClient } from '../../services/nostr/nostr-client.js'; |
||||
import { relayManager } from '../../services/nostr/relay-manager.js'; |
||||
import { getProfile } from '../../services/cache/profile-cache.js'; |
||||
import { KIND } from '../../types/kind-lookup.js'; |
||||
import type { NostrEvent } from '../../types/nostr.js'; |
||||
import { toggleMute, toggleFollow, isMuted, isFollowed } from '../../services/user-actions.js'; |
||||
|
||||
interface Props { |
||||
pubkey: string; |
||||
onOpenBookmarks?: () => void; |
||||
} |
||||
|
||||
let { pubkey, onOpenBookmarks }: Props = $props(); |
||||
|
||||
let menuOpen = $state(false); |
||||
let menuButtonElement: HTMLButtonElement | null = $state(null); |
||||
let menuDropdownElement: HTMLDivElement | null = $state(null); |
||||
let menuPosition = $state({ top: 0, right: 0 }); |
||||
let copied = $state<string | null>(null); |
||||
let profileEvent = $state<NostrEvent | null>(null); |
||||
let muting = $state(false); |
||||
let following = $state(false); |
||||
|
||||
let isLoggedIn = $derived(sessionManager.isLoggedIn()); |
||||
let currentUserPubkey = $derived(sessionManager.getCurrentPubkey()); |
||||
let isOwnProfile = $derived(isLoggedIn && currentUserPubkey === pubkey); |
||||
let muted = $state(false); |
||||
let followed = $state(false); |
||||
|
||||
// Update mute and follow state |
||||
$effect(() => { |
||||
if (pubkey) { |
||||
isMuted(pubkey).then(m => { |
||||
muted = m; |
||||
}); |
||||
isFollowed(pubkey).then(f => { |
||||
followed = f; |
||||
}); |
||||
} |
||||
}); |
||||
|
||||
// Load kind 0 event for this profile |
||||
$effect(() => { |
||||
if (pubkey) { |
||||
loadProfileEvent(); |
||||
} |
||||
}); |
||||
|
||||
async function loadProfileEvent() { |
||||
try { |
||||
// Try cache first |
||||
const cached = await getProfile(pubkey); |
||||
if (cached) { |
||||
profileEvent = cached.event; |
||||
return; |
||||
} |
||||
|
||||
// Fetch from relays if not in cache |
||||
const relays = relayManager.getProfileReadRelays(); |
||||
const events = await nostrClient.fetchEvents( |
||||
[{ kinds: [KIND.METADATA], authors: [pubkey], limit: 1 }], |
||||
relays, |
||||
{ useCache: true, cacheResults: true } |
||||
); |
||||
|
||||
if (events.length > 0) { |
||||
profileEvent = events[0]; |
||||
} |
||||
} catch (error) { |
||||
console.error('Error loading profile event:', error); |
||||
} |
||||
} |
||||
|
||||
function toggleMenu() { |
||||
if (menuOpen) { |
||||
closeMenu(); |
||||
} else { |
||||
openMenu(); |
||||
} |
||||
} |
||||
|
||||
function openMenu() { |
||||
menuOpen = true; |
||||
requestAnimationFrame(() => { |
||||
positionMenu(); |
||||
}); |
||||
} |
||||
|
||||
function closeMenu() { |
||||
menuOpen = false; |
||||
} |
||||
|
||||
function positionMenu() { |
||||
if (!menuButtonElement || !menuDropdownElement) return; |
||||
|
||||
const buttonRect = menuButtonElement.getBoundingClientRect(); |
||||
const top = buttonRect.bottom + 4; |
||||
const right = window.innerWidth - buttonRect.right; |
||||
|
||||
menuPosition = { top, right }; |
||||
|
||||
requestAnimationFrame(() => { |
||||
if (!menuDropdownElement) return; |
||||
const dropdownRect = menuDropdownElement.getBoundingClientRect(); |
||||
const viewportHeight = window.innerHeight; |
||||
const viewportWidth = window.innerWidth; |
||||
|
||||
// Adjust if menu goes off bottom of screen |
||||
if (top + dropdownRect.height > viewportHeight) { |
||||
menuPosition.top = buttonRect.top - dropdownRect.height - 4; |
||||
} |
||||
|
||||
// Adjust if menu goes off right side of screen |
||||
if (right - dropdownRect.width < 0) { |
||||
menuPosition.right = window.innerWidth - buttonRect.left; |
||||
} |
||||
}); |
||||
} |
||||
|
||||
// Close menu when clicking outside |
||||
$effect(() => { |
||||
if (!menuOpen) return; |
||||
|
||||
function handleClickOutside(event: MouseEvent) { |
||||
if ( |
||||
menuButtonElement && |
||||
menuDropdownElement && |
||||
!menuButtonElement.contains(event.target as Node) && |
||||
!menuDropdownElement.contains(event.target as Node) |
||||
) { |
||||
closeMenu(); |
||||
} |
||||
} |
||||
|
||||
function handleEscape(event: KeyboardEvent) { |
||||
if (event.key === 'Escape') { |
||||
closeMenu(); |
||||
} |
||||
} |
||||
|
||||
document.addEventListener('mousedown', handleClickOutside); |
||||
document.addEventListener('keydown', handleEscape); |
||||
|
||||
return () => { |
||||
document.removeEventListener('mousedown', handleClickOutside); |
||||
document.removeEventListener('keydown', handleEscape); |
||||
}; |
||||
}); |
||||
|
||||
async function copyUserId() { |
||||
try { |
||||
const npub = nip19.npubEncode(pubkey); |
||||
await navigator.clipboard.writeText(npub); |
||||
copied = 'userId'; |
||||
setTimeout(() => { |
||||
copied = null; |
||||
}, 2000); |
||||
closeMenu(); |
||||
} catch (error) { |
||||
console.error('Failed to copy npub:', error); |
||||
} |
||||
} |
||||
|
||||
async function copyEventId() { |
||||
try { |
||||
if (!profileEvent) { |
||||
await loadProfileEvent(); |
||||
} |
||||
if (profileEvent) { |
||||
const nevent = nip19.neventEncode({ |
||||
id: profileEvent.id, |
||||
author: profileEvent.pubkey, |
||||
relays: [] |
||||
}); |
||||
await navigator.clipboard.writeText(nevent); |
||||
copied = 'eventId'; |
||||
setTimeout(() => { |
||||
copied = null; |
||||
}, 2000); |
||||
} |
||||
closeMenu(); |
||||
} catch (error) { |
||||
console.error('Failed to copy nevent:', error); |
||||
} |
||||
} |
||||
|
||||
function shareWithAitherboard() { |
||||
const url = `${window.location.origin}/profile/${pubkey}`; |
||||
navigator.clipboard.writeText(url).then(() => { |
||||
copied = 'share'; |
||||
setTimeout(() => { |
||||
copied = null; |
||||
}, 2000); |
||||
}).catch(error => { |
||||
console.error('Failed to copy share URL:', error); |
||||
}); |
||||
closeMenu(); |
||||
} |
||||
|
||||
function seeBookmarks() { |
||||
if (onOpenBookmarks) { |
||||
onOpenBookmarks(); |
||||
} |
||||
closeMenu(); |
||||
} |
||||
|
||||
async function handleMute() { |
||||
if (muting || isOwnProfile) return; |
||||
muting = true; |
||||
try { |
||||
await toggleMute(pubkey); |
||||
// Update state |
||||
const newMuted = await isMuted(pubkey); |
||||
muted = newMuted; |
||||
} catch (error) { |
||||
console.error('Failed to toggle mute:', error); |
||||
} finally { |
||||
muting = false; |
||||
closeMenu(); |
||||
} |
||||
} |
||||
|
||||
async function handleFollow() { |
||||
if (following || isOwnProfile) return; |
||||
following = true; |
||||
try { |
||||
await toggleFollow(pubkey); |
||||
// Update state |
||||
const newFollowed = await isFollowed(pubkey); |
||||
followed = newFollowed; |
||||
} catch (error) { |
||||
console.error('Failed to toggle follow:', error); |
||||
} finally { |
||||
following = false; |
||||
closeMenu(); |
||||
} |
||||
} |
||||
</script> |
||||
|
||||
<div class="profile-menu-container"> |
||||
<button |
||||
bind:this={menuButtonElement} |
||||
class="menu-button" |
||||
onclick={toggleMenu} |
||||
aria-label="Profile menu" |
||||
aria-expanded={menuOpen} |
||||
aria-haspopup="true" |
||||
> |
||||
<span class="menu-icon">⋯</span> |
||||
</button> |
||||
|
||||
{#if menuOpen} |
||||
<div |
||||
bind:this={menuDropdownElement} |
||||
class="menu-dropdown" |
||||
style="top: {menuPosition.top}px; right: {menuPosition.right}px;" |
||||
role="menu" |
||||
> |
||||
<button class="menu-item" onclick={copyUserId} role="menuitem"> |
||||
<span class="menu-item-icon">📋</span> |
||||
<span class="menu-item-text">Copy user ID (npub)</span> |
||||
{#if copied === 'userId'} |
||||
<span class="menu-item-check">✓</span> |
||||
{/if} |
||||
</button> |
||||
|
||||
<button class="menu-item" onclick={copyEventId} role="menuitem" disabled={!profileEvent}> |
||||
<span class="menu-item-icon">📋</span> |
||||
<span class="menu-item-text">Copy event ID (nevent)</span> |
||||
{#if copied === 'eventId'} |
||||
<span class="menu-item-check">✓</span> |
||||
{/if} |
||||
</button> |
||||
|
||||
<div class="menu-divider"></div> |
||||
|
||||
<button class="menu-item" onclick={shareWithAitherboard} role="menuitem"> |
||||
<span class="menu-item-icon">🔗</span> |
||||
<span class="menu-item-text">Share with aitherboard</span> |
||||
{#if copied === 'share'} |
||||
<span class="menu-item-check">✓</span> |
||||
{/if} |
||||
</button> |
||||
|
||||
{#if isLoggedIn && !isOwnProfile} |
||||
<div class="menu-divider"></div> |
||||
|
||||
<button |
||||
class="menu-item" |
||||
onclick={handleMute} |
||||
role="menuitem" |
||||
disabled={muting} |
||||
> |
||||
<span class="menu-item-icon">{muted ? '🔇' : '🔊'}</span> |
||||
<span class="menu-item-text">{muted ? 'Unmute this user' : 'Mute this user'}</span> |
||||
{#if muted} |
||||
<span class="menu-item-check">✓</span> |
||||
{/if} |
||||
</button> |
||||
|
||||
<button |
||||
class="menu-item" |
||||
onclick={handleFollow} |
||||
role="menuitem" |
||||
disabled={following} |
||||
> |
||||
<span class="menu-item-icon">{followed ? '👤' : '➕'}</span> |
||||
<span class="menu-item-text">{followed ? 'Unfollow this user' : 'Follow this user'}</span> |
||||
{#if followed} |
||||
<span class="menu-item-check">✓</span> |
||||
{/if} |
||||
</button> |
||||
{/if} |
||||
|
||||
{#if isOwnProfile && onOpenBookmarks} |
||||
<div class="menu-divider"></div> |
||||
<button class="menu-item" onclick={seeBookmarks} role="menuitem"> |
||||
<span class="menu-item-icon">🔖</span> |
||||
<span class="menu-item-text">See bookmarks</span> |
||||
</button> |
||||
{/if} |
||||
</div> |
||||
{/if} |
||||
</div> |
||||
|
||||
<style> |
||||
.profile-menu-container { |
||||
position: relative; |
||||
display: inline-block; |
||||
} |
||||
|
||||
.menu-button { |
||||
display: inline-flex; |
||||
align-items: center; |
||||
justify-content: center; |
||||
padding: 0.25rem 0.5rem; |
||||
background: var(--fog-post, #ffffff); |
||||
border: 1px solid var(--fog-border, #e5e7eb); |
||||
border-radius: 0.25rem; |
||||
cursor: pointer; |
||||
transition: all 0.2s; |
||||
font-size: 0.875rem; |
||||
min-width: 2rem; |
||||
min-height: 2rem; |
||||
filter: grayscale(100%); |
||||
} |
||||
|
||||
:global(.dark) .menu-button { |
||||
background: var(--fog-dark-post, #1f2937); |
||||
border-color: var(--fog-dark-border, #374151); |
||||
} |
||||
|
||||
.menu-button:hover { |
||||
background: var(--fog-highlight, #f3f4f6); |
||||
border-color: var(--fog-accent, #64748b); |
||||
} |
||||
|
||||
:global(.dark) .menu-button:hover { |
||||
background: var(--fog-dark-highlight, #475569); |
||||
border-color: var(--fog-dark-accent, #94a3b8); |
||||
} |
||||
|
||||
.menu-icon { |
||||
font-size: 1.25rem; |
||||
line-height: 1; |
||||
color: var(--fog-text, #1f2937); |
||||
filter: grayscale(100%); |
||||
} |
||||
|
||||
:global(.dark) .menu-icon { |
||||
color: var(--fog-dark-text, #f9fafb); |
||||
} |
||||
|
||||
.menu-dropdown { |
||||
position: fixed; |
||||
z-index: 1000; |
||||
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); |
||||
min-width: 180px; |
||||
padding: 0.25rem; |
||||
} |
||||
|
||||
:global(.dark) .menu-dropdown { |
||||
background: var(--fog-dark-post, #1f2937); |
||||
border-color: var(--fog-dark-border, #374151); |
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); |
||||
} |
||||
|
||||
.menu-item { |
||||
display: flex; |
||||
align-items: center; |
||||
gap: 0.5rem; |
||||
width: 100%; |
||||
padding: 0.5rem 0.75rem; |
||||
background: none; |
||||
border: none; |
||||
border-radius: 0.25rem; |
||||
cursor: pointer; |
||||
font-size: 0.875rem; |
||||
color: var(--fog-text, #1f2937); |
||||
text-align: left; |
||||
transition: background 0.2s; |
||||
} |
||||
|
||||
:global(.dark) .menu-item { |
||||
color: var(--fog-dark-text, #f9fafb); |
||||
} |
||||
|
||||
.menu-item:hover:not(:disabled) { |
||||
background: var(--fog-highlight, #f3f4f6); |
||||
} |
||||
|
||||
:global(.dark) .menu-item:hover:not(:disabled) { |
||||
background: var(--fog-dark-highlight, #374151); |
||||
} |
||||
|
||||
.menu-item:disabled { |
||||
opacity: 0.5; |
||||
cursor: not-allowed; |
||||
} |
||||
|
||||
.menu-item-icon { |
||||
font-size: 1rem; |
||||
line-height: 1; |
||||
flex-shrink: 0; |
||||
filter: grayscale(100%); |
||||
} |
||||
|
||||
.menu-item-text { |
||||
flex: 1; |
||||
} |
||||
|
||||
.menu-item-check { |
||||
color: var(--fog-accent, #64748b); |
||||
font-weight: 600; |
||||
flex-shrink: 0; |
||||
} |
||||
|
||||
:global(.dark) .menu-item-check { |
||||
color: var(--fog-dark-accent, #94a3b8); |
||||
} |
||||
|
||||
.menu-divider { |
||||
height: 1px; |
||||
background: var(--fog-border, #e5e7eb); |
||||
margin: 0.25rem 0; |
||||
} |
||||
|
||||
:global(.dark) .menu-divider { |
||||
background: var(--fog-dark-border, #374151); |
||||
} |
||||
</style> |
||||
Loading…
Reference in new issue