18 changed files with 2844 additions and 189 deletions
@ -1,8 +1,8 @@ |
|||||||
{ |
{ |
||||||
"status": "ok", |
"status": "ok", |
||||||
"service": "aitherboard", |
"service": "aitherboard", |
||||||
"version": "0.2.1", |
"version": "0.3.0", |
||||||
"buildTime": "2026-02-11T09:48:17.166Z", |
"buildTime": "2026-02-11T10:22:29.390Z", |
||||||
"gitCommit": "unknown", |
"gitCommit": "unknown", |
||||||
"timestamp": 1770803297166 |
"timestamp": 1770805349390 |
||||||
} |
} |
||||||
@ -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 @@ |
|||||||
|
<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 @@ |
|||||||
|
<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 @@ |
|||||||
|
<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 @@ |
|||||||
|
<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 @@ |
|||||||
|
<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 @@ |
|||||||
|
/** |
||||||
|
* 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