|
|
<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'; |
|
|
import Icon from '../ui/Icon.svelte'; |
|
|
import EditProfileEventsPanel from './EditProfileEventsPanel.svelte'; |
|
|
import ReportUserModal from '../modals/ReportUserModal.svelte'; |
|
|
import DeleteAllEventsModal from '../modals/DeleteAllEventsModal.svelte'; |
|
|
// @ts-ignore - highlight.js default export works at runtime |
|
|
import hljs from 'highlight.js'; |
|
|
import 'highlight.js/styles/vs2015.css'; |
|
|
|
|
|
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 showJsonModal = $state(false); |
|
|
let jsonPreviewRef: HTMLElement | null = $state(null); |
|
|
let showEditProfileEventsPanel = $state(false); |
|
|
let showReportModal = $state(false); |
|
|
let showDeleteAllEventsModal = $state(false); |
|
|
|
|
|
// Highlight JSON when profileEvent or showJsonModal changes |
|
|
$effect(() => { |
|
|
if (jsonPreviewRef && profileEvent && jsonPreviewRef instanceof HTMLElement && showJsonModal) { |
|
|
try { |
|
|
const jsonText = JSON.stringify(profileEvent, null, 2); |
|
|
const highlighted = hljs.highlight(jsonText, { language: 'json' }).value; |
|
|
jsonPreviewRef.innerHTML = highlighted; |
|
|
jsonPreviewRef.className = 'hljs language-json'; |
|
|
} catch (err) { |
|
|
// Fallback to plain text if highlighting fails |
|
|
if (profileEvent) { |
|
|
jsonPreviewRef.textContent = JSON.stringify(profileEvent, null, 2); |
|
|
jsonPreviewRef.className = 'language-json'; |
|
|
} |
|
|
} |
|
|
} |
|
|
}); |
|
|
|
|
|
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(); |
|
|
}); |
|
|
} |
|
|
|
|
|
// Reposition menu on window resize |
|
|
$effect(() => { |
|
|
if (!menuOpen) return; |
|
|
|
|
|
function handleResize() { |
|
|
positionMenu(); |
|
|
} |
|
|
|
|
|
window.addEventListener('resize', handleResize); |
|
|
window.addEventListener('scroll', handleResize, true); |
|
|
|
|
|
return () => { |
|
|
window.removeEventListener('resize', handleResize); |
|
|
window.removeEventListener('scroll', handleResize, true); |
|
|
}; |
|
|
}); |
|
|
|
|
|
function closeMenu() { |
|
|
menuOpen = false; |
|
|
} |
|
|
|
|
|
function positionMenu() { |
|
|
if (!menuButtonElement || !menuDropdownElement) return; |
|
|
|
|
|
const buttonRect = menuButtonElement.getBoundingClientRect(); |
|
|
const viewportWidth = window.innerWidth; |
|
|
const viewportHeight = window.innerHeight; |
|
|
const padding = 8; // Padding from viewport edges |
|
|
|
|
|
// Initial position: below button, aligned to right edge |
|
|
let top = buttonRect.bottom + 4; |
|
|
let right = viewportWidth - buttonRect.right; |
|
|
|
|
|
menuPosition = { top, right }; |
|
|
|
|
|
requestAnimationFrame(() => { |
|
|
if (!menuDropdownElement) return; |
|
|
const dropdownRect = menuDropdownElement.getBoundingClientRect(); |
|
|
const dropdownWidth = dropdownRect.width; |
|
|
const dropdownHeight = dropdownRect.height; |
|
|
|
|
|
// Calculate left position from right |
|
|
const left = viewportWidth - right - dropdownWidth; |
|
|
|
|
|
let adjustedTop = top; |
|
|
let adjustedRight = right; |
|
|
|
|
|
// Check bottom overflow |
|
|
if (top + dropdownHeight + padding > viewportHeight) { |
|
|
// Try positioning above button |
|
|
const spaceAbove = buttonRect.top; |
|
|
const spaceBelow = viewportHeight - buttonRect.bottom; |
|
|
if (spaceAbove >= dropdownHeight + padding || spaceAbove > spaceBelow) { |
|
|
adjustedTop = buttonRect.top - dropdownHeight - 4; |
|
|
} else { |
|
|
// Not enough space above, position at bottom of viewport |
|
|
adjustedTop = viewportHeight - dropdownHeight - padding; |
|
|
} |
|
|
} |
|
|
|
|
|
// Check top overflow |
|
|
if (adjustedTop < padding) { |
|
|
adjustedTop = padding; |
|
|
} |
|
|
|
|
|
// Check right overflow (menu goes off right edge) |
|
|
if (left < padding) { |
|
|
adjustedRight = viewportWidth - padding - dropdownWidth; |
|
|
} |
|
|
|
|
|
// Check left overflow (menu goes off left edge) |
|
|
const adjustedLeft = viewportWidth - adjustedRight - dropdownWidth; |
|
|
if (adjustedLeft < padding) { |
|
|
adjustedRight = viewportWidth - padding - dropdownWidth; |
|
|
} |
|
|
|
|
|
// Ensure menu doesn't go off right edge |
|
|
if (adjustedRight < padding) { |
|
|
adjustedRight = padding; |
|
|
} |
|
|
|
|
|
menuPosition = { top: adjustedTop, right: adjustedRight }; |
|
|
}); |
|
|
} |
|
|
|
|
|
// 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); |
|
|
} |
|
|
} |
|
|
|
|
|
async function viewJson() { |
|
|
if (!profileEvent) { |
|
|
await loadProfileEvent(); |
|
|
} |
|
|
showJsonModal = true; |
|
|
closeMenu(); |
|
|
} |
|
|
|
|
|
function closeJsonModal() { |
|
|
showJsonModal = false; |
|
|
} |
|
|
|
|
|
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 cloneEvent() { |
|
|
if (!profileEvent) return; |
|
|
// Store event data in sessionStorage for the write page to pick up |
|
|
const cloneData = { |
|
|
kind: profileEvent.kind, |
|
|
content: profileEvent.content, |
|
|
tags: profileEvent.tags, |
|
|
isClone: true |
|
|
}; |
|
|
sessionStorage.setItem('aitherboard_cloneEvent', JSON.stringify(cloneData)); |
|
|
closeMenu(); |
|
|
goto('/write'); |
|
|
} |
|
|
|
|
|
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(); |
|
|
} |
|
|
} |
|
|
|
|
|
function openEditProfileEventsPanel() { |
|
|
showEditProfileEventsPanel = true; |
|
|
closeMenu(); |
|
|
} |
|
|
|
|
|
function closeEditProfileEventsPanel() { |
|
|
showEditProfileEventsPanel = false; |
|
|
} |
|
|
|
|
|
function openReportModal() { |
|
|
showReportModal = true; |
|
|
closeMenu(); |
|
|
} |
|
|
|
|
|
function closeReportModal() { |
|
|
showReportModal = false; |
|
|
} |
|
|
|
|
|
function openDeleteAllEventsModal() { |
|
|
showDeleteAllEventsModal = true; |
|
|
closeMenu(); |
|
|
} |
|
|
|
|
|
function closeDeleteAllEventsModal() { |
|
|
showDeleteAllEventsModal = false; |
|
|
} |
|
|
</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> |
|
|
|
|
|
<button class="menu-item" onclick={viewJson} role="menuitem" disabled={!profileEvent}> |
|
|
<span class="menu-item-icon">📄</span> |
|
|
<span class="menu-item-text">View Json</span> |
|
|
</button> |
|
|
|
|
|
{#if isLoggedIn && profileEvent} |
|
|
<button class="menu-item" onclick={cloneEvent} role="menuitem"> |
|
|
<span class="menu-item-icon">✏️</span> |
|
|
<span class="menu-item-text">Edit/Clone this event</span> |
|
|
</button> |
|
|
{/if} |
|
|
|
|
|
{#if isOwnProfile} |
|
|
<button class="menu-item" onclick={openEditProfileEventsPanel} role="menuitem"> |
|
|
<span class="menu-item-icon">⚙️</span> |
|
|
<span class="menu-item-text">Edit profile events</span> |
|
|
</button> |
|
|
<button class="menu-item menu-item-danger" onclick={openDeleteAllEventsModal} role="menuitem"> |
|
|
<span class="menu-item-icon">🗑️</span> |
|
|
<span class="menu-item-text">Delete all events from this npub</span> |
|
|
</button> |
|
|
{/if} |
|
|
|
|
|
{#if isLoggedIn && !isOwnProfile} |
|
|
<button class="menu-item" onclick={openReportModal} role="menuitem"> |
|
|
<span class="menu-item-icon">🚩</span> |
|
|
<span class="menu-item-text">Report this user</span> |
|
|
</button> |
|
|
{/if} |
|
|
|
|
|
<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"> |
|
|
{#if followed} |
|
|
<Icon name="user" size={16} /> |
|
|
{:else} |
|
|
<Icon name="plus" size={16} /> |
|
|
{/if} |
|
|
</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} |
|
|
|
|
|
</div> |
|
|
{/if} |
|
|
|
|
|
{#if showJsonModal} |
|
|
<div |
|
|
class="json-modal-overlay" |
|
|
role="dialog" |
|
|
aria-modal="true" |
|
|
aria-labelledby="json-modal-title" |
|
|
> |
|
|
<button |
|
|
class="json-modal-backdrop" |
|
|
onclick={closeJsonModal} |
|
|
onkeydown={(e) => e.key === 'Escape' && closeJsonModal()} |
|
|
aria-label="Close modal" |
|
|
></button> |
|
|
<div |
|
|
class="json-modal-content" |
|
|
role="document" |
|
|
> |
|
|
<div class="json-modal-header"> |
|
|
<h3 id="json-modal-title">Profile Event JSON</h3> |
|
|
<button class="json-modal-close" onclick={closeJsonModal} aria-label="Close">×</button> |
|
|
</div> |
|
|
<div class="json-modal-body"> |
|
|
{#if profileEvent} |
|
|
<pre class="json-content"><code bind:this={jsonPreviewRef} class="language-json">{JSON.stringify(profileEvent, null, 2)}</code></pre> |
|
|
{:else} |
|
|
<p class="text-fog-text-light dark:text-fog-dark-text-light">Loading profile event...</p> |
|
|
{/if} |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
{/if} |
|
|
|
|
|
<EditProfileEventsPanel |
|
|
isOpen={showEditProfileEventsPanel} |
|
|
pubkey={pubkey} |
|
|
onClose={closeEditProfileEventsPanel} |
|
|
/> |
|
|
|
|
|
<ReportUserModal |
|
|
bind:open={showReportModal} |
|
|
reportedPubkey={pubkey} |
|
|
onClose={closeReportModal} |
|
|
/> |
|
|
|
|
|
<DeleteAllEventsModal |
|
|
bind:open={showDeleteAllEventsModal} |
|
|
pubkey={pubkey} |
|
|
onClose={closeDeleteAllEventsModal} |
|
|
/> |
|
|
</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; |
|
|
max-width: calc(100vw - 16px); |
|
|
max-height: calc(100vh - 16px); |
|
|
padding: 0.25rem; |
|
|
overflow-y: auto; |
|
|
overflow-x: hidden; |
|
|
} |
|
|
|
|
|
: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); |
|
|
} |
|
|
|
|
|
.json-modal-overlay { |
|
|
position: fixed; |
|
|
top: 0; |
|
|
left: 0; |
|
|
right: 0; |
|
|
bottom: 0; |
|
|
z-index: 2000; |
|
|
display: flex; |
|
|
align-items: center; |
|
|
justify-content: center; |
|
|
padding: 1rem; |
|
|
} |
|
|
|
|
|
.json-modal-backdrop { |
|
|
position: absolute; |
|
|
top: 0; |
|
|
left: 0; |
|
|
right: 0; |
|
|
bottom: 0; |
|
|
background: rgba(0, 0, 0, 0.5); |
|
|
border: none; |
|
|
padding: 0; |
|
|
cursor: pointer; |
|
|
} |
|
|
|
|
|
:global(.dark) .json-modal-backdrop { |
|
|
background: rgba(0, 0, 0, 0.7); |
|
|
} |
|
|
|
|
|
.json-modal-content { |
|
|
position: relative; |
|
|
z-index: 1; |
|
|
background: var(--fog-post, #ffffff); |
|
|
border: 1px solid var(--fog-border, #e5e7eb); |
|
|
border-radius: 0.5rem; |
|
|
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2); |
|
|
max-width: 90vw; |
|
|
max-height: 90vh; |
|
|
width: 100%; |
|
|
display: flex; |
|
|
flex-direction: column; |
|
|
overflow: hidden; |
|
|
} |
|
|
|
|
|
:global(.dark) .json-modal-content { |
|
|
background: var(--fog-dark-post, #1f2937); |
|
|
border-color: var(--fog-dark-border, #374151); |
|
|
} |
|
|
|
|
|
.json-modal-header { |
|
|
display: flex; |
|
|
align-items: center; |
|
|
justify-content: space-between; |
|
|
padding: 1rem; |
|
|
border-bottom: 1px solid var(--fog-border, #e5e7eb); |
|
|
} |
|
|
|
|
|
:global(.dark) .json-modal-header { |
|
|
border-bottom-color: var(--fog-dark-border, #374151); |
|
|
} |
|
|
|
|
|
.json-modal-header h3 { |
|
|
margin: 0; |
|
|
font-size: 1.125rem; |
|
|
font-weight: 600; |
|
|
color: var(--fog-text, #1f2937); |
|
|
} |
|
|
|
|
|
:global(.dark) .json-modal-header h3 { |
|
|
color: var(--fog-dark-text, #f9fafb); |
|
|
} |
|
|
|
|
|
.json-modal-close { |
|
|
background: none; |
|
|
border: none; |
|
|
font-size: 1.5rem; |
|
|
line-height: 1; |
|
|
cursor: pointer; |
|
|
color: var(--fog-text, #1f2937); |
|
|
padding: 0.25rem 0.5rem; |
|
|
border-radius: 0.25rem; |
|
|
transition: background 0.2s; |
|
|
} |
|
|
|
|
|
:global(.dark) .json-modal-close { |
|
|
color: var(--fog-dark-text, #f9fafb); |
|
|
} |
|
|
|
|
|
.json-modal-close:hover { |
|
|
background: var(--fog-highlight, #f3f4f6); |
|
|
} |
|
|
|
|
|
:global(.dark) .json-modal-close:hover { |
|
|
background: var(--fog-dark-highlight, #374151); |
|
|
} |
|
|
|
|
|
.json-modal-body { |
|
|
padding: 1rem; |
|
|
overflow: auto; |
|
|
flex: 1; |
|
|
} |
|
|
|
|
|
.json-content { |
|
|
background: #1e1e1e !important; /* VS Code dark background, same as code blocks */ |
|
|
border: 1px solid #3e3e3e; |
|
|
border-radius: 0.25rem; |
|
|
padding: 1rem; |
|
|
margin: 0; |
|
|
overflow-x: auto; |
|
|
} |
|
|
|
|
|
.json-content code { |
|
|
display: block; |
|
|
overflow-x: auto; |
|
|
padding: 0; |
|
|
background: transparent !important; |
|
|
color: #d4d4d4; /* VS Code text color */ |
|
|
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', 'Droid Sans Mono', 'Source Code Pro', monospace; |
|
|
font-size: 0.875rem; |
|
|
line-height: 1.5; |
|
|
} |
|
|
|
|
|
@media (max-width: 640px) { |
|
|
.json-modal-content { |
|
|
max-width: 95vw; |
|
|
max-height: 95vh; |
|
|
} |
|
|
|
|
|
.json-modal-header { |
|
|
padding: 0.75rem; |
|
|
} |
|
|
|
|
|
.json-modal-header h3 { |
|
|
font-size: 1rem; |
|
|
} |
|
|
|
|
|
.json-modal-body { |
|
|
padding: 0.75rem; |
|
|
} |
|
|
|
|
|
.json-content { |
|
|
padding: 0.75rem; |
|
|
} |
|
|
|
|
|
.json-content code { |
|
|
font-size: 0.75rem; |
|
|
} |
|
|
} |
|
|
</style>
|
|
|
|