10 changed files with 869 additions and 3 deletions
@ -0,0 +1,431 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import { nip19 } from 'nostr-tools'; |
||||||
|
import type { NostrEvent } from '../types/nostr.js'; |
||||||
|
import { nostrClient } from '../services/nostr/nostr-client.js'; |
||||||
|
import { relayManager } from '../services/nostr/relay-manager.js'; |
||||||
|
import EventJsonModal from './modals/EventJsonModal.svelte'; |
||||||
|
import PublicationStatusModal from './modals/PublicationStatusModal.svelte'; |
||||||
|
import { |
||||||
|
isPinned, |
||||||
|
isBookmarked, |
||||||
|
isHighlighted, |
||||||
|
togglePin, |
||||||
|
toggleBookmark, |
||||||
|
toggleHighlight |
||||||
|
} from '../services/user-actions.js'; |
||||||
|
import { eventMenuStore } from '../services/event-menu-store.js'; |
||||||
|
|
||||||
|
interface Props { |
||||||
|
event: NostrEvent; |
||||||
|
showContentActions?: boolean; // Show pin/bookmark/highlight for notes with content |
||||||
|
} |
||||||
|
|
||||||
|
let { event, showContentActions = false }: Props = $props(); |
||||||
|
|
||||||
|
let menuOpen = $state(false); |
||||||
|
let jsonModalOpen = $state(false); |
||||||
|
let publicationModalOpen = $state(false); |
||||||
|
let publicationResults = $state<{ success: string[]; failed: Array<{ relay: string; error: string }> } | null>(null); |
||||||
|
let broadcasting = $state(false); |
||||||
|
let copied = $state<string | null>(null); |
||||||
|
let menuButtonElement: HTMLButtonElement | null = $state(null); |
||||||
|
let menuDropdownElement: HTMLDivElement | null = $state(null); |
||||||
|
let menuPosition = $state({ top: 0, right: 0 }); |
||||||
|
|
||||||
|
// Unique ID for this menu instance |
||||||
|
let menuId = $derived(event.id); |
||||||
|
|
||||||
|
// Check if this is a note with content (kind 1 or kind 11) |
||||||
|
let isContentNote = $derived(event.kind === 1 || event.kind === 11); |
||||||
|
|
||||||
|
// Track pin/bookmark/highlight state |
||||||
|
let pinnedState = $state(false); |
||||||
|
let bookmarkedState = $state(false); |
||||||
|
let highlightedState = $state(false); |
||||||
|
|
||||||
|
// Update state when event changes |
||||||
|
$effect(() => { |
||||||
|
pinnedState = isPinned(event.id); |
||||||
|
bookmarkedState = isBookmarked(event.id); |
||||||
|
highlightedState = isHighlighted(event.id); |
||||||
|
}); |
||||||
|
|
||||||
|
function toggleMenu() { |
||||||
|
if (menuOpen) { |
||||||
|
closeMenu(); |
||||||
|
} else { |
||||||
|
openMenu(); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
function openMenu() { |
||||||
|
menuOpen = true; |
||||||
|
// Register this menu as open - this will close any other open menu |
||||||
|
eventMenuStore.openMenu(menuId, closeMenu); |
||||||
|
// Position menu after opening |
||||||
|
requestAnimationFrame(() => { |
||||||
|
positionMenu(); |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
function positionMenu() { |
||||||
|
if (!menuButtonElement || !menuDropdownElement) return; |
||||||
|
|
||||||
|
const buttonRect = menuButtonElement.getBoundingClientRect(); |
||||||
|
|
||||||
|
// Calculate position - align right edge of dropdown with right edge of button |
||||||
|
// Position below button by default |
||||||
|
const top = buttonRect.bottom + 4; |
||||||
|
const right = window.innerWidth - buttonRect.right; |
||||||
|
|
||||||
|
menuPosition = { top, right }; |
||||||
|
|
||||||
|
// Get actual dimensions after rendering |
||||||
|
requestAnimationFrame(() => { |
||||||
|
if (!menuDropdownElement) return; |
||||||
|
const dropdownRect = menuDropdownElement.getBoundingClientRect(); |
||||||
|
|
||||||
|
// Adjust if menu would go off screen at bottom |
||||||
|
if (dropdownRect.bottom > window.innerHeight) { |
||||||
|
// Position above button if there's not enough space below |
||||||
|
const spaceAbove = buttonRect.top; |
||||||
|
const spaceBelow = window.innerHeight - buttonRect.bottom; |
||||||
|
if (spaceAbove > spaceBelow) { |
||||||
|
menuPosition.top = buttonRect.top - dropdownRect.height - 4; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Adjust if menu would go off screen to the left |
||||||
|
if (dropdownRect.left < 0) { |
||||||
|
menuPosition.right = window.innerWidth - buttonRect.left; |
||||||
|
} |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
function closeMenu() { |
||||||
|
menuOpen = false; |
||||||
|
eventMenuStore.closeMenu(menuId); |
||||||
|
} |
||||||
|
|
||||||
|
// Close menu when clicking outside |
||||||
|
function handleClickOutside(e: MouseEvent) { |
||||||
|
const target = e.target as HTMLElement; |
||||||
|
if (!target.closest('.event-menu-container') && !target.closest('.menu-dropdown')) { |
||||||
|
eventMenuStore.closeCurrentMenu(); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
$effect(() => { |
||||||
|
if (menuOpen) { |
||||||
|
document.addEventListener('click', handleClickOutside); |
||||||
|
window.addEventListener('scroll', closeMenu, true); |
||||||
|
window.addEventListener('resize', positionMenu); |
||||||
|
// Reposition on scroll to keep menu aligned |
||||||
|
const handleScroll = () => { |
||||||
|
if (menuOpen) { |
||||||
|
positionMenu(); |
||||||
|
} |
||||||
|
}; |
||||||
|
window.addEventListener('scroll', handleScroll, true); |
||||||
|
return () => { |
||||||
|
document.removeEventListener('click', handleClickOutside); |
||||||
|
window.removeEventListener('scroll', closeMenu, true); |
||||||
|
window.removeEventListener('resize', positionMenu); |
||||||
|
window.removeEventListener('scroll', handleScroll, true); |
||||||
|
}; |
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
async function copyUserId() { |
||||||
|
try { |
||||||
|
const npub = nip19.npubEncode(event.pubkey); |
||||||
|
await navigator.clipboard.writeText(npub); |
||||||
|
copied = 'npub'; |
||||||
|
setTimeout(() => { |
||||||
|
copied = null; |
||||||
|
}, 2000); |
||||||
|
closeMenu(); |
||||||
|
} catch (error) { |
||||||
|
console.error('Failed to copy user ID:', error); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
async function copyEventId() { |
||||||
|
try { |
||||||
|
const nevent = nip19.neventEncode({ |
||||||
|
id: event.id, |
||||||
|
relays: [] |
||||||
|
}); |
||||||
|
await navigator.clipboard.writeText(nevent); |
||||||
|
copied = 'nevent'; |
||||||
|
setTimeout(() => { |
||||||
|
copied = null; |
||||||
|
}, 2000); |
||||||
|
closeMenu(); |
||||||
|
} catch (error) { |
||||||
|
console.error('Failed to copy event ID:', error); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
function viewJson() { |
||||||
|
jsonModalOpen = true; |
||||||
|
closeMenu(); |
||||||
|
} |
||||||
|
|
||||||
|
async function broadcastEvent() { |
||||||
|
broadcasting = true; |
||||||
|
closeMenu(); |
||||||
|
|
||||||
|
try { |
||||||
|
// Get all available relays for broadcasting |
||||||
|
const relays = relayManager.getPublishRelays( |
||||||
|
[...relayManager.getThreadReadRelays(), ...relayManager.getFeedReadRelays()], |
||||||
|
true |
||||||
|
); |
||||||
|
|
||||||
|
const results = await nostrClient.publish(event, { relays }); |
||||||
|
publicationResults = results; |
||||||
|
publicationModalOpen = true; |
||||||
|
} catch (error) { |
||||||
|
console.error('Error broadcasting event:', error); |
||||||
|
publicationResults = { |
||||||
|
success: [], |
||||||
|
failed: [{ relay: 'Unknown', error: error instanceof Error ? error.message : 'Unknown error' }] |
||||||
|
}; |
||||||
|
publicationModalOpen = true; |
||||||
|
} finally { |
||||||
|
broadcasting = false; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
async function shareWithAitherboard() { |
||||||
|
try { |
||||||
|
const url = `${window.location.origin}/thread/${event.id}`; |
||||||
|
await navigator.clipboard.writeText(url); |
||||||
|
copied = 'share'; |
||||||
|
setTimeout(() => { |
||||||
|
copied = null; |
||||||
|
}, 2000); |
||||||
|
closeMenu(); |
||||||
|
} catch (error) { |
||||||
|
console.error('Failed to copy share URL:', error); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
function pinNote() { |
||||||
|
pinnedState = togglePin(event.id); |
||||||
|
closeMenu(); |
||||||
|
} |
||||||
|
|
||||||
|
function bookmarkNote() { |
||||||
|
bookmarkedState = toggleBookmark(event.id); |
||||||
|
closeMenu(); |
||||||
|
} |
||||||
|
|
||||||
|
function highlightNote() { |
||||||
|
highlightedState = toggleHighlight(event.id); |
||||||
|
closeMenu(); |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
<div class="event-menu-container"> |
||||||
|
<button |
||||||
|
bind:this={menuButtonElement} |
||||||
|
class="menu-button" |
||||||
|
onclick={toggleMenu} |
||||||
|
aria-label="Event menu" |
||||||
|
aria-expanded={menuOpen} |
||||||
|
> |
||||||
|
<span class="menu-icon">⋯</span> |
||||||
|
</button> |
||||||
|
|
||||||
|
{#if menuOpen} |
||||||
|
<div |
||||||
|
bind:this={menuDropdownElement} |
||||||
|
class="menu-dropdown" |
||||||
|
style="top: {menuPosition.top}px; right: {menuPosition.right}px;" |
||||||
|
> |
||||||
|
<button class="menu-item" onclick={copyUserId}> |
||||||
|
Copy user ID |
||||||
|
{#if copied === 'npub'} |
||||||
|
<span class="copied-indicator">✓</span> |
||||||
|
{/if} |
||||||
|
</button> |
||||||
|
<button class="menu-item" onclick={copyEventId}> |
||||||
|
Copy event ID |
||||||
|
{#if copied === 'nevent'} |
||||||
|
<span class="copied-indicator">✓</span> |
||||||
|
{/if} |
||||||
|
</button> |
||||||
|
<button class="menu-item" onclick={viewJson}> |
||||||
|
View JSON |
||||||
|
</button> |
||||||
|
<button class="menu-item" onclick={broadcastEvent} disabled={broadcasting}> |
||||||
|
{broadcasting ? 'Broadcasting...' : 'Broadcast event'} |
||||||
|
</button> |
||||||
|
<button class="menu-item" onclick={shareWithAitherboard}> |
||||||
|
Share with aitherboard |
||||||
|
{#if copied === 'share'} |
||||||
|
<span class="copied-indicator">✓</span> |
||||||
|
{/if} |
||||||
|
</button> |
||||||
|
|
||||||
|
{#if showContentActions && isContentNote} |
||||||
|
<div class="menu-divider"></div> |
||||||
|
<button class="menu-item" onclick={pinNote} class:active={pinnedState}> |
||||||
|
Pin note |
||||||
|
{#if pinnedState} |
||||||
|
<span class="action-indicator">✓</span> |
||||||
|
{/if} |
||||||
|
</button> |
||||||
|
<button class="menu-item" onclick={bookmarkNote} class:active={bookmarkedState}> |
||||||
|
Bookmark note |
||||||
|
{#if bookmarkedState} |
||||||
|
<span class="action-indicator">✓</span> |
||||||
|
{/if} |
||||||
|
</button> |
||||||
|
<button class="menu-item" onclick={highlightNote} class:active={highlightedState}> |
||||||
|
Highlight note |
||||||
|
{#if highlightedState} |
||||||
|
<span class="action-indicator">✓</span> |
||||||
|
{/if} |
||||||
|
</button> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
|
||||||
|
<EventJsonModal bind:open={jsonModalOpen} bind:event /> |
||||||
|
<PublicationStatusModal bind:open={publicationModalOpen} bind:results={publicationResults} /> |
||||||
|
|
||||||
|
<style> |
||||||
|
.event-menu-container { |
||||||
|
position: relative; |
||||||
|
display: inline-block; |
||||||
|
/* Ensure menu can overflow container */ |
||||||
|
overflow: visible; |
||||||
|
} |
||||||
|
|
||||||
|
.menu-button { |
||||||
|
background: none; |
||||||
|
border: none; |
||||||
|
cursor: pointer; |
||||||
|
padding: 0.25rem 0.5rem; |
||||||
|
color: var(--fog-text-light, #9ca3af); |
||||||
|
font-size: 1.25rem; |
||||||
|
line-height: 1; |
||||||
|
display: flex; |
||||||
|
align-items: center; |
||||||
|
justify-content: center; |
||||||
|
border-radius: 4px; |
||||||
|
transition: background-color 0.2s, color 0.2s; |
||||||
|
} |
||||||
|
|
||||||
|
.menu-button:hover { |
||||||
|
background: var(--fog-highlight, #f3f4f6); |
||||||
|
color: var(--fog-text, #1f2937); |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .menu-button { |
||||||
|
color: var(--fog-dark-text-light, #6b7280); |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .menu-button:hover { |
||||||
|
background: var(--fog-dark-highlight, #374151); |
||||||
|
color: var(--fog-dark-text, #f1f5f9); |
||||||
|
} |
||||||
|
|
||||||
|
.menu-icon { |
||||||
|
user-select: none; |
||||||
|
transform: rotate(90deg); |
||||||
|
display: inline-block; |
||||||
|
} |
||||||
|
|
||||||
|
.menu-dropdown { |
||||||
|
position: fixed; |
||||||
|
background: var(--fog-post, #ffffff); |
||||||
|
border: 1px solid var(--fog-border, #e5e7eb); |
||||||
|
border-radius: 6px; |
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); |
||||||
|
min-width: 200px; |
||||||
|
z-index: 1000; |
||||||
|
overflow: visible; |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .menu-dropdown { |
||||||
|
background: var(--fog-dark-post, #1f2937); |
||||||
|
border-color: var(--fog-dark-border, #374151); |
||||||
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3); |
||||||
|
} |
||||||
|
|
||||||
|
.menu-item { |
||||||
|
display: flex; |
||||||
|
align-items: center; |
||||||
|
justify-content: space-between; |
||||||
|
width: 100%; |
||||||
|
padding: 0.5rem 0.75rem; |
||||||
|
background: none; |
||||||
|
border: none; |
||||||
|
text-align: left; |
||||||
|
cursor: pointer; |
||||||
|
font-size: 0.875rem; |
||||||
|
color: var(--fog-text, #1f2937); |
||||||
|
transition: background-color 0.2s; |
||||||
|
} |
||||||
|
|
||||||
|
.menu-item:hover:not(:disabled) { |
||||||
|
background: var(--fog-highlight, #f3f4f6); |
||||||
|
} |
||||||
|
|
||||||
|
.menu-item:disabled { |
||||||
|
opacity: 0.6; |
||||||
|
cursor: not-allowed; |
||||||
|
} |
||||||
|
|
||||||
|
.menu-item.active { |
||||||
|
background: var(--fog-highlight, #f3f4f6); |
||||||
|
font-weight: 500; |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .menu-item { |
||||||
|
color: var(--fog-dark-text, #f1f5f9); |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .menu-item:hover:not(:disabled) { |
||||||
|
background: var(--fog-dark-highlight, #374151); |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .menu-item.active { |
||||||
|
background: var(--fog-dark-highlight, #374151); |
||||||
|
} |
||||||
|
|
||||||
|
.action-indicator { |
||||||
|
color: var(--fog-accent, #64748b); |
||||||
|
font-weight: 600; |
||||||
|
margin-left: 0.5rem; |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .action-indicator { |
||||||
|
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); |
||||||
|
} |
||||||
|
|
||||||
|
.copied-indicator { |
||||||
|
color: var(--fog-accent, #64748b); |
||||||
|
font-weight: 600; |
||||||
|
margin-left: 0.5rem; |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .copied-indicator { |
||||||
|
color: var(--fog-dark-accent, #94a3b8); |
||||||
|
} |
||||||
|
</style> |
||||||
@ -0,0 +1,214 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import type { NostrEvent } from '../../types/nostr.js'; |
||||||
|
|
||||||
|
interface Props { |
||||||
|
open?: boolean; |
||||||
|
event?: NostrEvent | null; |
||||||
|
} |
||||||
|
|
||||||
|
let { open = $bindable(false), event = $bindable(null) }: Props = $props(); |
||||||
|
let jsonText = $derived(event ? JSON.stringify(event, null, 2) : ''); |
||||||
|
let copied = $state(false); |
||||||
|
|
||||||
|
function close() { |
||||||
|
open = false; |
||||||
|
} |
||||||
|
|
||||||
|
async function copyJson() { |
||||||
|
if (!jsonText) return; |
||||||
|
try { |
||||||
|
await navigator.clipboard.writeText(jsonText); |
||||||
|
copied = true; |
||||||
|
setTimeout(() => { |
||||||
|
copied = false; |
||||||
|
}, 2000); |
||||||
|
} catch (error) { |
||||||
|
console.error('Failed to copy JSON:', error); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
function selectAll() { |
||||||
|
const textarea = document.querySelector('.json-textarea') as HTMLTextAreaElement; |
||||||
|
if (textarea) { |
||||||
|
textarea.select(); |
||||||
|
} |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
{#if open && event} |
||||||
|
<div |
||||||
|
class="modal-overlay" |
||||||
|
onclick={close} |
||||||
|
onkeydown={(e) => e.key === 'Escape' && close()} |
||||||
|
role="dialog" |
||||||
|
aria-modal="true" |
||||||
|
tabindex="-1" |
||||||
|
> |
||||||
|
<div class="modal-content" onclick={(e) => e.stopPropagation()}> |
||||||
|
<div class="modal-header"> |
||||||
|
<h2>Event JSON</h2> |
||||||
|
<button onclick={close} class="close-button">×</button> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="modal-body"> |
||||||
|
<textarea |
||||||
|
class="json-textarea" |
||||||
|
readonly |
||||||
|
value={jsonText} |
||||||
|
onclick={selectAll} |
||||||
|
></textarea> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="modal-footer"> |
||||||
|
<button onclick={copyJson} class="copy-button"> |
||||||
|
{copied ? 'Copied!' : 'Copy'} |
||||||
|
</button> |
||||||
|
<button onclick={close}>Close</button> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
|
||||||
|
<style> |
||||||
|
.modal-overlay { |
||||||
|
position: fixed; |
||||||
|
top: 0; |
||||||
|
left: 0; |
||||||
|
right: 0; |
||||||
|
bottom: 0; |
||||||
|
background: rgba(15, 23, 42, 0.4); |
||||||
|
backdrop-filter: blur(4px); |
||||||
|
display: flex; |
||||||
|
align-items: center; |
||||||
|
justify-content: center; |
||||||
|
z-index: 1000; |
||||||
|
} |
||||||
|
|
||||||
|
.modal-content { |
||||||
|
background: var(--fog-post, #ffffff); |
||||||
|
border: 1px solid var(--fog-border, #e5e7eb); |
||||||
|
border-radius: 8px; |
||||||
|
max-width: 800px; |
||||||
|
width: 90%; |
||||||
|
max-height: 80vh; |
||||||
|
display: flex; |
||||||
|
flex-direction: column; |
||||||
|
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1); |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .modal-content { |
||||||
|
background: var(--fog-dark-post, #1f2937); |
||||||
|
border-color: var(--fog-dark-border, #374151); |
||||||
|
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.3); |
||||||
|
} |
||||||
|
|
||||||
|
.modal-header { |
||||||
|
display: flex; |
||||||
|
justify-content: space-between; |
||||||
|
align-items: center; |
||||||
|
padding: 1rem; |
||||||
|
border-bottom: 1px solid var(--fog-border, #e5e7eb); |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .modal-header { |
||||||
|
border-bottom-color: var(--fog-dark-border, #374151); |
||||||
|
} |
||||||
|
|
||||||
|
.modal-header h2 { |
||||||
|
margin: 0; |
||||||
|
font-size: 1.25rem; |
||||||
|
color: var(--fog-text, #1f2937); |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .modal-header h2 { |
||||||
|
color: var(--fog-dark-text, #f1f5f9); |
||||||
|
} |
||||||
|
|
||||||
|
.close-button { |
||||||
|
background: none; |
||||||
|
border: none; |
||||||
|
font-size: 1.5rem; |
||||||
|
cursor: pointer; |
||||||
|
padding: 0; |
||||||
|
width: 2rem; |
||||||
|
height: 2rem; |
||||||
|
color: var(--fog-text, #1f2937); |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .close-button { |
||||||
|
color: var(--fog-dark-text, #f1f5f9); |
||||||
|
} |
||||||
|
|
||||||
|
.modal-body { |
||||||
|
padding: 1rem; |
||||||
|
flex: 1; |
||||||
|
overflow: auto; |
||||||
|
} |
||||||
|
|
||||||
|
.json-textarea { |
||||||
|
width: 100%; |
||||||
|
min-height: 400px; |
||||||
|
font-family: 'Courier New', monospace; |
||||||
|
font-size: 0.875rem; |
||||||
|
padding: 0.75rem; |
||||||
|
border: 1px solid var(--fog-border, #e5e7eb); |
||||||
|
border-radius: 4px; |
||||||
|
background: var(--fog-bg, #ffffff); |
||||||
|
color: var(--fog-text, #1f2937); |
||||||
|
resize: vertical; |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .json-textarea { |
||||||
|
background: var(--fog-dark-bg, #0f172a); |
||||||
|
border-color: var(--fog-dark-border, #374151); |
||||||
|
color: var(--fog-dark-text, #f1f5f9); |
||||||
|
} |
||||||
|
|
||||||
|
.modal-footer { |
||||||
|
padding: 1rem; |
||||||
|
border-top: 1px solid var(--fog-border, #e5e7eb); |
||||||
|
display: flex; |
||||||
|
justify-content: flex-end; |
||||||
|
gap: 0.5rem; |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .modal-footer { |
||||||
|
border-top-color: var(--fog-dark-border, #374151); |
||||||
|
} |
||||||
|
|
||||||
|
.modal-footer button { |
||||||
|
padding: 0.5rem 1rem; |
||||||
|
border: none; |
||||||
|
border-radius: 4px; |
||||||
|
cursor: pointer; |
||||||
|
transition: background-color 0.2s; |
||||||
|
font-size: 0.875rem; |
||||||
|
} |
||||||
|
|
||||||
|
.copy-button { |
||||||
|
background: var(--fog-accent, #64748b); |
||||||
|
color: white; |
||||||
|
} |
||||||
|
|
||||||
|
.copy-button:hover { |
||||||
|
background: var(--fog-accent-dark, #475569); |
||||||
|
} |
||||||
|
|
||||||
|
.modal-footer button:not(.copy-button) { |
||||||
|
background: var(--fog-border, #e5e7eb); |
||||||
|
color: var(--fog-text, #1f2937); |
||||||
|
} |
||||||
|
|
||||||
|
.modal-footer button:not(.copy-button):hover { |
||||||
|
background: var(--fog-highlight, #f3f4f6); |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .modal-footer button:not(.copy-button) { |
||||||
|
background: var(--fog-dark-border, #374151); |
||||||
|
color: var(--fog-dark-text, #f1f5f9); |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .modal-footer button:not(.copy-button):hover { |
||||||
|
background: var(--fog-dark-highlight, #374151); |
||||||
|
} |
||||||
|
</style> |
||||||
@ -0,0 +1,57 @@ |
|||||||
|
/** |
||||||
|
* Global store for managing which EventMenu is currently open |
||||||
|
* Ensures only one menu is open at a time |
||||||
|
*/ |
||||||
|
|
||||||
|
type CloseCallback = () => void; |
||||||
|
|
||||||
|
class EventMenuStore { |
||||||
|
private openMenuId: string | null = null; |
||||||
|
private closeCallback: CloseCallback | null = null; |
||||||
|
|
||||||
|
/** |
||||||
|
* Register a menu as open |
||||||
|
* @param menuId Unique identifier for the menu (typically event.id) |
||||||
|
* @param closeCallback Function to call to close this menu |
||||||
|
*/ |
||||||
|
openMenu(menuId: string, closeCallback: CloseCallback): void { |
||||||
|
// Close any previously open menu
|
||||||
|
if (this.openMenuId && this.openMenuId !== menuId && this.closeCallback) { |
||||||
|
this.closeCallback(); |
||||||
|
} |
||||||
|
|
||||||
|
this.openMenuId = menuId; |
||||||
|
this.closeCallback = closeCallback; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Unregister a menu as open |
||||||
|
* @param menuId Unique identifier for the menu |
||||||
|
*/ |
||||||
|
closeMenu(menuId: string): void { |
||||||
|
if (this.openMenuId === menuId) { |
||||||
|
this.openMenuId = null; |
||||||
|
this.closeCallback = null; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Check if a specific menu is open |
||||||
|
*/ |
||||||
|
isOpen(menuId: string): boolean { |
||||||
|
return this.openMenuId === menuId; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Close the currently open menu (if any) |
||||||
|
*/ |
||||||
|
closeCurrentMenu(): void { |
||||||
|
if (this.closeCallback) { |
||||||
|
this.closeCallback(); |
||||||
|
} |
||||||
|
this.openMenuId = null; |
||||||
|
this.closeCallback = null; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
export const eventMenuStore = new EventMenuStore(); |
||||||
@ -0,0 +1,140 @@ |
|||||||
|
/** |
||||||
|
* User actions service - manages pinned, bookmarked, and highlighted events |
||||||
|
* Stores in localStorage for persistence |
||||||
|
*/ |
||||||
|
|
||||||
|
const STORAGE_KEY_PINNED = 'aitherboard_pinned_events'; |
||||||
|
const STORAGE_KEY_BOOKMARKED = 'aitherboard_bookmarked_events'; |
||||||
|
const STORAGE_KEY_HIGHLIGHTED = 'aitherboard_highlighted_events'; |
||||||
|
|
||||||
|
/** |
||||||
|
* Get all pinned event IDs |
||||||
|
*/ |
||||||
|
export function getPinnedEvents(): Set<string> { |
||||||
|
if (typeof window === 'undefined') return new Set(); |
||||||
|
try { |
||||||
|
const stored = localStorage.getItem(STORAGE_KEY_PINNED); |
||||||
|
if (!stored) return new Set(); |
||||||
|
const ids = JSON.parse(stored) as string[]; |
||||||
|
return new Set(ids); |
||||||
|
} catch { |
||||||
|
return new Set(); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get all bookmarked event IDs |
||||||
|
*/ |
||||||
|
export function getBookmarkedEvents(): Set<string> { |
||||||
|
if (typeof window === 'undefined') return new Set(); |
||||||
|
try { |
||||||
|
const stored = localStorage.getItem(STORAGE_KEY_BOOKMARKED); |
||||||
|
if (!stored) return new Set(); |
||||||
|
const ids = JSON.parse(stored) as string[]; |
||||||
|
return new Set(ids); |
||||||
|
} catch { |
||||||
|
return new Set(); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get all highlighted event IDs |
||||||
|
*/ |
||||||
|
export function getHighlightedEvents(): Set<string> { |
||||||
|
if (typeof window === 'undefined') return new Set(); |
||||||
|
try { |
||||||
|
const stored = localStorage.getItem(STORAGE_KEY_HIGHLIGHTED); |
||||||
|
if (!stored) return new Set(); |
||||||
|
const ids = JSON.parse(stored) as string[]; |
||||||
|
return new Set(ids); |
||||||
|
} catch { |
||||||
|
return new Set(); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Check if an event is pinned |
||||||
|
*/ |
||||||
|
export function isPinned(eventId: string): boolean { |
||||||
|
return getPinnedEvents().has(eventId); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Check if an event is bookmarked |
||||||
|
*/ |
||||||
|
export function isBookmarked(eventId: string): boolean { |
||||||
|
return getBookmarkedEvents().has(eventId); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Check if an event is highlighted |
||||||
|
*/ |
||||||
|
export function isHighlighted(eventId: string): boolean { |
||||||
|
return getHighlightedEvents().has(eventId); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Toggle pin status of an event |
||||||
|
*/ |
||||||
|
export function togglePin(eventId: string): boolean { |
||||||
|
const pinned = getPinnedEvents(); |
||||||
|
const isCurrentlyPinned = pinned.has(eventId); |
||||||
|
|
||||||
|
if (isCurrentlyPinned) { |
||||||
|
pinned.delete(eventId); |
||||||
|
} else { |
||||||
|
pinned.add(eventId); |
||||||
|
} |
||||||
|
|
||||||
|
try { |
||||||
|
localStorage.setItem(STORAGE_KEY_PINNED, JSON.stringify(Array.from(pinned))); |
||||||
|
return !isCurrentlyPinned; |
||||||
|
} catch (error) { |
||||||
|
console.error('Failed to save pinned events:', error); |
||||||
|
return isCurrentlyPinned; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Toggle bookmark status of an event |
||||||
|
*/ |
||||||
|
export function toggleBookmark(eventId: string): boolean { |
||||||
|
const bookmarked = getBookmarkedEvents(); |
||||||
|
const isCurrentlyBookmarked = bookmarked.has(eventId); |
||||||
|
|
||||||
|
if (isCurrentlyBookmarked) { |
||||||
|
bookmarked.delete(eventId); |
||||||
|
} else { |
||||||
|
bookmarked.add(eventId); |
||||||
|
} |
||||||
|
|
||||||
|
try { |
||||||
|
localStorage.setItem(STORAGE_KEY_BOOKMARKED, JSON.stringify(Array.from(bookmarked))); |
||||||
|
return !isCurrentlyBookmarked; |
||||||
|
} catch (error) { |
||||||
|
console.error('Failed to save bookmarked events:', error); |
||||||
|
return isCurrentlyBookmarked; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Toggle highlight status of an event |
||||||
|
*/ |
||||||
|
export function toggleHighlight(eventId: string): boolean { |
||||||
|
const highlighted = getHighlightedEvents(); |
||||||
|
const isCurrentlyHighlighted = highlighted.has(eventId); |
||||||
|
|
||||||
|
if (isCurrentlyHighlighted) { |
||||||
|
highlighted.delete(eventId); |
||||||
|
} else { |
||||||
|
highlighted.add(eventId); |
||||||
|
} |
||||||
|
|
||||||
|
try { |
||||||
|
localStorage.setItem(STORAGE_KEY_HIGHLIGHTED, JSON.stringify(Array.from(highlighted))); |
||||||
|
return !isCurrentlyHighlighted; |
||||||
|
} catch (error) { |
||||||
|
console.error('Failed to save highlighted events:', error); |
||||||
|
return isCurrentlyHighlighted; |
||||||
|
} |
||||||
|
} |
||||||
Loading…
Reference in new issue