18 changed files with 2844 additions and 189 deletions
@ -1,8 +1,8 @@
@@ -1,8 +1,8 @@
|
||||
{ |
||||
"status": "ok", |
||||
"service": "aitherboard", |
||||
"version": "0.2.1", |
||||
"buildTime": "2026-02-11T09:48:17.166Z", |
||||
"version": "0.3.0", |
||||
"buildTime": "2026-02-11T10:22:29.390Z", |
||||
"gitCommit": "unknown", |
||||
"timestamp": 1770803297166 |
||||
"timestamp": 1770805349390 |
||||
} |
||||
@ -0,0 +1,549 @@
@@ -0,0 +1,549 @@
|
||||
<script lang="ts"> |
||||
import { nostrClient } from '../../services/nostr/nostr-client.js'; |
||||
import { relayManager } from '../../services/nostr/relay-manager.js'; |
||||
import { cacheEvent, getEventsByPubkey, deleteEvents } from '../../services/cache/event-cache.js'; |
||||
import { deleteArchivedEventsByPubkey } from '../../services/cache/event-archive.js'; |
||||
import { deleteProfile } from '../../services/cache/profile-cache.js'; |
||||
import { sessionManager } from '../../services/auth/session-manager.js'; |
||||
import PublicationStatusModal from './PublicationStatusModal.svelte'; |
||||
import { KIND } from '../../types/kind-lookup.js'; |
||||
import { goto } from '$app/navigation'; |
||||
|
||||
interface Props { |
||||
open?: boolean; |
||||
pubkey: string; |
||||
onClose: () => void; |
||||
} |
||||
|
||||
let { open = $bindable(false), pubkey, onClose }: Props = $props(); |
||||
|
||||
let reason = $state(''); |
||||
let selectedRelays = $state<Set<string>>(new Set()); |
||||
let publishing = $state(false); |
||||
let publicationModalOpen = $state(false); |
||||
let publicationResults = $state<{ success: string[]; failed: Array<{ relay: string; error: string }> } | null>(null); |
||||
let availableRelays = $state<string[]>([]); |
||||
|
||||
// Load available relays when modal opens |
||||
$effect(() => { |
||||
if (open) { |
||||
loadAvailableRelays(); |
||||
} |
||||
}); |
||||
|
||||
function loadAvailableRelays() { |
||||
// Get all available relays (default, profile, user inbox/outbox, etc.) |
||||
const allRelays = relayManager.getAllAvailableRelays(); |
||||
// Remove duplicates and sort |
||||
availableRelays = [...new Set(allRelays)].sort(); |
||||
// Pre-select all relays by default |
||||
selectedRelays = new Set(availableRelays); |
||||
} |
||||
|
||||
function toggleRelay(relay: string) { |
||||
const newSet = new Set(selectedRelays); |
||||
if (newSet.has(relay)) { |
||||
newSet.delete(relay); |
||||
} else { |
||||
newSet.add(relay); |
||||
} |
||||
selectedRelays = newSet; |
||||
} |
||||
|
||||
function selectAll() { |
||||
selectedRelays = new Set(availableRelays); |
||||
} |
||||
|
||||
function deselectAll() { |
||||
selectedRelays = new Set(); |
||||
} |
||||
|
||||
function close() { |
||||
open = false; |
||||
reason = ''; |
||||
selectedRelays = new Set(); |
||||
publicationModalOpen = false; |
||||
publicationResults = null; |
||||
} |
||||
|
||||
async function deleteAllEvents() { |
||||
if (selectedRelays.size === 0) { |
||||
alert('Please select at least one relay to send the deletion request to.'); |
||||
return; |
||||
} |
||||
|
||||
const session = sessionManager.getSession(); |
||||
if (!session) { |
||||
alert('You must be logged in to delete events.'); |
||||
return; |
||||
} |
||||
|
||||
if (session.pubkey !== pubkey) { |
||||
alert('You can only delete your own events.'); |
||||
return; |
||||
} |
||||
|
||||
publishing = true; |
||||
|
||||
try { |
||||
// Create kind 62 deletion request |
||||
// According to NIP-62, this should have: |
||||
// - p tag with the pubkey whose events should be deleted |
||||
// - r tags for each relay that should receive the request |
||||
// - Optional reason in content |
||||
const tags: string[][] = [['p', pubkey]]; |
||||
|
||||
// Add r tags for selected relays |
||||
for (const relay of selectedRelays) { |
||||
tags.push(['r', relay]); |
||||
} |
||||
|
||||
const deletionRequest = { |
||||
kind: KIND.DELETION_REQUEST, |
||||
pubkey: session.pubkey, |
||||
created_at: Math.floor(Date.now() / 1000), |
||||
tags, |
||||
content: reason.trim() || '' // Optional reason |
||||
}; |
||||
|
||||
const signedEvent = await session.signer(deletionRequest); |
||||
await cacheEvent(signedEvent); |
||||
|
||||
// Publish to the selected relays |
||||
const relays = Array.from(selectedRelays); |
||||
const results = await nostrClient.publish(signedEvent, { relays }); |
||||
publicationResults = results; |
||||
publicationModalOpen = true; |
||||
|
||||
if (results.success.length > 0) { |
||||
// Delete all events from this pubkey from cache and archive |
||||
try { |
||||
// Get all events by this pubkey from cache |
||||
const cachedEvents = await getEventsByPubkey(pubkey); |
||||
if (cachedEvents.length > 0) { |
||||
const eventIds = cachedEvents.map(e => e.id); |
||||
await deleteEvents(eventIds); |
||||
} |
||||
|
||||
// Delete all archived events by this pubkey |
||||
await deleteArchivedEventsByPubkey(pubkey); |
||||
|
||||
// Delete profile from cache (so profile page shows blank) |
||||
await deleteProfile(pubkey); |
||||
} catch (error) { |
||||
console.error('Error deleting events from cache/archive:', error); |
||||
} |
||||
|
||||
// Clear the form after successful publication |
||||
reason = ''; |
||||
selectedRelays = new Set(); |
||||
// Close the modal after a delay and refresh the profile page |
||||
setTimeout(() => { |
||||
close(); |
||||
// Reload the current page to refresh the UI (profile should be blank) |
||||
window.location.reload(); |
||||
}, 2000); |
||||
} |
||||
} catch (error) { |
||||
console.error('Error creating deletion request:', error); |
||||
publicationResults = { |
||||
success: [], |
||||
failed: [{ relay: 'Unknown', error: error instanceof Error ? error.message : 'Unknown error' }] |
||||
}; |
||||
publicationModalOpen = true; |
||||
} finally { |
||||
publishing = false; |
||||
} |
||||
} |
||||
</script> |
||||
|
||||
{#if open} |
||||
<div |
||||
class="modal-overlay" |
||||
onclick={(e) => { |
||||
if (e.target === e.currentTarget) close(); |
||||
}} |
||||
onkeydown={(e) => { |
||||
if (e.key === 'Escape') close(); |
||||
}} |
||||
role="dialog" |
||||
aria-modal="true" |
||||
aria-labelledby="delete-all-modal-title" |
||||
tabindex="-1" |
||||
> |
||||
<div class="modal-content"> |
||||
<div class="modal-header"> |
||||
<h2 id="delete-all-modal-title">Delete All Events</h2> |
||||
<button onclick={close} class="close-button" aria-label="Close">×</button> |
||||
</div> |
||||
|
||||
<div class="modal-body"> |
||||
<p class="modal-description"> |
||||
This will create a kind 62 deletion request to delete all events from this npub. Select which relays should receive the request. |
||||
</p> |
||||
|
||||
<div class="form-group"> |
||||
<label for="delete-reason" class="form-label">Reason (optional)</label> |
||||
<textarea |
||||
id="delete-reason" |
||||
bind:value={reason} |
||||
class="reason-input" |
||||
rows="4" |
||||
placeholder="Enter an optional reason for deletion..." |
||||
disabled={publishing} |
||||
></textarea> |
||||
</div> |
||||
|
||||
<div class="form-group"> |
||||
<div class="relay-selection-header"> |
||||
<div class="form-label">Select Relays ({selectedRelays.size} of {availableRelays.length})</div> |
||||
<div class="relay-selection-actions"> |
||||
<button class="select-all-button" onclick={selectAll} disabled={publishing}> |
||||
Select All |
||||
</button> |
||||
<button class="deselect-all-button" onclick={deselectAll} disabled={publishing}> |
||||
Deselect All |
||||
</button> |
||||
</div> |
||||
</div> |
||||
<div class="relay-list"> |
||||
{#each availableRelays as relay} |
||||
<label class="relay-item"> |
||||
<input |
||||
type="checkbox" |
||||
checked={selectedRelays.has(relay)} |
||||
onchange={() => toggleRelay(relay)} |
||||
disabled={publishing} |
||||
/> |
||||
<span class="relay-url">{relay}</span> |
||||
</label> |
||||
{/each} |
||||
</div> |
||||
</div> |
||||
|
||||
<div class="form-actions"> |
||||
<button |
||||
class="cancel-button" |
||||
onclick={close} |
||||
disabled={publishing} |
||||
> |
||||
Cancel |
||||
</button> |
||||
<button |
||||
class="delete-button" |
||||
onclick={deleteAllEvents} |
||||
disabled={publishing || selectedRelays.size === 0} |
||||
> |
||||
{publishing ? 'Sending...' : 'Delete All Events'} |
||||
</button> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
{/if} |
||||
|
||||
<PublicationStatusModal bind:open={publicationModalOpen} bind:results={publicationResults} /> |
||||
|
||||
<style> |
||||
.modal-overlay { |
||||
position: fixed; |
||||
top: 0; |
||||
left: 0; |
||||
right: 0; |
||||
bottom: 0; |
||||
background: rgba(0, 0, 0, 0.5); |
||||
z-index: 2000; |
||||
display: flex; |
||||
align-items: center; |
||||
justify-content: center; |
||||
padding: 1rem; |
||||
} |
||||
|
||||
:global(.dark) .modal-overlay { |
||||
background: rgba(0, 0, 0, 0.7); |
||||
} |
||||
|
||||
.modal-content { |
||||
position: relative; |
||||
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: 600px; |
||||
width: 100%; |
||||
max-height: 90vh; |
||||
display: flex; |
||||
flex-direction: column; |
||||
overflow: hidden; |
||||
} |
||||
|
||||
:global(.dark) .modal-content { |
||||
background: var(--fog-dark-post, #1f2937); |
||||
border-color: var(--fog-dark-border, #374151); |
||||
} |
||||
|
||||
.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; |
||||
font-weight: 600; |
||||
color: var(--fog-text, #1f2937); |
||||
} |
||||
|
||||
:global(.dark) .modal-header h2 { |
||||
color: var(--fog-dark-text, #f9fafb); |
||||
} |
||||
|
||||
.close-button { |
||||
background: transparent; |
||||
border: none; |
||||
font-size: 1.5rem; |
||||
line-height: 1; |
||||
cursor: pointer; |
||||
color: var(--fog-text-light, #52667a); |
||||
padding: 0.25rem 0.5rem; |
||||
border-radius: 0.25rem; |
||||
transition: background 0.2s; |
||||
} |
||||
|
||||
.close-button:hover { |
||||
background: var(--fog-highlight, #f3f4f6); |
||||
color: var(--fog-text, #1f2937); |
||||
} |
||||
|
||||
:global(.dark) .close-button { |
||||
color: var(--fog-dark-text-light, #a8b8d0); |
||||
} |
||||
|
||||
:global(.dark) .close-button:hover { |
||||
background: var(--fog-dark-highlight, #374151); |
||||
color: var(--fog-dark-text, #f9fafb); |
||||
} |
||||
|
||||
.modal-body { |
||||
padding: 1rem; |
||||
overflow-y: auto; |
||||
flex: 1; |
||||
} |
||||
|
||||
.modal-description { |
||||
margin: 0 0 1rem 0; |
||||
color: var(--fog-text-light, #52667a); |
||||
font-size: 0.875rem; |
||||
} |
||||
|
||||
:global(.dark) .modal-description { |
||||
color: var(--fog-dark-text-light, #a8b8d0); |
||||
} |
||||
|
||||
.form-group { |
||||
display: flex; |
||||
flex-direction: column; |
||||
gap: 0.5rem; |
||||
margin-bottom: 1rem; |
||||
} |
||||
|
||||
.form-label { |
||||
font-weight: 500; |
||||
color: var(--fog-text, #1f2937); |
||||
font-size: 0.875rem; |
||||
} |
||||
|
||||
:global(.dark) .form-label { |
||||
color: var(--fog-dark-text, #f9fafb); |
||||
} |
||||
|
||||
.reason-input { |
||||
padding: 0.75rem; |
||||
border: 1px solid var(--fog-border, #e5e7eb); |
||||
border-radius: 0.25rem; |
||||
background: var(--fog-post, #ffffff); |
||||
color: var(--fog-text, #1f2937); |
||||
font-size: 0.875rem; |
||||
font-family: inherit; |
||||
resize: vertical; |
||||
width: 100%; |
||||
box-sizing: border-box; |
||||
} |
||||
|
||||
:global(.dark) .reason-input { |
||||
border-color: var(--fog-dark-border, #374151); |
||||
background: var(--fog-dark-post, #1f2937); |
||||
color: var(--fog-dark-text, #f9fafb); |
||||
} |
||||
|
||||
.reason-input:focus { |
||||
outline: none; |
||||
border-color: var(--fog-accent, #64748b); |
||||
} |
||||
|
||||
:global(.dark) .reason-input:focus { |
||||
border-color: var(--fog-dark-accent, #94a3b8); |
||||
} |
||||
|
||||
.reason-input:disabled { |
||||
opacity: 0.6; |
||||
cursor: not-allowed; |
||||
} |
||||
|
||||
.relay-selection-header { |
||||
display: flex; |
||||
justify-content: space-between; |
||||
align-items: center; |
||||
flex-wrap: wrap; |
||||
gap: 0.5rem; |
||||
} |
||||
|
||||
.relay-selection-actions { |
||||
display: flex; |
||||
gap: 0.5rem; |
||||
} |
||||
|
||||
.select-all-button, |
||||
.deselect-all-button { |
||||
padding: 0.25rem 0.5rem; |
||||
border: 1px solid var(--fog-border, #e5e7eb); |
||||
border-radius: 0.25rem; |
||||
background: var(--fog-highlight, #f3f4f6); |
||||
color: var(--fog-text, #1f2937); |
||||
font-size: 0.75rem; |
||||
cursor: pointer; |
||||
transition: all 0.2s; |
||||
} |
||||
|
||||
:global(.dark) .select-all-button, |
||||
:global(.dark) .deselect-all-button { |
||||
border-color: var(--fog-dark-border, #374151); |
||||
background: var(--fog-dark-highlight, #374151); |
||||
color: var(--fog-dark-text, #f9fafb); |
||||
} |
||||
|
||||
.select-all-button:hover:not(:disabled), |
||||
.deselect-all-button:hover:not(:disabled) { |
||||
background: var(--fog-border, #e5e7eb); |
||||
} |
||||
|
||||
:global(.dark) .select-all-button:hover:not(:disabled), |
||||
:global(.dark) .deselect-all-button:hover:not(:disabled) { |
||||
background: var(--fog-dark-border, #475569); |
||||
} |
||||
|
||||
.select-all-button:disabled, |
||||
.deselect-all-button:disabled { |
||||
opacity: 0.6; |
||||
cursor: not-allowed; |
||||
} |
||||
|
||||
.relay-list { |
||||
max-height: 300px; |
||||
overflow-y: auto; |
||||
border: 1px solid var(--fog-border, #e5e7eb); |
||||
border-radius: 0.25rem; |
||||
padding: 0.5rem; |
||||
background: var(--fog-post, #ffffff); |
||||
} |
||||
|
||||
:global(.dark) .relay-list { |
||||
border-color: var(--fog-dark-border, #374151); |
||||
background: var(--fog-dark-post, #1f2937); |
||||
} |
||||
|
||||
.relay-item { |
||||
display: flex; |
||||
align-items: center; |
||||
gap: 0.5rem; |
||||
padding: 0.5rem; |
||||
cursor: pointer; |
||||
border-radius: 0.25rem; |
||||
transition: background 0.2s; |
||||
} |
||||
|
||||
.relay-item:hover { |
||||
background: var(--fog-highlight, #f3f4f6); |
||||
} |
||||
|
||||
:global(.dark) .relay-item:hover { |
||||
background: var(--fog-dark-highlight, #374151); |
||||
} |
||||
|
||||
.relay-item input[type="checkbox"] { |
||||
cursor: pointer; |
||||
} |
||||
|
||||
.relay-url { |
||||
font-family: monospace; |
||||
font-size: 0.875rem; |
||||
color: var(--fog-text, #1f2937); |
||||
word-break: break-all; |
||||
} |
||||
|
||||
:global(.dark) .relay-url { |
||||
color: var(--fog-dark-text, #f9fafb); |
||||
} |
||||
|
||||
.form-actions { |
||||
display: flex; |
||||
gap: 0.5rem; |
||||
justify-content: flex-end; |
||||
} |
||||
|
||||
.cancel-button, |
||||
.delete-button { |
||||
padding: 0.5rem 1rem; |
||||
border-radius: 0.25rem; |
||||
font-size: 0.875rem; |
||||
font-weight: 500; |
||||
cursor: pointer; |
||||
transition: all 0.2s; |
||||
} |
||||
|
||||
.cancel-button { |
||||
background: var(--fog-highlight, #f3f4f6); |
||||
color: var(--fog-text, #1f2937); |
||||
border: 1px solid var(--fog-border, #e5e7eb); |
||||
} |
||||
|
||||
:global(.dark) .cancel-button { |
||||
background: var(--fog-dark-highlight, #374151); |
||||
color: var(--fog-dark-text, #f9fafb); |
||||
border-color: var(--fog-dark-border, #475569); |
||||
} |
||||
|
||||
.cancel-button:hover:not(:disabled) { |
||||
background: var(--fog-border, #e5e7eb); |
||||
} |
||||
|
||||
:global(.dark) .cancel-button:hover:not(:disabled) { |
||||
background: var(--fog-dark-border, #475569); |
||||
} |
||||
|
||||
.delete-button { |
||||
background: var(--fog-danger, #dc2626); |
||||
color: white; |
||||
border: none; |
||||
} |
||||
|
||||
:global(.dark) .delete-button { |
||||
background: var(--fog-dark-danger, #ef4444); |
||||
} |
||||
|
||||
.delete-button:hover:not(:disabled) { |
||||
opacity: 0.9; |
||||
} |
||||
|
||||
.delete-button:disabled { |
||||
opacity: 0.6; |
||||
cursor: not-allowed; |
||||
} |
||||
</style> |
||||
@ -0,0 +1,371 @@
@@ -0,0 +1,371 @@
|
||||
<script lang="ts"> |
||||
import { nostrClient } from '../../services/nostr/nostr-client.js'; |
||||
import { relayManager } from '../../services/nostr/relay-manager.js'; |
||||
import { cacheEvent, deleteEvent as deleteEventFromCache } from '../../services/cache/event-cache.js'; |
||||
import { deleteArchivedEvent } from '../../services/cache/event-archive.js'; |
||||
import { deleteProfile } from '../../services/cache/profile-cache.js'; |
||||
import { sessionManager } from '../../services/auth/session-manager.js'; |
||||
import PublicationStatusModal from './PublicationStatusModal.svelte'; |
||||
import { KIND } from '../../types/kind-lookup.js'; |
||||
import type { NostrEvent } from '../../types/nostr.js'; |
||||
|
||||
interface Props { |
||||
open?: boolean; |
||||
event: NostrEvent; |
||||
onClose: () => void; |
||||
} |
||||
|
||||
let { open = $bindable(false), event, onClose }: Props = $props(); |
||||
|
||||
let reason = $state(''); |
||||
let publishing = $state(false); |
||||
let publicationModalOpen = $state(false); |
||||
let publicationResults = $state<{ success: string[]; failed: Array<{ relay: string; error: string }> } | null>(null); |
||||
|
||||
function close() { |
||||
open = false; |
||||
reason = ''; |
||||
publicationModalOpen = false; |
||||
publicationResults = null; |
||||
} |
||||
|
||||
async function deleteEvent() { |
||||
const session = sessionManager.getSession(); |
||||
if (!session) { |
||||
alert('You must be logged in to delete an event.'); |
||||
return; |
||||
} |
||||
|
||||
if (session.pubkey !== event.pubkey) { |
||||
alert('You can only delete your own events.'); |
||||
return; |
||||
} |
||||
|
||||
publishing = true; |
||||
|
||||
try { |
||||
// Create kind 5 deletion event |
||||
const deletionEvent = { |
||||
kind: KIND.EVENT_DELETION, |
||||
pubkey: session.pubkey, |
||||
created_at: Math.floor(Date.now() / 1000), |
||||
tags: [['e', event.id]], // Reference the deleted event |
||||
content: reason.trim() || '' // Optional reason |
||||
}; |
||||
|
||||
const signedEvent = await session.signer(deletionEvent); |
||||
await cacheEvent(signedEvent); |
||||
|
||||
const relays = relayManager.getPublishRelays( |
||||
[...relayManager.getThreadReadRelays(), ...relayManager.getFeedReadRelays()], |
||||
true |
||||
); |
||||
|
||||
const results = await nostrClient.publish(signedEvent, { relays }); |
||||
publicationResults = results; |
||||
publicationModalOpen = true; |
||||
|
||||
if (results.success.length > 0) { |
||||
// Delete from cache and archive |
||||
try { |
||||
await deleteEventFromCache(event.id); |
||||
await deleteArchivedEvent(event.id); |
||||
|
||||
// If this is a profile event (kind 0), also delete the profile cache |
||||
if (event.kind === KIND.METADATA) { |
||||
await deleteProfile(event.pubkey); |
||||
} |
||||
} catch (error) { |
||||
console.error('Error deleting event from cache/archive:', error); |
||||
} |
||||
|
||||
// Clear the form after successful publication |
||||
reason = ''; |
||||
// Close the modal after a delay and refresh the page |
||||
setTimeout(() => { |
||||
close(); |
||||
// Reload the current page to refresh the UI |
||||
window.location.reload(); |
||||
}, 2000); |
||||
} |
||||
} catch (error) { |
||||
console.error('Error deleting event:', error); |
||||
publicationResults = { |
||||
success: [], |
||||
failed: [{ relay: 'Unknown', error: error instanceof Error ? error.message : 'Unknown error' }] |
||||
}; |
||||
publicationModalOpen = true; |
||||
} finally { |
||||
publishing = false; |
||||
} |
||||
} |
||||
</script> |
||||
|
||||
{#if open} |
||||
<div |
||||
class="modal-overlay" |
||||
onclick={(e) => { |
||||
if (e.target === e.currentTarget) close(); |
||||
}} |
||||
onkeydown={(e) => { |
||||
if (e.key === 'Escape') close(); |
||||
}} |
||||
role="dialog" |
||||
aria-modal="true" |
||||
aria-labelledby="delete-modal-title" |
||||
tabindex="-1" |
||||
> |
||||
<div class="modal-content"> |
||||
<div class="modal-header"> |
||||
<h2 id="delete-modal-title">Delete Event</h2> |
||||
<button onclick={close} class="close-button" aria-label="Close">×</button> |
||||
</div> |
||||
|
||||
<div class="modal-body"> |
||||
<p class="modal-description"> |
||||
This will create a deletion request (kind 5) for this event. You can optionally provide a reason. |
||||
</p> |
||||
|
||||
<div class="form-group"> |
||||
<label for="delete-reason" class="form-label">Reason (optional)</label> |
||||
<textarea |
||||
id="delete-reason" |
||||
bind:value={reason} |
||||
class="reason-input" |
||||
rows="4" |
||||
placeholder="Enter an optional reason for deletion..." |
||||
disabled={publishing} |
||||
></textarea> |
||||
</div> |
||||
|
||||
<div class="form-actions"> |
||||
<button |
||||
class="cancel-button" |
||||
onclick={close} |
||||
disabled={publishing} |
||||
> |
||||
Cancel |
||||
</button> |
||||
<button |
||||
class="delete-button" |
||||
onclick={deleteEvent} |
||||
disabled={publishing} |
||||
> |
||||
{publishing ? 'Deleting...' : 'Delete Event'} |
||||
</button> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
{/if} |
||||
|
||||
<PublicationStatusModal bind:open={publicationModalOpen} bind:results={publicationResults} /> |
||||
|
||||
<style> |
||||
.modal-overlay { |
||||
position: fixed; |
||||
top: 0; |
||||
left: 0; |
||||
right: 0; |
||||
bottom: 0; |
||||
background: rgba(0, 0, 0, 0.5); |
||||
z-index: 2000; |
||||
display: flex; |
||||
align-items: center; |
||||
justify-content: center; |
||||
padding: 1rem; |
||||
} |
||||
|
||||
:global(.dark) .modal-overlay { |
||||
background: rgba(0, 0, 0, 0.7); |
||||
} |
||||
|
||||
.modal-content { |
||||
position: relative; |
||||
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: 500px; |
||||
width: 100%; |
||||
max-height: 90vh; |
||||
display: flex; |
||||
flex-direction: column; |
||||
overflow: hidden; |
||||
} |
||||
|
||||
:global(.dark) .modal-content { |
||||
background: var(--fog-dark-post, #1f2937); |
||||
border-color: var(--fog-dark-border, #374151); |
||||
} |
||||
|
||||
.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; |
||||
font-weight: 600; |
||||
color: var(--fog-text, #1f2937); |
||||
} |
||||
|
||||
:global(.dark) .modal-header h2 { |
||||
color: var(--fog-dark-text, #f9fafb); |
||||
} |
||||
|
||||
.close-button { |
||||
background: transparent; |
||||
border: none; |
||||
font-size: 1.5rem; |
||||
line-height: 1; |
||||
cursor: pointer; |
||||
color: var(--fog-text-light, #52667a); |
||||
padding: 0.25rem 0.5rem; |
||||
border-radius: 0.25rem; |
||||
transition: background 0.2s; |
||||
} |
||||
|
||||
.close-button:hover { |
||||
background: var(--fog-highlight, #f3f4f6); |
||||
color: var(--fog-text, #1f2937); |
||||
} |
||||
|
||||
:global(.dark) .close-button { |
||||
color: var(--fog-dark-text-light, #a8b8d0); |
||||
} |
||||
|
||||
:global(.dark) .close-button:hover { |
||||
background: var(--fog-dark-highlight, #374151); |
||||
color: var(--fog-dark-text, #f9fafb); |
||||
} |
||||
|
||||
.modal-body { |
||||
padding: 1rem; |
||||
overflow-y: auto; |
||||
flex: 1; |
||||
} |
||||
|
||||
.modal-description { |
||||
margin: 0 0 1rem 0; |
||||
color: var(--fog-text-light, #52667a); |
||||
font-size: 0.875rem; |
||||
} |
||||
|
||||
:global(.dark) .modal-description { |
||||
color: var(--fog-dark-text-light, #a8b8d0); |
||||
} |
||||
|
||||
.form-group { |
||||
display: flex; |
||||
flex-direction: column; |
||||
gap: 0.5rem; |
||||
margin-bottom: 1rem; |
||||
} |
||||
|
||||
.form-label { |
||||
font-weight: 500; |
||||
color: var(--fog-text, #1f2937); |
||||
font-size: 0.875rem; |
||||
} |
||||
|
||||
:global(.dark) .form-label { |
||||
color: var(--fog-dark-text, #f9fafb); |
||||
} |
||||
|
||||
.reason-input { |
||||
padding: 0.75rem; |
||||
border: 1px solid var(--fog-border, #e5e7eb); |
||||
border-radius: 0.25rem; |
||||
background: var(--fog-post, #ffffff); |
||||
color: var(--fog-text, #1f2937); |
||||
font-size: 0.875rem; |
||||
font-family: inherit; |
||||
resize: vertical; |
||||
width: 100%; |
||||
box-sizing: border-box; |
||||
} |
||||
|
||||
:global(.dark) .reason-input { |
||||
border-color: var(--fog-dark-border, #374151); |
||||
background: var(--fog-dark-post, #1f2937); |
||||
color: var(--fog-dark-text, #f9fafb); |
||||
} |
||||
|
||||
.reason-input:focus { |
||||
outline: none; |
||||
border-color: var(--fog-accent, #64748b); |
||||
} |
||||
|
||||
:global(.dark) .reason-input:focus { |
||||
border-color: var(--fog-dark-accent, #94a3b8); |
||||
} |
||||
|
||||
.reason-input:disabled { |
||||
opacity: 0.6; |
||||
cursor: not-allowed; |
||||
} |
||||
|
||||
.form-actions { |
||||
display: flex; |
||||
gap: 0.5rem; |
||||
justify-content: flex-end; |
||||
} |
||||
|
||||
.cancel-button, |
||||
.delete-button { |
||||
padding: 0.5rem 1rem; |
||||
border-radius: 0.25rem; |
||||
font-size: 0.875rem; |
||||
font-weight: 500; |
||||
cursor: pointer; |
||||
transition: all 0.2s; |
||||
} |
||||
|
||||
.cancel-button { |
||||
background: var(--fog-highlight, #f3f4f6); |
||||
color: var(--fog-text, #1f2937); |
||||
border: 1px solid var(--fog-border, #e5e7eb); |
||||
} |
||||
|
||||
:global(.dark) .cancel-button { |
||||
background: var(--fog-dark-highlight, #374151); |
||||
color: var(--fog-dark-text, #f9fafb); |
||||
border-color: var(--fog-dark-border, #475569); |
||||
} |
||||
|
||||
.cancel-button:hover:not(:disabled) { |
||||
background: var(--fog-border, #e5e7eb); |
||||
} |
||||
|
||||
:global(.dark) .cancel-button:hover:not(:disabled) { |
||||
background: var(--fog-dark-border, #475569); |
||||
} |
||||
|
||||
.delete-button { |
||||
background: var(--fog-danger, #dc2626); |
||||
color: white; |
||||
border: none; |
||||
} |
||||
|
||||
:global(.dark) .delete-button { |
||||
background: var(--fog-dark-danger, #ef4444); |
||||
} |
||||
|
||||
.delete-button:hover:not(:disabled) { |
||||
opacity: 0.9; |
||||
} |
||||
|
||||
.delete-button:disabled { |
||||
opacity: 0.6; |
||||
cursor: not-allowed; |
||||
} |
||||
</style> |
||||
@ -0,0 +1,355 @@
@@ -0,0 +1,355 @@
|
||||
<script lang="ts"> |
||||
import { nostrClient } from '../../services/nostr/nostr-client.js'; |
||||
import { relayManager } from '../../services/nostr/relay-manager.js'; |
||||
import { cacheEvent } from '../../services/cache/event-cache.js'; |
||||
import { sessionManager } from '../../services/auth/session-manager.js'; |
||||
import PublicationStatusModal from './PublicationStatusModal.svelte'; |
||||
import type { NostrEvent } from '../../types/nostr.js'; |
||||
|
||||
interface Props { |
||||
open?: boolean; |
||||
event: NostrEvent; |
||||
onClose: () => void; |
||||
} |
||||
|
||||
let { open = $bindable(false), event, onClose }: Props = $props(); |
||||
|
||||
let reason = $state(''); |
||||
let publishing = $state(false); |
||||
let publicationModalOpen = $state(false); |
||||
let publicationResults = $state<{ success: string[]; failed: Array<{ relay: string; error: string }> } | null>(null); |
||||
|
||||
function close() { |
||||
open = false; |
||||
reason = ''; |
||||
publicationModalOpen = false; |
||||
publicationResults = null; |
||||
} |
||||
|
||||
async function sendReport() { |
||||
if (!reason.trim()) { |
||||
alert('Please enter a reason for reporting this event.'); |
||||
return; |
||||
} |
||||
|
||||
const session = sessionManager.getSession(); |
||||
if (!session) { |
||||
alert('You must be logged in to report an event.'); |
||||
return; |
||||
} |
||||
|
||||
publishing = true; |
||||
|
||||
try { |
||||
const eventTemplate = { |
||||
kind: 1984, |
||||
pubkey: session.pubkey, |
||||
created_at: Math.floor(Date.now() / 1000), |
||||
tags: [ |
||||
['e', event.id], // Tag the reported event |
||||
['p', event.pubkey] // Tag the event author |
||||
], |
||||
content: reason.trim() |
||||
}; |
||||
|
||||
const signedEvent = await session.signer(eventTemplate); |
||||
await cacheEvent(signedEvent); |
||||
|
||||
const relays = relayManager.getPublishRelays( |
||||
relayManager.getProfileReadRelays(), |
||||
true |
||||
); |
||||
|
||||
const results = await nostrClient.publish(signedEvent, { relays }); |
||||
publicationResults = results; |
||||
publicationModalOpen = true; |
||||
|
||||
if (results.success.length > 0) { |
||||
// Clear the form after successful publication |
||||
reason = ''; |
||||
// Close the modal after a delay |
||||
setTimeout(() => { |
||||
close(); |
||||
}, 2000); |
||||
} |
||||
} catch (error) { |
||||
console.error('Error publishing report:', error); |
||||
publicationResults = { |
||||
success: [], |
||||
failed: [{ relay: 'Unknown', error: error instanceof Error ? error.message : 'Unknown error' }] |
||||
}; |
||||
publicationModalOpen = true; |
||||
} finally { |
||||
publishing = false; |
||||
} |
||||
} |
||||
</script> |
||||
|
||||
{#if open} |
||||
<div |
||||
class="modal-overlay" |
||||
onclick={(e) => { |
||||
if (e.target === e.currentTarget) close(); |
||||
}} |
||||
onkeydown={(e) => { |
||||
if (e.key === 'Escape') close(); |
||||
}} |
||||
role="dialog" |
||||
aria-modal="true" |
||||
aria-labelledby="report-modal-title" |
||||
tabindex="-1" |
||||
> |
||||
<div class="modal-content"> |
||||
<div class="modal-header"> |
||||
<h2 id="report-modal-title">Report Event</h2> |
||||
<button onclick={close} class="close-button" aria-label="Close">×</button> |
||||
</div> |
||||
|
||||
<div class="modal-body"> |
||||
<p class="modal-description"> |
||||
Please provide a reason for reporting this event. This will create a kind 1984 event. |
||||
</p> |
||||
|
||||
<div class="form-group"> |
||||
<label for="report-reason" class="form-label">Reason</label> |
||||
<textarea |
||||
id="report-reason" |
||||
bind:value={reason} |
||||
class="reason-input" |
||||
rows="6" |
||||
placeholder="Enter the reason for reporting this event..." |
||||
disabled={publishing} |
||||
></textarea> |
||||
</div> |
||||
|
||||
<div class="form-actions"> |
||||
<button |
||||
class="cancel-button" |
||||
onclick={close} |
||||
disabled={publishing} |
||||
> |
||||
Cancel |
||||
</button> |
||||
<button |
||||
class="send-button" |
||||
onclick={sendReport} |
||||
disabled={publishing || !reason.trim()} |
||||
> |
||||
{publishing ? 'Sending...' : 'Send Report'} |
||||
</button> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
{/if} |
||||
|
||||
<PublicationStatusModal bind:open={publicationModalOpen} bind:results={publicationResults} /> |
||||
|
||||
<style> |
||||
.modal-overlay { |
||||
position: fixed; |
||||
top: 0; |
||||
left: 0; |
||||
right: 0; |
||||
bottom: 0; |
||||
background: rgba(0, 0, 0, 0.5); |
||||
z-index: 2000; |
||||
display: flex; |
||||
align-items: center; |
||||
justify-content: center; |
||||
padding: 1rem; |
||||
} |
||||
|
||||
:global(.dark) .modal-overlay { |
||||
background: rgba(0, 0, 0, 0.7); |
||||
} |
||||
|
||||
.modal-content { |
||||
position: relative; |
||||
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: 500px; |
||||
width: 100%; |
||||
max-height: 90vh; |
||||
display: flex; |
||||
flex-direction: column; |
||||
overflow: hidden; |
||||
} |
||||
|
||||
:global(.dark) .modal-content { |
||||
background: var(--fog-dark-post, #1f2937); |
||||
border-color: var(--fog-dark-border, #374151); |
||||
} |
||||
|
||||
.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; |
||||
font-weight: 600; |
||||
color: var(--fog-text, #1f2937); |
||||
} |
||||
|
||||
:global(.dark) .modal-header h2 { |
||||
color: var(--fog-dark-text, #f9fafb); |
||||
} |
||||
|
||||
.close-button { |
||||
background: transparent; |
||||
border: none; |
||||
font-size: 1.5rem; |
||||
line-height: 1; |
||||
cursor: pointer; |
||||
color: var(--fog-text-light, #52667a); |
||||
padding: 0.25rem 0.5rem; |
||||
border-radius: 0.25rem; |
||||
transition: background 0.2s; |
||||
} |
||||
|
||||
.close-button:hover { |
||||
background: var(--fog-highlight, #f3f4f6); |
||||
color: var(--fog-text, #1f2937); |
||||
} |
||||
|
||||
:global(.dark) .close-button { |
||||
color: var(--fog-dark-text-light, #a8b8d0); |
||||
} |
||||
|
||||
:global(.dark) .close-button:hover { |
||||
background: var(--fog-dark-highlight, #374151); |
||||
color: var(--fog-dark-text, #f9fafb); |
||||
} |
||||
|
||||
.modal-body { |
||||
padding: 1rem; |
||||
overflow-y: auto; |
||||
flex: 1; |
||||
} |
||||
|
||||
.modal-description { |
||||
margin: 0 0 1rem 0; |
||||
color: var(--fog-text-light, #52667a); |
||||
font-size: 0.875rem; |
||||
} |
||||
|
||||
:global(.dark) .modal-description { |
||||
color: var(--fog-dark-text-light, #a8b8d0); |
||||
} |
||||
|
||||
.form-group { |
||||
display: flex; |
||||
flex-direction: column; |
||||
gap: 0.5rem; |
||||
margin-bottom: 1rem; |
||||
} |
||||
|
||||
.form-label { |
||||
font-weight: 500; |
||||
color: var(--fog-text, #1f2937); |
||||
font-size: 0.875rem; |
||||
} |
||||
|
||||
:global(.dark) .form-label { |
||||
color: var(--fog-dark-text, #f9fafb); |
||||
} |
||||
|
||||
.reason-input { |
||||
padding: 0.75rem; |
||||
border: 1px solid var(--fog-border, #e5e7eb); |
||||
border-radius: 0.25rem; |
||||
background: var(--fog-post, #ffffff); |
||||
color: var(--fog-text, #1f2937); |
||||
font-size: 0.875rem; |
||||
font-family: inherit; |
||||
resize: vertical; |
||||
width: 100%; |
||||
box-sizing: border-box; |
||||
} |
||||
|
||||
:global(.dark) .reason-input { |
||||
border-color: var(--fog-dark-border, #374151); |
||||
background: var(--fog-dark-post, #1f2937); |
||||
color: var(--fog-dark-text, #f9fafb); |
||||
} |
||||
|
||||
.reason-input:focus { |
||||
outline: none; |
||||
border-color: var(--fog-accent, #64748b); |
||||
} |
||||
|
||||
:global(.dark) .reason-input:focus { |
||||
border-color: var(--fog-dark-accent, #94a3b8); |
||||
} |
||||
|
||||
.reason-input:disabled { |
||||
opacity: 0.6; |
||||
cursor: not-allowed; |
||||
} |
||||
|
||||
.form-actions { |
||||
display: flex; |
||||
gap: 0.5rem; |
||||
justify-content: flex-end; |
||||
} |
||||
|
||||
.cancel-button, |
||||
.send-button { |
||||
padding: 0.5rem 1rem; |
||||
border-radius: 0.25rem; |
||||
font-size: 0.875rem; |
||||
font-weight: 500; |
||||
cursor: pointer; |
||||
transition: all 0.2s; |
||||
} |
||||
|
||||
.cancel-button { |
||||
background: var(--fog-highlight, #f3f4f6); |
||||
color: var(--fog-text, #1f2937); |
||||
border: 1px solid var(--fog-border, #e5e7eb); |
||||
} |
||||
|
||||
:global(.dark) .cancel-button { |
||||
background: var(--fog-dark-highlight, #374151); |
||||
color: var(--fog-dark-text, #f9fafb); |
||||
border-color: var(--fog-dark-border, #475569); |
||||
} |
||||
|
||||
.cancel-button:hover:not(:disabled) { |
||||
background: var(--fog-border, #e5e7eb); |
||||
} |
||||
|
||||
:global(.dark) .cancel-button:hover:not(:disabled) { |
||||
background: var(--fog-dark-border, #475569); |
||||
} |
||||
|
||||
.send-button { |
||||
background: var(--fog-accent, #64748b); |
||||
color: white; |
||||
border: none; |
||||
} |
||||
|
||||
:global(.dark) .send-button { |
||||
background: var(--fog-dark-accent, #94a3b8); |
||||
} |
||||
|
||||
.send-button:hover:not(:disabled) { |
||||
opacity: 0.9; |
||||
} |
||||
|
||||
.send-button:disabled { |
||||
opacity: 0.6; |
||||
cursor: not-allowed; |
||||
} |
||||
</style> |
||||
@ -0,0 +1,353 @@
@@ -0,0 +1,353 @@
|
||||
<script lang="ts"> |
||||
import { nostrClient } from '../../services/nostr/nostr-client.js'; |
||||
import { relayManager } from '../../services/nostr/relay-manager.js'; |
||||
import { cacheEvent } from '../../services/cache/event-cache.js'; |
||||
import { sessionManager } from '../../services/auth/session-manager.js'; |
||||
import PublicationStatusModal from './PublicationStatusModal.svelte'; |
||||
|
||||
interface Props { |
||||
open?: boolean; |
||||
reportedPubkey: string; |
||||
onClose: () => void; |
||||
} |
||||
|
||||
let { open = $bindable(false), reportedPubkey, onClose }: Props = $props(); |
||||
|
||||
let reason = $state(''); |
||||
let publishing = $state(false); |
||||
let publicationModalOpen = $state(false); |
||||
let publicationResults = $state<{ success: string[]; failed: Array<{ relay: string; error: string }> } | null>(null); |
||||
|
||||
function close() { |
||||
open = false; |
||||
reason = ''; |
||||
publicationModalOpen = false; |
||||
publicationResults = null; |
||||
} |
||||
|
||||
async function sendReport() { |
||||
if (!reason.trim()) { |
||||
alert('Please enter a reason for reporting this user.'); |
||||
return; |
||||
} |
||||
|
||||
const session = sessionManager.getSession(); |
||||
if (!session) { |
||||
alert('You must be logged in to report a user.'); |
||||
return; |
||||
} |
||||
|
||||
publishing = true; |
||||
|
||||
try { |
||||
const eventTemplate = { |
||||
kind: 1984, |
||||
pubkey: session.pubkey, |
||||
created_at: Math.floor(Date.now() / 1000), |
||||
tags: [ |
||||
['p', reportedPubkey] // Tag the reported user |
||||
], |
||||
content: reason.trim() |
||||
}; |
||||
|
||||
const signedEvent = await session.signer(eventTemplate); |
||||
await cacheEvent(signedEvent); |
||||
|
||||
const relays = relayManager.getPublishRelays( |
||||
relayManager.getProfileReadRelays(), |
||||
true |
||||
); |
||||
|
||||
const results = await nostrClient.publish(signedEvent, { relays }); |
||||
publicationResults = results; |
||||
publicationModalOpen = true; |
||||
|
||||
if (results.success.length > 0) { |
||||
// Clear the form after successful publication |
||||
reason = ''; |
||||
// Close the modal after a delay |
||||
setTimeout(() => { |
||||
close(); |
||||
}, 2000); |
||||
} |
||||
} catch (error) { |
||||
console.error('Error publishing report:', error); |
||||
publicationResults = { |
||||
success: [], |
||||
failed: [{ relay: 'Unknown', error: error instanceof Error ? error.message : 'Unknown error' }] |
||||
}; |
||||
publicationModalOpen = true; |
||||
} finally { |
||||
publishing = false; |
||||
} |
||||
} |
||||
</script> |
||||
|
||||
{#if open} |
||||
<div |
||||
class="modal-overlay" |
||||
onclick={(e) => { |
||||
if (e.target === e.currentTarget) close(); |
||||
}} |
||||
onkeydown={(e) => { |
||||
if (e.key === 'Escape') close(); |
||||
}} |
||||
role="dialog" |
||||
aria-modal="true" |
||||
aria-labelledby="report-modal-title" |
||||
tabindex="-1" |
||||
> |
||||
<div class="modal-content"> |
||||
<div class="modal-header"> |
||||
<h2 id="report-modal-title">Report User</h2> |
||||
<button onclick={close} class="close-button" aria-label="Close">×</button> |
||||
</div> |
||||
|
||||
<div class="modal-body"> |
||||
<p class="modal-description"> |
||||
Please provide a reason for reporting this user. This will create a kind 1984 event. |
||||
</p> |
||||
|
||||
<div class="form-group"> |
||||
<label for="report-reason" class="form-label">Reason</label> |
||||
<textarea |
||||
id="report-reason" |
||||
bind:value={reason} |
||||
class="reason-input" |
||||
rows="6" |
||||
placeholder="Enter the reason for reporting this user..." |
||||
disabled={publishing} |
||||
></textarea> |
||||
</div> |
||||
|
||||
<div class="form-actions"> |
||||
<button |
||||
class="cancel-button" |
||||
onclick={close} |
||||
disabled={publishing} |
||||
> |
||||
Cancel |
||||
</button> |
||||
<button |
||||
class="send-button" |
||||
onclick={sendReport} |
||||
disabled={publishing || !reason.trim()} |
||||
> |
||||
{publishing ? 'Sending...' : 'Send Report'} |
||||
</button> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
{/if} |
||||
|
||||
<PublicationStatusModal bind:open={publicationModalOpen} bind:results={publicationResults} /> |
||||
|
||||
<style> |
||||
.modal-overlay { |
||||
position: fixed; |
||||
top: 0; |
||||
left: 0; |
||||
right: 0; |
||||
bottom: 0; |
||||
background: rgba(0, 0, 0, 0.5); |
||||
z-index: 2000; |
||||
display: flex; |
||||
align-items: center; |
||||
justify-content: center; |
||||
padding: 1rem; |
||||
} |
||||
|
||||
:global(.dark) .modal-overlay { |
||||
background: rgba(0, 0, 0, 0.7); |
||||
} |
||||
|
||||
.modal-content { |
||||
position: relative; |
||||
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: 500px; |
||||
width: 100%; |
||||
max-height: 90vh; |
||||
display: flex; |
||||
flex-direction: column; |
||||
overflow: hidden; |
||||
} |
||||
|
||||
:global(.dark) .modal-content { |
||||
background: var(--fog-dark-post, #1f2937); |
||||
border-color: var(--fog-dark-border, #374151); |
||||
} |
||||
|
||||
.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; |
||||
font-weight: 600; |
||||
color: var(--fog-text, #1f2937); |
||||
} |
||||
|
||||
:global(.dark) .modal-header h2 { |
||||
color: var(--fog-dark-text, #f9fafb); |
||||
} |
||||
|
||||
.close-button { |
||||
background: transparent; |
||||
border: none; |
||||
font-size: 1.5rem; |
||||
line-height: 1; |
||||
cursor: pointer; |
||||
color: var(--fog-text-light, #52667a); |
||||
padding: 0.25rem 0.5rem; |
||||
border-radius: 0.25rem; |
||||
transition: background 0.2s; |
||||
} |
||||
|
||||
.close-button:hover { |
||||
background: var(--fog-highlight, #f3f4f6); |
||||
color: var(--fog-text, #1f2937); |
||||
} |
||||
|
||||
:global(.dark) .close-button { |
||||
color: var(--fog-dark-text-light, #a8b8d0); |
||||
} |
||||
|
||||
:global(.dark) .close-button:hover { |
||||
background: var(--fog-dark-highlight, #374151); |
||||
color: var(--fog-dark-text, #f9fafb); |
||||
} |
||||
|
||||
.modal-body { |
||||
padding: 1rem; |
||||
overflow-y: auto; |
||||
flex: 1; |
||||
} |
||||
|
||||
.modal-description { |
||||
margin: 0 0 1rem 0; |
||||
color: var(--fog-text-light, #52667a); |
||||
font-size: 0.875rem; |
||||
} |
||||
|
||||
:global(.dark) .modal-description { |
||||
color: var(--fog-dark-text-light, #a8b8d0); |
||||
} |
||||
|
||||
.form-group { |
||||
display: flex; |
||||
flex-direction: column; |
||||
gap: 0.5rem; |
||||
margin-bottom: 1rem; |
||||
} |
||||
|
||||
.form-label { |
||||
font-weight: 500; |
||||
color: var(--fog-text, #1f2937); |
||||
font-size: 0.875rem; |
||||
} |
||||
|
||||
:global(.dark) .form-label { |
||||
color: var(--fog-dark-text, #f9fafb); |
||||
} |
||||
|
||||
.reason-input { |
||||
padding: 0.75rem; |
||||
border: 1px solid var(--fog-border, #e5e7eb); |
||||
border-radius: 0.25rem; |
||||
background: var(--fog-post, #ffffff); |
||||
color: var(--fog-text, #1f2937); |
||||
font-size: 0.875rem; |
||||
font-family: inherit; |
||||
resize: vertical; |
||||
width: 100%; |
||||
box-sizing: border-box; |
||||
} |
||||
|
||||
:global(.dark) .reason-input { |
||||
border-color: var(--fog-dark-border, #374151); |
||||
background: var(--fog-dark-post, #1f2937); |
||||
color: var(--fog-dark-text, #f9fafb); |
||||
} |
||||
|
||||
.reason-input:focus { |
||||
outline: none; |
||||
border-color: var(--fog-accent, #64748b); |
||||
} |
||||
|
||||
:global(.dark) .reason-input:focus { |
||||
border-color: var(--fog-dark-accent, #94a3b8); |
||||
} |
||||
|
||||
.reason-input:disabled { |
||||
opacity: 0.6; |
||||
cursor: not-allowed; |
||||
} |
||||
|
||||
.form-actions { |
||||
display: flex; |
||||
gap: 0.5rem; |
||||
justify-content: flex-end; |
||||
} |
||||
|
||||
.cancel-button, |
||||
.send-button { |
||||
padding: 0.5rem 1rem; |
||||
border-radius: 0.25rem; |
||||
font-size: 0.875rem; |
||||
font-weight: 500; |
||||
cursor: pointer; |
||||
transition: all 0.2s; |
||||
} |
||||
|
||||
.cancel-button { |
||||
background: var(--fog-highlight, #f3f4f6); |
||||
color: var(--fog-text, #1f2937); |
||||
border: 1px solid var(--fog-border, #e5e7eb); |
||||
} |
||||
|
||||
:global(.dark) .cancel-button { |
||||
background: var(--fog-dark-highlight, #374151); |
||||
color: var(--fog-dark-text, #f9fafb); |
||||
border-color: var(--fog-dark-border, #475569); |
||||
} |
||||
|
||||
.cancel-button:hover:not(:disabled) { |
||||
background: var(--fog-border, #e5e7eb); |
||||
} |
||||
|
||||
:global(.dark) .cancel-button:hover:not(:disabled) { |
||||
background: var(--fog-dark-border, #475569); |
||||
} |
||||
|
||||
.send-button { |
||||
background: var(--fog-accent, #64748b); |
||||
color: white; |
||||
border: none; |
||||
} |
||||
|
||||
:global(.dark) .send-button { |
||||
background: var(--fog-dark-accent, #94a3b8); |
||||
} |
||||
|
||||
.send-button:hover:not(:disabled) { |
||||
opacity: 0.9; |
||||
} |
||||
|
||||
.send-button:disabled { |
||||
opacity: 0.6; |
||||
cursor: not-allowed; |
||||
} |
||||
</style> |
||||
@ -0,0 +1,449 @@
@@ -0,0 +1,449 @@
|
||||
<script lang="ts"> |
||||
import { getVersionsForEvent } from '../../services/cache/version-history.js'; |
||||
import type { EventVersion } from '../../services/cache/version-history.js'; |
||||
import type { NostrEvent } from '../../types/nostr.js'; |
||||
// @ts-ignore - highlight.js default export works at runtime |
||||
import hljs from 'highlight.js'; |
||||
import 'highlight.js/styles/vs2015.css'; |
||||
|
||||
interface Props { |
||||
open?: boolean; |
||||
event: NostrEvent; |
||||
onClose: () => void; |
||||
} |
||||
|
||||
let { open = $bindable(false), event, onClose }: Props = $props(); |
||||
|
||||
let versions = $state<EventVersion[]>([]); |
||||
let loading = $state(false); |
||||
let selectedVersion = $state<EventVersion | null>(null); |
||||
let jsonPreviewRef: HTMLElement | null = $state(null); |
||||
|
||||
// Load versions when modal opens |
||||
$effect(() => { |
||||
if (open) { |
||||
loadVersions(); |
||||
} |
||||
}); |
||||
|
||||
async function loadVersions() { |
||||
loading = true; |
||||
try { |
||||
versions = await getVersionsForEvent(event); |
||||
// If there are versions, select the first one (newest) |
||||
if (versions.length > 0) { |
||||
selectedVersion = versions[0]; |
||||
} |
||||
} catch (error) { |
||||
console.error('Error loading versions:', error); |
||||
versions = []; |
||||
} finally { |
||||
loading = false; |
||||
} |
||||
} |
||||
|
||||
// Highlight JSON when selectedVersion changes |
||||
$effect(() => { |
||||
if (jsonPreviewRef && selectedVersion && jsonPreviewRef instanceof HTMLElement) { |
||||
try { |
||||
const jsonText = JSON.stringify(selectedVersion.event, 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 (selectedVersion) { |
||||
jsonPreviewRef.textContent = JSON.stringify(selectedVersion.event, null, 2); |
||||
jsonPreviewRef.className = 'language-json'; |
||||
} |
||||
} |
||||
} |
||||
}); |
||||
|
||||
function formatDate(timestamp: number): string { |
||||
return new Date(timestamp).toLocaleString(); |
||||
} |
||||
</script> |
||||
|
||||
{#if open} |
||||
<div |
||||
class="modal-overlay" |
||||
onclick={(e) => { |
||||
if (e.target === e.currentTarget) onClose(); |
||||
}} |
||||
onkeydown={(e) => { |
||||
if (e.key === 'Escape') onClose(); |
||||
}} |
||||
role="dialog" |
||||
aria-modal="true" |
||||
aria-labelledby="version-history-modal-title" |
||||
tabindex="-1" |
||||
> |
||||
<div class="modal-content"> |
||||
<div class="modal-header"> |
||||
<h2 id="version-history-modal-title">Version History</h2> |
||||
<button onclick={onClose} class="close-button" aria-label="Close">×</button> |
||||
</div> |
||||
|
||||
<div class="modal-body"> |
||||
{#if loading} |
||||
<p class="loading-text">Loading versions...</p> |
||||
{:else if versions.length === 0} |
||||
<p class="no-versions-text">No version history available for this event.</p> |
||||
{:else} |
||||
<div class="version-history-container"> |
||||
<div class="version-list"> |
||||
<h3 class="version-list-title">Versions ({versions.length})</h3> |
||||
<div class="version-items"> |
||||
{#each versions as version (version.id)} |
||||
<button |
||||
class="version-item" |
||||
class:active={selectedVersion?.id === version.id} |
||||
onclick={() => selectedVersion = version} |
||||
> |
||||
<div class="version-item-header"> |
||||
<span class="version-number">Version {version.versionNumber}</span> |
||||
{#if version.versionNumber === versions[0].versionNumber} |
||||
<span class="version-badge">Latest</span> |
||||
{/if} |
||||
</div> |
||||
<div class="version-item-meta"> |
||||
<span class="version-date">{formatDate(version.savedAt)}</span> |
||||
</div> |
||||
<div class="version-item-id"> |
||||
<code class="event-id">{version.event.id.substring(0, 16)}...</code> |
||||
</div> |
||||
</button> |
||||
{/each} |
||||
</div> |
||||
</div> |
||||
|
||||
<div class="version-viewer"> |
||||
{#if selectedVersion} |
||||
<div class="version-viewer-header"> |
||||
<h3>Version {selectedVersion.versionNumber}</h3> |
||||
<div class="version-viewer-meta"> |
||||
<span>Saved: {formatDate(selectedVersion.savedAt)}</span> |
||||
<span>Event ID: <code>{selectedVersion.event.id}</code></span> |
||||
</div> |
||||
</div> |
||||
<div class="version-viewer-content"> |
||||
<pre class="json-content"><code bind:this={jsonPreviewRef} class="language-json"></code></pre> |
||||
</div> |
||||
{:else} |
||||
<p class="no-selection-text">Select a version to view</p> |
||||
{/if} |
||||
</div> |
||||
</div> |
||||
{/if} |
||||
</div> |
||||
</div> |
||||
</div> |
||||
{/if} |
||||
|
||||
<style> |
||||
.modal-overlay { |
||||
position: fixed; |
||||
top: 0; |
||||
left: 0; |
||||
right: 0; |
||||
bottom: 0; |
||||
background: rgba(0, 0, 0, 0.5); |
||||
z-index: 2000; |
||||
display: flex; |
||||
align-items: center; |
||||
justify-content: center; |
||||
padding: 1rem; |
||||
} |
||||
|
||||
:global(.dark) .modal-overlay { |
||||
background: rgba(0, 0, 0, 0.7); |
||||
} |
||||
|
||||
.modal-content { |
||||
position: relative; |
||||
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; |
||||
width: 100%; |
||||
max-height: 90vh; |
||||
display: flex; |
||||
flex-direction: column; |
||||
overflow: hidden; |
||||
} |
||||
|
||||
:global(.dark) .modal-content { |
||||
background: var(--fog-dark-post, #1f2937); |
||||
border-color: var(--fog-dark-border, #374151); |
||||
} |
||||
|
||||
.modal-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) .modal-header { |
||||
border-bottom-color: var(--fog-dark-border, #374151); |
||||
} |
||||
|
||||
.modal-header h2 { |
||||
margin: 0; |
||||
font-size: 1.25rem; |
||||
font-weight: 600; |
||||
color: var(--fog-text, #1f2937); |
||||
} |
||||
|
||||
:global(.dark) .modal-header h2 { |
||||
color: var(--fog-dark-text, #f9fafb); |
||||
} |
||||
|
||||
.close-button { |
||||
background: transparent; |
||||
border: none; |
||||
font-size: 1.5rem; |
||||
line-height: 1; |
||||
cursor: pointer; |
||||
color: var(--fog-text-light, #52667a); |
||||
padding: 0.25rem 0.5rem; |
||||
border-radius: 0.25rem; |
||||
transition: background 0.2s; |
||||
} |
||||
|
||||
.close-button:hover { |
||||
background: var(--fog-highlight, #f3f4f6); |
||||
color: var(--fog-text, #1f2937); |
||||
} |
||||
|
||||
:global(.dark) .close-button { |
||||
color: var(--fog-dark-text-light, #a8b8d0); |
||||
} |
||||
|
||||
:global(.dark) .close-button:hover { |
||||
background: var(--fog-dark-highlight, #374151); |
||||
color: var(--fog-dark-text, #f9fafb); |
||||
} |
||||
|
||||
.modal-body { |
||||
padding: 1rem; |
||||
overflow-y: auto; |
||||
flex: 1; |
||||
} |
||||
|
||||
.loading-text, |
||||
.no-versions-text, |
||||
.no-selection-text { |
||||
text-align: center; |
||||
color: var(--fog-text-light, #52667a); |
||||
padding: 2rem; |
||||
} |
||||
|
||||
:global(.dark) .loading-text, |
||||
:global(.dark) .no-versions-text, |
||||
:global(.dark) .no-selection-text { |
||||
color: var(--fog-dark-text-light, #a8b8d0); |
||||
} |
||||
|
||||
.version-history-container { |
||||
display: grid; |
||||
grid-template-columns: 300px 1fr; |
||||
gap: 1rem; |
||||
height: calc(90vh - 200px); |
||||
min-height: 400px; |
||||
} |
||||
|
||||
@media (max-width: 768px) { |
||||
.version-history-container { |
||||
grid-template-columns: 1fr; |
||||
grid-template-rows: auto 1fr; |
||||
} |
||||
} |
||||
|
||||
.version-list { |
||||
display: flex; |
||||
flex-direction: column; |
||||
border: 1px solid var(--fog-border, #e5e7eb); |
||||
border-radius: 0.25rem; |
||||
overflow: hidden; |
||||
} |
||||
|
||||
:global(.dark) .version-list { |
||||
border-color: var(--fog-dark-border, #374151); |
||||
} |
||||
|
||||
.version-list-title { |
||||
margin: 0; |
||||
padding: 0.75rem; |
||||
font-size: 0.875rem; |
||||
font-weight: 600; |
||||
color: var(--fog-text, #1f2937); |
||||
background: var(--fog-highlight, #f3f4f6); |
||||
border-bottom: 1px solid var(--fog-border, #e5e7eb); |
||||
} |
||||
|
||||
:global(.dark) .version-list-title { |
||||
color: var(--fog-dark-text, #f9fafb); |
||||
background: var(--fog-dark-highlight, #374151); |
||||
border-bottom-color: var(--fog-dark-border, #374151); |
||||
} |
||||
|
||||
.version-items { |
||||
overflow-y: auto; |
||||
flex: 1; |
||||
} |
||||
|
||||
.version-item { |
||||
display: flex; |
||||
flex-direction: column; |
||||
gap: 0.25rem; |
||||
width: 100%; |
||||
padding: 0.75rem; |
||||
background: var(--fog-post, #ffffff); |
||||
border: none; |
||||
border-bottom: 1px solid var(--fog-border, #e5e7eb); |
||||
text-align: left; |
||||
cursor: pointer; |
||||
transition: background 0.2s; |
||||
} |
||||
|
||||
:global(.dark) .version-item { |
||||
background: var(--fog-dark-post, #1f2937); |
||||
border-bottom-color: var(--fog-dark-border, #374151); |
||||
} |
||||
|
||||
.version-item:hover { |
||||
background: var(--fog-highlight, #f3f4f6); |
||||
} |
||||
|
||||
:global(.dark) .version-item:hover { |
||||
background: var(--fog-dark-highlight, #374151); |
||||
} |
||||
|
||||
.version-item.active { |
||||
background: var(--fog-accent, #64748b); |
||||
color: white; |
||||
} |
||||
|
||||
:global(.dark) .version-item.active { |
||||
background: var(--fog-dark-accent, #94a3b8); |
||||
} |
||||
|
||||
.version-item-header { |
||||
display: flex; |
||||
justify-content: space-between; |
||||
align-items: center; |
||||
} |
||||
|
||||
.version-number { |
||||
font-weight: 600; |
||||
font-size: 0.875rem; |
||||
} |
||||
|
||||
.version-badge { |
||||
font-size: 0.75rem; |
||||
padding: 0.125rem 0.5rem; |
||||
background: var(--fog-accent, #64748b); |
||||
color: white; |
||||
border-radius: 0.25rem; |
||||
} |
||||
|
||||
.version-item.active .version-badge { |
||||
background: rgba(255, 255, 255, 0.3); |
||||
} |
||||
|
||||
.version-item-meta { |
||||
font-size: 0.75rem; |
||||
opacity: 0.8; |
||||
} |
||||
|
||||
.version-item-id { |
||||
font-size: 0.75rem; |
||||
opacity: 0.7; |
||||
} |
||||
|
||||
.event-id { |
||||
font-family: monospace; |
||||
} |
||||
|
||||
.version-viewer { |
||||
display: flex; |
||||
flex-direction: column; |
||||
border: 1px solid var(--fog-border, #e5e7eb); |
||||
border-radius: 0.25rem; |
||||
overflow: hidden; |
||||
} |
||||
|
||||
:global(.dark) .version-viewer { |
||||
border-color: var(--fog-dark-border, #374151); |
||||
} |
||||
|
||||
.version-viewer-header { |
||||
padding: 0.75rem; |
||||
background: var(--fog-highlight, #f3f4f6); |
||||
border-bottom: 1px solid var(--fog-border, #e5e7eb); |
||||
} |
||||
|
||||
:global(.dark) .version-viewer-header { |
||||
background: var(--fog-dark-highlight, #374151); |
||||
border-bottom-color: var(--fog-dark-border, #374151); |
||||
} |
||||
|
||||
.version-viewer-header h3 { |
||||
margin: 0 0 0.5rem 0; |
||||
font-size: 1rem; |
||||
font-weight: 600; |
||||
color: var(--fog-text, #1f2937); |
||||
} |
||||
|
||||
:global(.dark) .version-viewer-header h3 { |
||||
color: var(--fog-dark-text, #f9fafb); |
||||
} |
||||
|
||||
.version-viewer-meta { |
||||
display: flex; |
||||
flex-direction: column; |
||||
gap: 0.25rem; |
||||
font-size: 0.75rem; |
||||
color: var(--fog-text-light, #52667a); |
||||
} |
||||
|
||||
:global(.dark) .version-viewer-meta { |
||||
color: var(--fog-dark-text-light, #a8b8d0); |
||||
} |
||||
|
||||
.version-viewer-meta code { |
||||
font-family: monospace; |
||||
font-size: 0.75rem; |
||||
} |
||||
|
||||
.version-viewer-content { |
||||
flex: 1; |
||||
overflow: auto; |
||||
padding: 1rem; |
||||
} |
||||
|
||||
.json-content { |
||||
background: #1e1e1e !important; |
||||
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; |
||||
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', 'Droid Sans Mono', 'Source Code Pro', monospace; |
||||
font-size: 0.875rem; |
||||
line-height: 1.5; |
||||
} |
||||
</style> |
||||
@ -0,0 +1,379 @@
@@ -0,0 +1,379 @@
|
||||
<script lang="ts"> |
||||
import { goto } from '$app/navigation'; |
||||
import { nostrClient } from '../../services/nostr/nostr-client.js'; |
||||
import { relayManager } from '../../services/nostr/relay-manager.js'; |
||||
import { isParameterizedReplaceableKind } from '../../types/kind-lookup.js'; |
||||
import type { NostrEvent } from '../../types/nostr.js'; |
||||
|
||||
interface Props { |
||||
isOpen: boolean; |
||||
pubkey: string; |
||||
onClose: () => void; |
||||
} |
||||
|
||||
let { isOpen, pubkey, onClose }: Props = $props(); |
||||
|
||||
const PROFILE_EVENT_KINDS = [ |
||||
{ kind: 0, name: 'Metadata (Profile)' }, |
||||
{ kind: 3, name: 'Contacts' }, |
||||
{ kind: 30315, name: 'User Status' }, |
||||
{ kind: 10133, name: 'Payment Addresses' }, |
||||
{ kind: 10002, name: 'Relay List' }, |
||||
{ kind: 10432, name: 'Local Relays' }, |
||||
{ kind: 10001, name: 'Pin List' }, |
||||
{ kind: 10003, name: 'Bookmarks' }, |
||||
{ kind: 10895, name: 'RSS Feed' }, |
||||
{ kind: 10015, name: 'Interest List' }, |
||||
{ kind: 10030, name: 'Emoji Set' }, |
||||
{ kind: 30030, name: 'Emoji Pack' }, |
||||
{ kind: 10000, name: 'Mute List' }, |
||||
{ kind: 30008, name: 'Badges' }, |
||||
{ kind: 30000, name: 'Follow Set' } |
||||
]; |
||||
|
||||
let eventMap = $state<Map<number, NostrEvent>>(new Map()); |
||||
let loadingKinds = $state<Set<number>>(new Set()); |
||||
let loadedKinds = $state<Set<number>>(new Set()); |
||||
|
||||
// Load events for all kinds when panel opens |
||||
$effect(() => { |
||||
if (isOpen && pubkey) { |
||||
loadAllEvents(); |
||||
} |
||||
}); |
||||
|
||||
async function loadAllEvents() { |
||||
// Reset state |
||||
eventMap = new Map(); |
||||
loadingKinds = new Set(); |
||||
loadedKinds = new Set(); |
||||
|
||||
// Load events for all kinds in parallel |
||||
const loadPromises = PROFILE_EVENT_KINDS.map(({ kind }) => loadEventForKind(kind)); |
||||
await Promise.all(loadPromises); |
||||
} |
||||
|
||||
async function loadEventForKind(kind: number) { |
||||
if (loadingKinds.has(kind)) return; |
||||
|
||||
loadingKinds = new Set([...loadingKinds, kind]); |
||||
|
||||
try { |
||||
const relays = relayManager.getProfileReadRelays(); |
||||
const limit = isParameterizedReplaceableKind(kind) ? 50 : 1; |
||||
const events = await nostrClient.fetchEvents( |
||||
[{ kinds: [kind], authors: [pubkey], limit }], |
||||
relays, |
||||
{ useCache: true, cacheResults: true } |
||||
); |
||||
|
||||
if (events.length > 0) { |
||||
// Get newest version (or first if multiple allowed) |
||||
const event = events.sort((a, b) => b.created_at - a.created_at)[0]; |
||||
const newMap = new Map(eventMap); |
||||
newMap.set(kind, event); |
||||
eventMap = newMap; |
||||
} |
||||
} catch (error) { |
||||
console.error(`Error loading event for kind ${kind}:`, error); |
||||
} finally { |
||||
loadingKinds = new Set([...loadingKinds].filter(k => k !== kind)); |
||||
loadedKinds = new Set([...loadedKinds, kind]); |
||||
} |
||||
} |
||||
|
||||
function handleEdit(kind: number, event: NostrEvent) { |
||||
// Store event data in sessionStorage for the write page to pick up |
||||
const editData = { |
||||
kind: event.kind, |
||||
content: event.content, |
||||
tags: event.tags, |
||||
isClone: false |
||||
}; |
||||
sessionStorage.setItem('aitherboard_cloneEvent', JSON.stringify(editData)); |
||||
onClose(); |
||||
goto('/write'); |
||||
} |
||||
|
||||
function handleCreate(kind: number) { |
||||
// Navigate to write page with kind parameter |
||||
onClose(); |
||||
goto(`/write?kind=${kind}`); |
||||
} |
||||
</script> |
||||
|
||||
{#if isOpen} |
||||
<div |
||||
class="panel-backdrop" |
||||
onclick={onClose} |
||||
onkeydown={(e) => { |
||||
if (e.key === 'Enter' || e.key === ' ') { |
||||
e.preventDefault(); |
||||
onClose(); |
||||
} |
||||
}} |
||||
role="button" |
||||
tabindex="0" |
||||
aria-label="Close panel" |
||||
></div> |
||||
<div class="edit-profile-events-panel"> |
||||
<div class="panel-header"> |
||||
<h2 class="panel-title">Edit Profile Events</h2> |
||||
<button class="panel-close" onclick={onClose}>×</button> |
||||
</div> |
||||
|
||||
<div class="panel-content"> |
||||
<div class="kind-list"> |
||||
{#each PROFILE_EVENT_KINDS as { kind, name }} |
||||
{@const event = eventMap.get(kind)} |
||||
{@const isLoading = loadingKinds.has(kind)} |
||||
{@const isLoaded = loadedKinds.has(kind)} |
||||
<div class="kind-item"> |
||||
<div class="kind-info"> |
||||
<span class="kind-name">{name}</span> |
||||
<span class="kind-number">Kind {kind}</span> |
||||
</div> |
||||
<div class="kind-actions"> |
||||
{#if isLoading} |
||||
<span class="loading-text">Loading...</span> |
||||
{:else if event} |
||||
<button |
||||
class="action-button edit-button" |
||||
onclick={() => handleEdit(kind, event)} |
||||
> |
||||
Edit |
||||
</button> |
||||
{:else if isLoaded} |
||||
<button |
||||
class="action-button create-button" |
||||
onclick={() => handleCreate(kind)} |
||||
> |
||||
Create |
||||
</button> |
||||
{:else} |
||||
<button |
||||
class="action-button create-button" |
||||
onclick={() => handleCreate(kind)} |
||||
> |
||||
Create |
||||
</button> |
||||
{/if} |
||||
</div> |
||||
</div> |
||||
{/each} |
||||
</div> |
||||
</div> |
||||
</div> |
||||
{/if} |
||||
|
||||
<style> |
||||
.panel-backdrop { |
||||
position: fixed; |
||||
top: 0; |
||||
left: 0; |
||||
right: 0; |
||||
bottom: 0; |
||||
background: rgba(0, 0, 0, 0.5); |
||||
z-index: 999; |
||||
} |
||||
|
||||
.edit-profile-events-panel { |
||||
position: fixed; |
||||
top: 0; |
||||
left: 0; |
||||
bottom: 0; |
||||
width: min(500px, 90vw); |
||||
background: var(--fog-post, #ffffff); |
||||
border-right: 2px solid var(--fog-border, #cbd5e1); |
||||
box-shadow: 2px 0 10px rgba(0, 0, 0, 0.2); |
||||
z-index: 1000; |
||||
display: flex; |
||||
flex-direction: column; |
||||
overflow: hidden; |
||||
} |
||||
|
||||
:global(.dark) .edit-profile-events-panel { |
||||
background: var(--fog-dark-post, #1f2937); |
||||
border-right-color: var(--fog-dark-border, #475569); |
||||
box-shadow: 2px 0 10px rgba(0, 0, 0, 0.5); |
||||
} |
||||
|
||||
.panel-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) .panel-header { |
||||
border-bottom-color: var(--fog-dark-border, #374151); |
||||
} |
||||
|
||||
.panel-title { |
||||
margin: 0; |
||||
font-size: 1.25rem; |
||||
font-weight: 600; |
||||
color: var(--fog-text, #1f2937); |
||||
} |
||||
|
||||
:global(.dark) .panel-title { |
||||
color: var(--fog-dark-text, #f9fafb); |
||||
} |
||||
|
||||
.panel-close { |
||||
background: transparent; |
||||
border: none; |
||||
font-size: 1.5rem; |
||||
line-height: 1; |
||||
cursor: pointer; |
||||
color: var(--fog-text-light, #52667a); |
||||
padding: 0.25rem 0.5rem; |
||||
border-radius: 0.25rem; |
||||
transition: background 0.2s; |
||||
} |
||||
|
||||
.panel-close:hover { |
||||
background: var(--fog-highlight, #f3f4f6); |
||||
color: var(--fog-text, #1f2937); |
||||
} |
||||
|
||||
:global(.dark) .panel-close { |
||||
color: var(--fog-dark-text-light, #a8b8d0); |
||||
} |
||||
|
||||
:global(.dark) .panel-close:hover { |
||||
background: var(--fog-dark-highlight, #374151); |
||||
color: var(--fog-dark-text, #f9fafb); |
||||
} |
||||
|
||||
.panel-content { |
||||
overflow-y: auto; |
||||
flex: 1; |
||||
padding: 1rem; |
||||
} |
||||
|
||||
.kind-list { |
||||
display: flex; |
||||
flex-direction: column; |
||||
gap: 0.75rem; |
||||
} |
||||
|
||||
.kind-item { |
||||
display: flex; |
||||
justify-content: space-between; |
||||
align-items: center; |
||||
padding: 0.75rem; |
||||
border: 1px solid var(--fog-border, #e5e7eb); |
||||
border-radius: 0.375rem; |
||||
background: var(--fog-post, #ffffff); |
||||
transition: all 0.2s; |
||||
} |
||||
|
||||
:global(.dark) .kind-item { |
||||
border-color: var(--fog-dark-border, #374151); |
||||
background: var(--fog-dark-post, #1f2937); |
||||
} |
||||
|
||||
.kind-item:hover { |
||||
border-color: var(--fog-accent, #64748b); |
||||
background: var(--fog-highlight, #f3f4f6); |
||||
} |
||||
|
||||
:global(.dark) .kind-item:hover { |
||||
border-color: var(--fog-dark-accent, #94a3b8); |
||||
background: var(--fog-dark-highlight, #374151); |
||||
} |
||||
|
||||
.kind-info { |
||||
display: flex; |
||||
flex-direction: column; |
||||
gap: 0.25rem; |
||||
flex: 1; |
||||
} |
||||
|
||||
.kind-name { |
||||
font-weight: 500; |
||||
color: var(--fog-text, #1f2937); |
||||
font-size: 0.875rem; |
||||
} |
||||
|
||||
:global(.dark) .kind-name { |
||||
color: var(--fog-dark-text, #f9fafb); |
||||
} |
||||
|
||||
.kind-number { |
||||
font-size: 0.75rem; |
||||
color: var(--fog-text-light, #52667a); |
||||
font-family: monospace; |
||||
} |
||||
|
||||
:global(.dark) .kind-number { |
||||
color: var(--fog-dark-text-light, #a8b8d0); |
||||
} |
||||
|
||||
.kind-actions { |
||||
display: flex; |
||||
align-items: center; |
||||
gap: 0.5rem; |
||||
} |
||||
|
||||
.action-button { |
||||
padding: 0.5rem 1rem; |
||||
border: 1px solid var(--fog-border, #e5e7eb); |
||||
border-radius: 0.25rem; |
||||
cursor: pointer; |
||||
font-size: 0.875rem; |
||||
font-weight: 500; |
||||
transition: all 0.2s; |
||||
white-space: nowrap; |
||||
} |
||||
|
||||
.edit-button { |
||||
background: var(--fog-accent, #64748b); |
||||
color: white; |
||||
border-color: var(--fog-accent, #64748b); |
||||
} |
||||
|
||||
:global(.dark) .edit-button { |
||||
background: var(--fog-dark-accent, #94a3b8); |
||||
border-color: var(--fog-dark-accent, #94a3b8); |
||||
} |
||||
|
||||
.edit-button:hover { |
||||
opacity: 0.9; |
||||
} |
||||
|
||||
.create-button { |
||||
background: var(--fog-highlight, #f3f4f6); |
||||
color: var(--fog-text, #1f2937); |
||||
border-color: var(--fog-border, #e5e7eb); |
||||
} |
||||
|
||||
:global(.dark) .create-button { |
||||
background: var(--fog-dark-highlight, #374151); |
||||
color: var(--fog-dark-text, #f9fafb); |
||||
border-color: var(--fog-dark-border, #475569); |
||||
} |
||||
|
||||
.create-button:hover { |
||||
background: var(--fog-border, #e5e7eb); |
||||
border-color: var(--fog-accent, #64748b); |
||||
} |
||||
|
||||
:global(.dark) .create-button:hover { |
||||
background: var(--fog-dark-border, #475569); |
||||
border-color: var(--fog-dark-accent, #94a3b8); |
||||
} |
||||
|
||||
.loading-text { |
||||
font-size: 0.875rem; |
||||
color: var(--fog-text-light, #52667a); |
||||
font-style: italic; |
||||
} |
||||
|
||||
:global(.dark) .loading-text { |
||||
color: var(--fog-dark-text-light, #a8b8d0); |
||||
} |
||||
</style> |
||||
@ -0,0 +1,151 @@
@@ -0,0 +1,151 @@
|
||||
/** |
||||
* Version history for replaceable events |
||||
* Stores previous versions of replaceable events before they are replaced |
||||
*/ |
||||
|
||||
import { getDB } from './indexeddb-store.js'; |
||||
import type { NostrEvent } from '../../types/nostr.js'; |
||||
import { isReplaceableKind, isParameterizedReplaceableKind } from '../../types/kind-lookup.js'; |
||||
|
||||
export interface EventVersion { |
||||
id: string; // Composite key: `${pubkey}:${kind}:${dTag || ''}:${versionNumber}`
|
||||
pubkey: string; |
||||
kind: number; |
||||
dTag: string | null; // For parameterized replaceable events
|
||||
versionNumber: number; // Incremental version number
|
||||
event: NostrEvent; |
||||
savedAt: number; // When this version was saved
|
||||
eventKey: string; // Composite key for indexing: `${pubkey}:${kind}:${dTag || ''}`
|
||||
} |
||||
|
||||
/** |
||||
* Get the composite key for a replaceable event |
||||
*/ |
||||
export function getEventKey(pubkey: string, kind: number, dTag: string | null): string { |
||||
return `${pubkey}:${kind}:${dTag || ''}`; |
||||
} |
||||
|
||||
/** |
||||
* Get the next version number for an event |
||||
*/ |
||||
async function getNextVersionNumber(pubkey: string, kind: number, dTag: string | null): Promise<number> { |
||||
try { |
||||
const db = await getDB(); |
||||
const eventKey = getEventKey(pubkey, kind, dTag); |
||||
|
||||
// Get all versions for this event key
|
||||
const tx = db.transaction('eventVersions', 'readonly'); |
||||
const index = tx.store.index('eventKey'); |
||||
const versions = await index.getAll(eventKey); |
||||
await tx.done; |
||||
|
||||
if (versions.length === 0) return 1; |
||||
|
||||
// Find the highest version number
|
||||
const maxVersion = Math.max(...versions.map(v => v.versionNumber)); |
||||
return maxVersion + 1; |
||||
} catch (error) { |
||||
// If error, start at version 1
|
||||
return 1; |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Save a version of a replaceable event to history |
||||
*/ |
||||
export async function saveEventVersion(event: NostrEvent): Promise<void> { |
||||
try { |
||||
// Only save versions for replaceable events
|
||||
if (!isReplaceableKind(event.kind) && !isParameterizedReplaceableKind(event.kind)) { |
||||
return; |
||||
} |
||||
|
||||
const db = await getDB(); |
||||
|
||||
// Get d tag for parameterized replaceable events
|
||||
const dTag = isParameterizedReplaceableKind(event.kind) |
||||
? event.tags.find(t => t[0] === 'd')?.[1] || null |
||||
: null; |
||||
|
||||
const eventKey = getEventKey(event.pubkey, event.kind, dTag); |
||||
|
||||
// Get the next version number
|
||||
const versionNumber = await getNextVersionNumber(event.pubkey, event.kind, dTag); |
||||
|
||||
// Create version record
|
||||
const version: EventVersion = { |
||||
id: `${eventKey}:${versionNumber}`, |
||||
pubkey: event.pubkey, |
||||
kind: event.kind, |
||||
dTag, |
||||
versionNumber, |
||||
event, |
||||
savedAt: Date.now(), |
||||
eventKey |
||||
}; |
||||
|
||||
// Save to version history
|
||||
await db.put('eventVersions', version); |
||||
} catch (error) { |
||||
// Version history save failed (non-critical)
|
||||
// Don't throw - version history failures shouldn't break the app
|
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Get all versions for a replaceable event |
||||
*/ |
||||
export async function getEventVersions( |
||||
pubkey: string, |
||||
kind: number, |
||||
dTag: string | null = null |
||||
): Promise<EventVersion[]> { |
||||
try { |
||||
const db = await getDB(); |
||||
const eventKey = getEventKey(pubkey, kind, dTag); |
||||
|
||||
const tx = db.transaction('eventVersions', 'readonly'); |
||||
const index = tx.store.index('eventKey'); |
||||
const versions = await index.getAll(eventKey); |
||||
await tx.done; |
||||
|
||||
// Sort by version number descending (newest first)
|
||||
return versions.sort((a, b) => b.versionNumber - a.versionNumber); |
||||
} catch (error) { |
||||
return []; |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Get a specific version by event ID (if we know which version it was) |
||||
*/ |
||||
export async function getVersionByEventId(eventId: string): Promise<EventVersion | null> { |
||||
try { |
||||
const db = await getDB(); |
||||
const tx = db.transaction('eventVersions', 'readonly'); |
||||
|
||||
// Search through all versions to find one with matching event ID
|
||||
for await (const cursor of tx.store.iterate()) { |
||||
if (cursor.value.event.id === eventId) { |
||||
return cursor.value as EventVersion; |
||||
} |
||||
} |
||||
|
||||
await tx.done; |
||||
return null; |
||||
} catch (error) { |
||||
return null; |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Get versions for a specific event (by current event ID) |
||||
* This finds the event key from the event and returns all versions |
||||
*/ |
||||
export async function getVersionsForEvent(event: NostrEvent): Promise<EventVersion[]> { |
||||
const dTag = isParameterizedReplaceableKind(event.kind) |
||||
? event.tags.find(t => t[0] === 'd')?.[1] || null |
||||
: null; |
||||
|
||||
return getEventVersions(event.pubkey, event.kind, dTag); |
||||
} |
||||
Loading…
Reference in new issue