7 changed files with 1265 additions and 824 deletions
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,162 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import { Tooltip } from "flowbite-svelte"; |
||||||
|
import type { EventData, TagData, ValidationResult } from "./types"; |
||||||
|
import { validateEvent } from "./validation"; |
||||||
|
|
||||||
|
// AI-NOTE: 2025-01-24 - EventForm component handles basic form inputs and validation |
||||||
|
// This component focuses on event kind and content, with validation feedback |
||||||
|
|
||||||
|
let { |
||||||
|
eventData = $bindable(), |
||||||
|
tags, |
||||||
|
onvalidate, |
||||||
|
}: { |
||||||
|
eventData: EventData; |
||||||
|
tags: TagData[]; |
||||||
|
onvalidate: (isValid: boolean, error?: string, warning?: string) => void; |
||||||
|
} = $props(); |
||||||
|
|
||||||
|
let validationError = $state<string | null>(null); |
||||||
|
let validationWarning = $state<string | null>(null); |
||||||
|
|
||||||
|
/** |
||||||
|
* Validates the current form data |
||||||
|
*/ |
||||||
|
function validateForm(): ValidationResult { |
||||||
|
return validateEvent(eventData, tags); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Handles form validation |
||||||
|
*/ |
||||||
|
function handleValidate(e: Event) { |
||||||
|
e.preventDefault(); |
||||||
|
validationError = null; |
||||||
|
validationWarning = null; |
||||||
|
|
||||||
|
const validation = validateForm(); |
||||||
|
if (!validation.valid) { |
||||||
|
validationError = validation.reason || "Validation failed."; |
||||||
|
onvalidate(false, validation.reason || "Validation failed."); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
if (validation.warning) { |
||||||
|
validationWarning = validation.warning; |
||||||
|
onvalidate(true, undefined, validation.warning); |
||||||
|
} else { |
||||||
|
onvalidate(true); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Validates kind input |
||||||
|
*/ |
||||||
|
function isValidKind(kind: number | string): boolean { |
||||||
|
const n = Number(kind); |
||||||
|
return Number.isInteger(n) && n >= 0 && n <= 65535; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Gets kind description |
||||||
|
*/ |
||||||
|
function getKindDescription(kind: number): string { |
||||||
|
switch (kind) { |
||||||
|
case 1: |
||||||
|
return "Text Note"; |
||||||
|
case 30023: |
||||||
|
return "Long-form Content"; |
||||||
|
case 30040: |
||||||
|
return "Publication Index"; |
||||||
|
case 30041: |
||||||
|
return "Publication Section"; |
||||||
|
case 30818: |
||||||
|
return "AsciiDoc Document"; |
||||||
|
default: |
||||||
|
return "Custom Event"; |
||||||
|
} |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
<form class="space-y-4" onsubmit={handleValidate}> |
||||||
|
<!-- Event Kind --> |
||||||
|
<div> |
||||||
|
<label class="block font-medium mb-1 text-gray-700 dark:text-gray-300" for="event-kind"> |
||||||
|
Kind |
||||||
|
</label> |
||||||
|
<input |
||||||
|
id="event-kind" |
||||||
|
type="number" |
||||||
|
class="input input-bordered w-full" |
||||||
|
bind:value={eventData.kind} |
||||||
|
min="0" |
||||||
|
max="65535" |
||||||
|
required |
||||||
|
/> |
||||||
|
{#if !isValidKind(eventData.kind)} |
||||||
|
<div class="text-red-600 dark:text-red-400 text-sm mt-1"> |
||||||
|
Kind must be an integer between 0 and 65535 (NIP-01). |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
{#if isValidKind(eventData.kind)} |
||||||
|
<div class="flex items-center gap-2 mt-1"> |
||||||
|
<span class="text-sm text-gray-600 dark:text-gray-400"> |
||||||
|
{getKindDescription(eventData.kind)} |
||||||
|
</span> |
||||||
|
{#if eventData.kind === 30040} |
||||||
|
<Tooltip class="tooltip-leather" type="auto" placement="bottom"> |
||||||
|
<button |
||||||
|
type="button" |
||||||
|
class="w-6 h-6 rounded-full bg-blue-500 hover:bg-blue-600 text-white flex items-center justify-center text-sm font-bold border border-blue-600 shadow-sm" |
||||||
|
title="Learn more about Publication Index events" |
||||||
|
> |
||||||
|
? |
||||||
|
</button> |
||||||
|
<div class="max-w-sm p-2 text-xs"> |
||||||
|
<strong>30040 - Publication Index:</strong> Events that organize AsciiDoc content into structured publications with metadata tags and section references. |
||||||
|
</div> |
||||||
|
</Tooltip> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
|
||||||
|
<!-- Event Content --> |
||||||
|
<div> |
||||||
|
<label class="block font-medium mb-1 text-gray-700 dark:text-gray-300" for="event-content"> |
||||||
|
Content |
||||||
|
</label> |
||||||
|
<textarea |
||||||
|
id="event-content" |
||||||
|
bind:value={eventData.content} |
||||||
|
placeholder="Content (start with a header for the title)" |
||||||
|
class="textarea textarea-bordered w-full h-40" |
||||||
|
required |
||||||
|
></textarea> |
||||||
|
|
||||||
|
<!-- Content hints based on kind --> |
||||||
|
{#if eventData.kind === 30023} |
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400 mt-1"> |
||||||
|
Use Markdown format for long-form content. Do not use AsciiDoc headers (=). |
||||||
|
</div> |
||||||
|
{:else if eventData.kind === 30040 || eventData.kind === 30041 || eventData.kind === 30818} |
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400 mt-1"> |
||||||
|
Use AsciiDoc format. Start with a document title (=) and include section headers (==). |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
|
||||||
|
<!-- Validation Messages --> |
||||||
|
{#if validationError} |
||||||
|
<div class="text-red-600 dark:text-red-400 text-sm"> |
||||||
|
{validationError} |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
{#if validationWarning} |
||||||
|
<div class="text-yellow-600 dark:text-yellow-400 text-sm"> |
||||||
|
Warning: {validationWarning} |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
|
||||||
|
|
||||||
|
</form> |
||||||
@ -0,0 +1,172 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import { get } from "svelte/store"; |
||||||
|
import { userStore } from "$lib/stores/userStore"; |
||||||
|
import { prefixNostrAddresses } from "$lib/utils/nostrUtils"; |
||||||
|
import { removeMetadataFromContent } from "$lib/utils/asciidoc_metadata"; |
||||||
|
import { build30040EventSet } from "$lib/utils/event_input_utils"; |
||||||
|
import type { EventData, TagData, EventPreview } from "./types"; |
||||||
|
|
||||||
|
// AI-NOTE: 2025-01-24 - EventPreview component shows a preview of the event that will be published |
||||||
|
// This component generates a preview based on the current form data |
||||||
|
|
||||||
|
let { |
||||||
|
ndk, |
||||||
|
eventData, |
||||||
|
tags, |
||||||
|
showJsonPreview, |
||||||
|
onTogglePreview, |
||||||
|
}: { |
||||||
|
ndk: any; |
||||||
|
eventData: EventData; |
||||||
|
tags: TagData[]; |
||||||
|
showJsonPreview: boolean; |
||||||
|
onTogglePreview: () => void; |
||||||
|
} = $props(); |
||||||
|
|
||||||
|
/** |
||||||
|
* Converts TagData array to NDK-compatible format |
||||||
|
*/ |
||||||
|
function convertTagsToNDKFormat(tags: TagData[]): string[][] { |
||||||
|
return tags |
||||||
|
.filter(tag => tag.key.trim() !== "") |
||||||
|
.map(tag => [tag.key, ...tag.values]); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Generates event preview |
||||||
|
*/ |
||||||
|
let eventPreview = $derived.by(() => { |
||||||
|
const userState = get(userStore); |
||||||
|
const pubkey = userState.pubkey; |
||||||
|
|
||||||
|
if (!pubkey) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
// Build the event data similar to how it's done in publishing |
||||||
|
const baseEvent = { |
||||||
|
pubkey: String(pubkey), |
||||||
|
created_at: eventData.createdAt, |
||||||
|
kind: Number(eventData.kind) |
||||||
|
}; |
||||||
|
|
||||||
|
if (Number(eventData.kind) === 30040) { |
||||||
|
// For 30040, we need to show the index event structure |
||||||
|
try { |
||||||
|
// Convert tags to compatible format (exclude preset tags) |
||||||
|
const presetTagKeys = ["version", "d", "title"]; |
||||||
|
const compatibleTags: [string, string][] = tags |
||||||
|
.filter(tag => tag.key.trim() !== "" && !presetTagKeys.includes(tag.key)) |
||||||
|
.map(tag => [tag.key, tag.values[0] || ""] as [string, string]); |
||||||
|
|
||||||
|
// Create a mock NDK instance for preview |
||||||
|
const mockNdk = { sign: async () => ({ sig: "mock_signature" }) }; |
||||||
|
|
||||||
|
const { indexEvent } = build30040EventSet( |
||||||
|
eventData.content, |
||||||
|
compatibleTags, |
||||||
|
baseEvent, |
||||||
|
mockNdk as any, |
||||||
|
); |
||||||
|
|
||||||
|
// Add preset tags from UI (version, d, title) |
||||||
|
const finalTags = indexEvent.tags.filter(tag => !presetTagKeys.includes(tag[0])); |
||||||
|
const versionTag = tags.find(t => t.key === "version"); |
||||||
|
const dTag = tags.find(t => t.key === "d"); |
||||||
|
const titleTag = tags.find(t => t.key === "title"); |
||||||
|
|
||||||
|
if (versionTag && versionTag.values[0]) { |
||||||
|
finalTags.push(["version", versionTag.values[0]]); |
||||||
|
} |
||||||
|
if (dTag && dTag.values[0]) { |
||||||
|
finalTags.push(["d", dTag.values[0]]); |
||||||
|
} |
||||||
|
if (titleTag && titleTag.values[0]) { |
||||||
|
finalTags.push(["title", titleTag.values[0]]); |
||||||
|
} |
||||||
|
|
||||||
|
return { |
||||||
|
type: "30040_index_event", |
||||||
|
event: { |
||||||
|
id: "[will be generated]", |
||||||
|
pubkey: String(pubkey), |
||||||
|
created_at: eventData.createdAt, |
||||||
|
kind: 30040, |
||||||
|
tags: finalTags, |
||||||
|
content: indexEvent.content, |
||||||
|
sig: "[will be generated]" |
||||||
|
} |
||||||
|
}; |
||||||
|
} catch (error) { |
||||||
|
return { |
||||||
|
type: "error", |
||||||
|
message: `Failed to generate 30040 preview: ${error instanceof Error ? error.message : "Unknown error"}` |
||||||
|
}; |
||||||
|
} |
||||||
|
} else { |
||||||
|
// For other event types |
||||||
|
let eventTags = convertTagsToNDKFormat(tags); |
||||||
|
|
||||||
|
// For AsciiDoc events, remove metadata from content |
||||||
|
let finalContent = eventData.content; |
||||||
|
if (eventData.kind === 30040 || eventData.kind === 30041) { |
||||||
|
finalContent = removeMetadataFromContent(eventData.content); |
||||||
|
} |
||||||
|
|
||||||
|
// Prefix Nostr addresses |
||||||
|
const prefixedContent = prefixNostrAddresses(finalContent); |
||||||
|
|
||||||
|
return { |
||||||
|
type: "standard_event", |
||||||
|
event: { |
||||||
|
id: "[will be generated]", |
||||||
|
pubkey: String(pubkey), |
||||||
|
created_at: eventData.createdAt, |
||||||
|
kind: Number(eventData.kind), |
||||||
|
tags: eventTags, |
||||||
|
content: prefixedContent, |
||||||
|
sig: "[will be generated]" |
||||||
|
} |
||||||
|
}; |
||||||
|
} |
||||||
|
}); |
||||||
|
</script> |
||||||
|
|
||||||
|
<!-- Event Preview Section --> |
||||||
|
<div class="mt-6 border-t border-gray-200 dark:border-gray-700 pt-4"> |
||||||
|
<div class="flex items-center justify-between mb-3"> |
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Event Preview</h3> |
||||||
|
<button |
||||||
|
type="button" |
||||||
|
class="btn btn-sm btn-outline btn-secondary" |
||||||
|
onclick={onTogglePreview} |
||||||
|
> |
||||||
|
{showJsonPreview ? 'Hide' : 'Show'} JSON Preview |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
|
||||||
|
{#if showJsonPreview} |
||||||
|
{#if eventPreview} |
||||||
|
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-600"> |
||||||
|
{#if eventPreview.type === 'error'} |
||||||
|
<div class="text-red-600 dark:text-red-400 text-sm"> |
||||||
|
{eventPreview.message} |
||||||
|
</div> |
||||||
|
{:else} |
||||||
|
<div class="mb-2"> |
||||||
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-300"> |
||||||
|
Event Type: {eventPreview.type === '30040_index_event' ? '30040 Publication Index' : 'Standard Event'} |
||||||
|
</span> |
||||||
|
</div> |
||||||
|
<pre class="text-xs bg-white dark:bg-gray-900 p-3 rounded border overflow-x-auto text-gray-800 dark:text-gray-200 font-mono whitespace-pre-wrap">{JSON.stringify(eventPreview.event, null, 2)}</pre> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
{:else} |
||||||
|
<div class="bg-yellow-50 dark:bg-yellow-900/20 rounded-lg p-4 border border-yellow-200 dark:border-yellow-700"> |
||||||
|
<div class="text-yellow-800 dark:text-yellow-200 text-sm"> |
||||||
|
Please log in to see the event preview. |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
{/if} |
||||||
|
</div> |
||||||
@ -0,0 +1,342 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import { extractSmartMetadata, metadataToTags } from "$lib/utils/asciidoc_metadata"; |
||||||
|
import { titleToDTag, requiresDTag } from "$lib/utils/event_input_utils"; |
||||||
|
import type { TagData, PresetTag } from "./types"; |
||||||
|
|
||||||
|
// AI-NOTE: 2025-01-24 - TagManager component handles tag management with preset tags |
||||||
|
// This component automatically manages preset tags based on event kind and content |
||||||
|
|
||||||
|
let { |
||||||
|
tags = $bindable(), |
||||||
|
kind, |
||||||
|
content, |
||||||
|
}: { |
||||||
|
tags: TagData[]; |
||||||
|
kind: number; |
||||||
|
content: string; |
||||||
|
} = $props(); |
||||||
|
|
||||||
|
let removedTags = $state<Set<string>>(new Set()); |
||||||
|
let extractedMetadata = $state<[string, string][]>([]); |
||||||
|
let lastContent = $state(""); |
||||||
|
let lastKind = $state(0); |
||||||
|
|
||||||
|
// Define preset tags for different event kinds |
||||||
|
let presetTags = $derived.by(() => { |
||||||
|
const presets: PresetTag[] = []; |
||||||
|
|
||||||
|
// Version tag for 30040 events |
||||||
|
if (kind === 30040) { |
||||||
|
presets.push({ |
||||||
|
key: "version", |
||||||
|
defaultValue: "1", |
||||||
|
required: true, |
||||||
|
autoUpdate: false, |
||||||
|
description: "Publication version" |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
// D-tag and title for addressable events |
||||||
|
if (requiresDTag(kind)) { |
||||||
|
presets.push({ |
||||||
|
key: "d", |
||||||
|
defaultValue: "default-title", |
||||||
|
required: true, |
||||||
|
autoUpdate: true, |
||||||
|
description: "Document identifier (derived from title)" |
||||||
|
}); |
||||||
|
|
||||||
|
presets.push({ |
||||||
|
key: "title", |
||||||
|
defaultValue: "Default Title", |
||||||
|
required: true, |
||||||
|
autoUpdate: true, |
||||||
|
description: "Document title (extracted from content)" |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
return presets; |
||||||
|
}); |
||||||
|
|
||||||
|
// Extract metadata from content for AsciiDoc events |
||||||
|
$effect(() => { |
||||||
|
if (kind === 30040 || kind === 30041) { |
||||||
|
const { metadata } = extractSmartMetadata(content); |
||||||
|
extractedMetadata = metadataToTags(metadata); |
||||||
|
} else { |
||||||
|
extractedMetadata = []; |
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
// Manage preset tags automatically |
||||||
|
$effect(() => { |
||||||
|
// Only run this effect when content or kind changes, not when tags change |
||||||
|
if (content === lastContent && kind === lastKind) { |
||||||
|
return; // Skip if nothing has changed |
||||||
|
} |
||||||
|
|
||||||
|
lastContent = content; |
||||||
|
lastKind = kind; |
||||||
|
|
||||||
|
const currentTags = [...tags]; // Create a copy to avoid mutation |
||||||
|
|
||||||
|
const newTags: TagData[] = []; |
||||||
|
|
||||||
|
// Add preset tags |
||||||
|
for (const preset of presetTags) { |
||||||
|
if (removedTags.has(preset.key)) continue; |
||||||
|
|
||||||
|
let value = preset.defaultValue; |
||||||
|
|
||||||
|
// Auto-update values based on content |
||||||
|
if (preset.autoUpdate && content.trim()) { |
||||||
|
if (preset.key === "title") { |
||||||
|
const { metadata } = extractSmartMetadata(content); |
||||||
|
value = metadata.title || preset.defaultValue; |
||||||
|
} else if (preset.key === "d") { |
||||||
|
const { metadata } = extractSmartMetadata(content); |
||||||
|
value = titleToDTag(metadata.title || "") || preset.defaultValue; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Find existing tag or create new one |
||||||
|
const existingTag = currentTags.find(t => t.key === preset.key); |
||||||
|
if (existingTag) { |
||||||
|
// For preset tags, always ensure exactly one value |
||||||
|
if (preset.autoUpdate) { |
||||||
|
newTags.push({ |
||||||
|
key: preset.key, |
||||||
|
values: [value] // Only keep the first (primary) value |
||||||
|
}); |
||||||
|
} else { |
||||||
|
newTags.push({ |
||||||
|
key: preset.key, |
||||||
|
values: [existingTag.values[0] || preset.defaultValue] // Keep user value or default |
||||||
|
}); |
||||||
|
} |
||||||
|
} else { |
||||||
|
newTags.push({ |
||||||
|
key: preset.key, |
||||||
|
values: [value] |
||||||
|
}); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Add non-preset tags (avoid duplicates) |
||||||
|
for (const tag of currentTags) { |
||||||
|
const isPresetKey = presetTags.some(p => p.key === tag.key); |
||||||
|
const alreadyAdded = newTags.some(t => t.key === tag.key); |
||||||
|
|
||||||
|
if (!isPresetKey && !alreadyAdded) { |
||||||
|
newTags.push(tag); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Ensure there's always an empty tag row for user input |
||||||
|
if (newTags.length === 0 || newTags[newTags.length - 1].key !== "") { |
||||||
|
newTags.push({ key: "", values: [""] }); |
||||||
|
} |
||||||
|
|
||||||
|
// Only update if the tags have actually changed |
||||||
|
const tagsChanged = JSON.stringify(newTags) !== JSON.stringify(currentTags); |
||||||
|
if (tagsChanged) { |
||||||
|
tags = newTags; |
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
/** |
||||||
|
* Adds a new tag |
||||||
|
*/ |
||||||
|
function addTag(): void { |
||||||
|
tags = [...tags, { key: "", values: [""] }]; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Removes a tag at the specified index |
||||||
|
*/ |
||||||
|
function removeTag(index: number): void { |
||||||
|
const tagKey = tags[index]?.key; |
||||||
|
|
||||||
|
if (tagKey) { |
||||||
|
removedTags.add(tagKey); |
||||||
|
} |
||||||
|
|
||||||
|
tags = tags.filter((_, i) => i !== index); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Adds a value to a tag |
||||||
|
*/ |
||||||
|
function addTagValue(tagIndex: number): void { |
||||||
|
tags = tags.map((tag, i) => { |
||||||
|
if (i === tagIndex) { |
||||||
|
return { ...tag, values: [...tag.values, ""] }; |
||||||
|
} |
||||||
|
return tag; |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Removes a value from a tag |
||||||
|
*/ |
||||||
|
function removeTagValue(tagIndex: number, valueIndex: number): void { |
||||||
|
tags = tags.map((tag, i) => { |
||||||
|
if (i === tagIndex) { |
||||||
|
const newValues = tag.values.filter((_, vi) => vi !== valueIndex); |
||||||
|
return { ...tag, values: newValues.length > 0 ? newValues : [""] }; |
||||||
|
} |
||||||
|
return tag; |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Updates a tag key |
||||||
|
*/ |
||||||
|
function updateTagKey(index: number, newKey: string): void { |
||||||
|
tags = tags.map((tag, i) => { |
||||||
|
if (i === index) { |
||||||
|
return { ...tag, key: newKey }; |
||||||
|
} |
||||||
|
return tag; |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Updates a tag value |
||||||
|
*/ |
||||||
|
function updateTagValue(tagIndex: number, valueIndex: number, newValue: string): void { |
||||||
|
tags = tags.map((tag, i) => { |
||||||
|
if (i === tagIndex) { |
||||||
|
const newValues = [...tag.values]; |
||||||
|
newValues[valueIndex] = newValue; |
||||||
|
return { ...tag, values: newValues }; |
||||||
|
} |
||||||
|
return tag; |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Checks if a tag is a preset tag |
||||||
|
*/ |
||||||
|
function isPresetTag(tagKey: string): boolean { |
||||||
|
return presetTags.some(p => p.key === tagKey); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Gets preset tag info |
||||||
|
*/ |
||||||
|
function getPresetTagInfo(tagKey: string): PresetTag | undefined { |
||||||
|
return presetTags.find(p => p.key === tagKey); |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
<div class="space-y-4"> |
||||||
|
<label for="tags-container" class="block font-medium mb-1 text-gray-700 dark:text-gray-300"> |
||||||
|
Tags |
||||||
|
</label> |
||||||
|
|
||||||
|
<!-- Extracted Metadata Section --> |
||||||
|
{#if extractedMetadata.length > 0} |
||||||
|
<div class="mb-4 p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg"> |
||||||
|
<h4 class="text-sm font-medium text-blue-800 dark:text-blue-200 mb-2"> |
||||||
|
Extracted Metadata (from AsciiDoc header) |
||||||
|
</h4> |
||||||
|
<div class="text-sm text-blue-700 dark:text-blue-300"> |
||||||
|
{extractedMetadata.map(([key, value]) => `${key}: ${value}`).join(', ')} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
|
||||||
|
<!-- Tags Container --> |
||||||
|
<div id="tags-container" class="space-y-2"> |
||||||
|
{#each tags as tag, i} |
||||||
|
<div class="border border-gray-300 dark:border-gray-600 rounded-lg p-3 space-y-2"> |
||||||
|
<!-- Tag Key Row --> |
||||||
|
<div class="flex gap-2 items-center"> |
||||||
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-300 min-w-[60px]">Tag:</span> |
||||||
|
<input |
||||||
|
type="text" |
||||||
|
class="input input-bordered flex-1" |
||||||
|
placeholder="tag key (e.g., q, p, e)" |
||||||
|
value={tag.key} |
||||||
|
oninput={(e) => updateTagKey(i, (e.target as HTMLInputElement).value)} |
||||||
|
/> |
||||||
|
{#if isPresetTag(tag.key)} |
||||||
|
<span class="text-xs bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 px-2 py-1 rounded"> |
||||||
|
Preset |
||||||
|
</span> |
||||||
|
{/if} |
||||||
|
<button |
||||||
|
type="button" |
||||||
|
class="btn btn-error btn-sm" |
||||||
|
onclick={() => removeTag(i)} |
||||||
|
> |
||||||
|
× |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
|
||||||
|
<!-- Preset Tag Description --> |
||||||
|
{#if isPresetTag(tag.key)} |
||||||
|
{@const presetInfo = getPresetTagInfo(tag.key)} |
||||||
|
{#if presetInfo} |
||||||
|
<div class="text-xs text-gray-600 dark:text-gray-400 italic"> |
||||||
|
{presetInfo.description} |
||||||
|
{#if presetInfo.autoUpdate} |
||||||
|
(auto-updates from content) |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
{/if} |
||||||
|
|
||||||
|
<!-- Tag Values --> |
||||||
|
<div class="space-y-2"> |
||||||
|
<div class="flex items-center gap-2"> |
||||||
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-300 min-w-[60px]">Values:</span> |
||||||
|
<button |
||||||
|
type="button" |
||||||
|
class="btn btn-sm btn-outline btn-primary" |
||||||
|
onclick={() => addTagValue(i)} |
||||||
|
> |
||||||
|
Add Value |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
|
||||||
|
{#each tag.values as value, valueIndex} |
||||||
|
<div class="flex gap-2 items-center"> |
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400 min-w-[40px]"> |
||||||
|
{valueIndex + 1}: |
||||||
|
</span> |
||||||
|
<input |
||||||
|
type="text" |
||||||
|
class="input input-bordered flex-1" |
||||||
|
placeholder="value" |
||||||
|
value={value} |
||||||
|
oninput={(e) => updateTagValue(i, valueIndex, (e.target as HTMLInputElement).value)} |
||||||
|
/> |
||||||
|
{#if tag.values.length > 1} |
||||||
|
<button |
||||||
|
type="button" |
||||||
|
class="btn btn-sm btn-outline btn-error" |
||||||
|
onclick={() => removeTagValue(i, valueIndex)} |
||||||
|
> |
||||||
|
× |
||||||
|
</button> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
{/each} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
{/each} |
||||||
|
|
||||||
|
<!-- Add Tag Button --> |
||||||
|
<div class="flex justify-end"> |
||||||
|
<button |
||||||
|
type="button" |
||||||
|
class="btn btn-primary btn-sm border border-primary-600 px-3 py-1" |
||||||
|
onclick={addTag} |
||||||
|
> |
||||||
|
Add Tag |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
@ -0,0 +1,277 @@ |
|||||||
|
/** |
||||||
|
* Event publishing and loading services |
||||||
|
*/ |
||||||
|
|
||||||
|
import { get } from "svelte/store"; |
||||||
|
import { userStore } from "$lib/stores/userStore"; |
||||||
|
import NDK, { NDKEvent as NDKEventClass } from "@nostr-dev-kit/ndk"; |
||||||
|
import type { NDKEvent } from "$lib/utils/nostrUtils"; |
||||||
|
import { prefixNostrAddresses } from "$lib/utils/nostrUtils"; |
||||||
|
import { fetchEventWithFallback } from "$lib/utils/nostrUtils"; |
||||||
|
|
||||||
|
import { WebSocketPool } from "$lib/data_structures/websocket_pool"; |
||||||
|
import { anonymousRelays } from "$lib/consts"; |
||||||
|
import { activeInboxRelays, activeOutboxRelays } from "$lib/ndk"; |
||||||
|
import { removeMetadataFromContent } from "$lib/utils/asciidoc_metadata"; |
||||||
|
import { build30040EventSet } from "$lib/utils/event_input_utils"; |
||||||
|
import type { EventData, TagData, PublishResult, LoadEventResult } from "./types"; |
||||||
|
|
||||||
|
/** |
||||||
|
* Converts TagData array to NDK-compatible format |
||||||
|
*/ |
||||||
|
function convertTagsToNDKFormat(tags: TagData[]): string[][] { |
||||||
|
return tags |
||||||
|
.filter(tag => tag.key.trim() !== "") |
||||||
|
.map(tag => [tag.key, ...tag.values]); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Publishes an event to relays |
||||||
|
*/ |
||||||
|
export async function publishEvent(ndk: any, eventData: EventData, tags: TagData[]): Promise<PublishResult> { |
||||||
|
if (!ndk) { |
||||||
|
return { success: false, error: "NDK context not available" }; |
||||||
|
} |
||||||
|
|
||||||
|
const userState = get(userStore); |
||||||
|
const pubkey = userState.pubkey; |
||||||
|
|
||||||
|
if (!pubkey) { |
||||||
|
return { success: false, error: "User not logged in." }; |
||||||
|
} |
||||||
|
|
||||||
|
const pubkeyString = String(pubkey); |
||||||
|
if (!/^[a-fA-F0-9]{64}$/.test(pubkeyString)) { |
||||||
|
return { success: false, error: "Invalid public key: must be a 64-character hex string." }; |
||||||
|
} |
||||||
|
|
||||||
|
const baseEvent = { pubkey: pubkeyString, created_at: eventData.createdAt }; |
||||||
|
let events: NDKEvent[] = []; |
||||||
|
|
||||||
|
console.log("Publishing event with kind:", eventData.kind); |
||||||
|
console.log("Content length:", eventData.content.length); |
||||||
|
console.log("Content preview:", eventData.content.substring(0, 100)); |
||||||
|
console.log("Tags:", tags); |
||||||
|
|
||||||
|
if (Number(eventData.kind) === 30040) { |
||||||
|
console.log("=== 30040 EVENT CREATION START ==="); |
||||||
|
console.log("Creating 30040 event set with content:", eventData.content); |
||||||
|
|
||||||
|
try { |
||||||
|
// Get the current d and title values from the UI
|
||||||
|
const dTagValue = tags.find(tag => tag.key === "d")?.values[0] || ""; |
||||||
|
const titleTagValue = tags.find(tag => tag.key === "title")?.values[0] || ""; |
||||||
|
|
||||||
|
// Convert multi-value tags to the format expected by build30040EventSet
|
||||||
|
// Filter out d and title tags since we'll add them manually
|
||||||
|
const compatibleTags: [string, string][] = tags |
||||||
|
.filter(tag => tag.key.trim() !== "" && tag.key !== "d" && tag.key !== "title") |
||||||
|
.map(tag => [tag.key, tag.values[0] || ""] as [string, string]); |
||||||
|
|
||||||
|
const { indexEvent, sectionEvents } = build30040EventSet( |
||||||
|
eventData.content, |
||||||
|
compatibleTags, |
||||||
|
baseEvent, |
||||||
|
ndk, |
||||||
|
); |
||||||
|
|
||||||
|
// Override the d and title tags with the UI values if they exist
|
||||||
|
const finalTags = indexEvent.tags.filter(tag => tag[0] !== "d" && tag[0] !== "title"); |
||||||
|
if (dTagValue) { |
||||||
|
finalTags.push(["d", dTagValue]); |
||||||
|
} |
||||||
|
if (titleTagValue) { |
||||||
|
finalTags.push(["title", titleTagValue]); |
||||||
|
} |
||||||
|
|
||||||
|
// Update the index event with the correct tags
|
||||||
|
indexEvent.tags = finalTags; |
||||||
|
console.log("Index event:", indexEvent); |
||||||
|
console.log("Section events:", sectionEvents); |
||||||
|
|
||||||
|
// Publish all 30041 section events first, then the 30040 index event
|
||||||
|
events = [...sectionEvents, indexEvent]; |
||||||
|
console.log("Total events to publish:", events.length); |
||||||
|
console.log("=== 30040 EVENT CREATION END ==="); |
||||||
|
} catch (error) { |
||||||
|
console.error("Error in build30040EventSet:", error); |
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `Failed to build 30040 event set: ${error instanceof Error ? error.message : "Unknown error"}`
|
||||||
|
}; |
||||||
|
} |
||||||
|
} else { |
||||||
|
// Convert multi-value tags to the format expected by NDK
|
||||||
|
let eventTags = convertTagsToNDKFormat(tags); |
||||||
|
|
||||||
|
// For AsciiDoc events, remove metadata from content
|
||||||
|
let finalContent = eventData.content; |
||||||
|
if (eventData.kind === 30040 || eventData.kind === 30041) { |
||||||
|
finalContent = removeMetadataFromContent(eventData.content); |
||||||
|
} |
||||||
|
|
||||||
|
// Prefix Nostr addresses before publishing
|
||||||
|
const prefixedContent = prefixNostrAddresses(finalContent); |
||||||
|
|
||||||
|
// Create event with proper serialization
|
||||||
|
const eventDataForNDK = { |
||||||
|
kind: eventData.kind, |
||||||
|
content: prefixedContent, |
||||||
|
tags: eventTags, |
||||||
|
pubkey: pubkeyString, |
||||||
|
created_at: eventData.createdAt, |
||||||
|
}; |
||||||
|
|
||||||
|
events = [new NDKEventClass(ndk, eventDataForNDK)]; |
||||||
|
} |
||||||
|
|
||||||
|
let atLeastOne = false; |
||||||
|
let relaysPublished: string[] = []; |
||||||
|
let lastEventId: string | null = null; |
||||||
|
|
||||||
|
for (let i = 0; i < events.length; i++) { |
||||||
|
const event = events[i]; |
||||||
|
try { |
||||||
|
console.log("Publishing event:", { |
||||||
|
kind: event.kind, |
||||||
|
content: event.content, |
||||||
|
tags: event.tags, |
||||||
|
hasContent: event.content && event.content.length > 0, |
||||||
|
}); |
||||||
|
|
||||||
|
// Always sign with a plain object if window.nostr is available
|
||||||
|
// Create a completely plain object to avoid proxy cloning issues
|
||||||
|
const plainEvent = { |
||||||
|
kind: Number(event.kind), |
||||||
|
pubkey: String(event.pubkey), |
||||||
|
created_at: Number( |
||||||
|
event.created_at ?? Math.floor(Date.now() / 1000), |
||||||
|
), |
||||||
|
tags: event.tags.map((tag) => tag.map(String)), |
||||||
|
content: String(event.content), |
||||||
|
}; |
||||||
|
|
||||||
|
if ( |
||||||
|
typeof window !== "undefined" && |
||||||
|
window.nostr && |
||||||
|
window.nostr.signEvent |
||||||
|
) { |
||||||
|
const signed = await window.nostr.signEvent(plainEvent); |
||||||
|
event.sig = signed.sig; |
||||||
|
if ("id" in signed) { |
||||||
|
event.id = signed.id as string; |
||||||
|
} |
||||||
|
} else { |
||||||
|
await event.sign(); |
||||||
|
} |
||||||
|
|
||||||
|
// Use direct WebSocket publishing like CommentBox does
|
||||||
|
const signedEvent = { |
||||||
|
...plainEvent, |
||||||
|
id: event.id, |
||||||
|
sig: event.sig, |
||||||
|
}; |
||||||
|
|
||||||
|
// Try to publish to relays directly
|
||||||
|
const relays = [ |
||||||
|
...anonymousRelays, |
||||||
|
...get(activeOutboxRelays), |
||||||
|
...get(activeInboxRelays), |
||||||
|
]; |
||||||
|
let published = false; |
||||||
|
|
||||||
|
for (const relayUrl of relays) { |
||||||
|
try { |
||||||
|
const ws = await WebSocketPool.instance.acquire(relayUrl); |
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => { |
||||||
|
const timeout = setTimeout(() => { |
||||||
|
WebSocketPool.instance.release(ws); |
||||||
|
reject(new Error("Timeout")); |
||||||
|
}, 5000); |
||||||
|
|
||||||
|
ws.onmessage = (e) => { |
||||||
|
const [type, id, ok, message] = JSON.parse(e.data); |
||||||
|
if (type === "OK" && id === signedEvent.id) { |
||||||
|
clearTimeout(timeout); |
||||||
|
if (ok) { |
||||||
|
published = true; |
||||||
|
relaysPublished.push(relayUrl); |
||||||
|
WebSocketPool.instance.release(ws); |
||||||
|
resolve(); |
||||||
|
} else { |
||||||
|
WebSocketPool.instance.release(ws); |
||||||
|
reject(new Error(message)); |
||||||
|
} |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
// Send the event to the relay
|
||||||
|
ws.send(JSON.stringify(["EVENT", signedEvent])); |
||||||
|
}); |
||||||
|
if (published) break; |
||||||
|
} catch (e) { |
||||||
|
console.error(`Failed to publish to ${relayUrl}:`, e); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if (published) { |
||||||
|
atLeastOne = true; |
||||||
|
// For 30040, set lastEventId to the index event (last in array)
|
||||||
|
if (Number(eventData.kind) === 30040) { |
||||||
|
if (i === events.length - 1) { |
||||||
|
lastEventId = event.id; |
||||||
|
} |
||||||
|
} else { |
||||||
|
lastEventId = event.id; |
||||||
|
} |
||||||
|
} |
||||||
|
} catch (signError) { |
||||||
|
console.error("Error signing/publishing event:", signError); |
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `Failed to sign event: ${signError instanceof Error ? signError.message : "Unknown error"}`
|
||||||
|
}; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if (atLeastOne) { |
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
eventId: lastEventId || undefined, |
||||||
|
relays: relaysPublished
|
||||||
|
}; |
||||||
|
} else { |
||||||
|
return { success: false, error: "Failed to publish to any relay." }; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Loads an event by its hex ID |
||||||
|
*/ |
||||||
|
export async function loadEvent(ndk: any, eventId: string): Promise<LoadEventResult | null> { |
||||||
|
if (!ndk) { |
||||||
|
throw new Error("NDK context not available"); |
||||||
|
} |
||||||
|
|
||||||
|
const foundEvent = await fetchEventWithFallback(ndk, eventId, 10000); |
||||||
|
|
||||||
|
if (foundEvent) { |
||||||
|
// Convert NDK event format to our format
|
||||||
|
const eventData: EventData = { |
||||||
|
kind: foundEvent.kind || 30040, |
||||||
|
content: foundEvent.content || "", |
||||||
|
createdAt: Math.floor(Date.now() / 1000), // Use current time for replacement
|
||||||
|
}; |
||||||
|
|
||||||
|
// Convert NDK tags format to our format
|
||||||
|
const tags: TagData[] = foundEvent.tags.map((tag: string[]) => ({ |
||||||
|
key: tag[0] || "", |
||||||
|
values: tag.slice(1) |
||||||
|
})); |
||||||
|
|
||||||
|
return { eventData, tags }; |
||||||
|
} |
||||||
|
|
||||||
|
return null; |
||||||
|
} |
||||||
@ -0,0 +1,63 @@ |
|||||||
|
/** |
||||||
|
* Type definitions for the EventInput component system |
||||||
|
*/ |
||||||
|
|
||||||
|
export interface EventData { |
||||||
|
kind: number; |
||||||
|
content: string; |
||||||
|
createdAt: number; |
||||||
|
} |
||||||
|
|
||||||
|
export interface TagData { |
||||||
|
key: string; |
||||||
|
values: string[]; |
||||||
|
} |
||||||
|
|
||||||
|
export interface ValidationResult { |
||||||
|
valid: boolean; |
||||||
|
reason?: string; |
||||||
|
warning?: string; |
||||||
|
} |
||||||
|
|
||||||
|
export interface PublishResult { |
||||||
|
success: boolean; |
||||||
|
eventId?: string; |
||||||
|
relays?: string[]; |
||||||
|
error?: string; |
||||||
|
} |
||||||
|
|
||||||
|
export interface LoadEventResult { |
||||||
|
eventData: EventData; |
||||||
|
tags: TagData[]; |
||||||
|
} |
||||||
|
|
||||||
|
export interface EventPreview { |
||||||
|
type: 'standard_event' | '30040_index_event' | 'error'; |
||||||
|
event?: { |
||||||
|
id: string; |
||||||
|
pubkey: string; |
||||||
|
created_at: number; |
||||||
|
kind: number; |
||||||
|
tags: string[][]; |
||||||
|
content: string; |
||||||
|
sig: string; |
||||||
|
}; |
||||||
|
message?: string; |
||||||
|
} |
||||||
|
|
||||||
|
export interface PresetTag { |
||||||
|
key: string; |
||||||
|
defaultValue: string; |
||||||
|
required: boolean; |
||||||
|
autoUpdate: boolean; |
||||||
|
description: string; |
||||||
|
} |
||||||
|
|
||||||
|
export interface KindConfig { |
||||||
|
kind: number; |
||||||
|
name: string; |
||||||
|
description: string; |
||||||
|
presetTags: PresetTag[]; |
||||||
|
requiresContent: boolean; |
||||||
|
contentValidation?: (content: string) => ValidationResult; |
||||||
|
} |
||||||
@ -0,0 +1,90 @@ |
|||||||
|
/** |
||||||
|
* Event validation utilities |
||||||
|
*/ |
||||||
|
|
||||||
|
import { get } from "svelte/store"; |
||||||
|
import { userStore } from "$lib/stores/userStore"; |
||||||
|
import type { EventData, TagData, ValidationResult } from "./types"; |
||||||
|
import { |
||||||
|
validateNotAsciidoc, |
||||||
|
validateAsciiDoc, |
||||||
|
validate30040EventSet, |
||||||
|
} from "$lib/utils/event_input_utils"; |
||||||
|
|
||||||
|
/** |
||||||
|
* Validates an event and its tags |
||||||
|
*/ |
||||||
|
export function validateEvent(eventData: EventData, tags: TagData[]): ValidationResult { |
||||||
|
const userState = get(userStore); |
||||||
|
|
||||||
|
const pubkey = userState.pubkey; |
||||||
|
if (!pubkey) { |
||||||
|
return { valid: false, reason: "Not logged in." }; |
||||||
|
} |
||||||
|
|
||||||
|
// Content validation - 30040 events don't require content
|
||||||
|
if (eventData.kind !== 30040 && !eventData.content.trim()) { |
||||||
|
return { valid: false, reason: "Content required." }; |
||||||
|
} |
||||||
|
|
||||||
|
// Kind-specific validation
|
||||||
|
if (eventData.kind === 30023) { |
||||||
|
const v = validateNotAsciidoc(eventData.content); |
||||||
|
if (!v.valid) return v; |
||||||
|
} |
||||||
|
|
||||||
|
if (eventData.kind === 30040) { |
||||||
|
// Check for required tags
|
||||||
|
const versionTag = tags.find(t => t.key === "version"); |
||||||
|
const dTag = tags.find(t => t.key === "d"); |
||||||
|
const titleTag = tags.find(t => t.key === "title"); |
||||||
|
|
||||||
|
if (!versionTag || !versionTag.values[0] || versionTag.values[0].trim() === "") { |
||||||
|
return { valid: false, reason: "30040 events require a 'version' tag." }; |
||||||
|
} |
||||||
|
|
||||||
|
if (!dTag || !dTag.values[0] || dTag.values[0].trim() === "") { |
||||||
|
return { valid: false, reason: "30040 events require a 'd' tag." }; |
||||||
|
} |
||||||
|
|
||||||
|
if (!titleTag || !titleTag.values[0] || titleTag.values[0].trim() === "") { |
||||||
|
return { valid: false, reason: "30040 events require a 'title' tag." }; |
||||||
|
} |
||||||
|
|
||||||
|
// Validate content format if present
|
||||||
|
if (eventData.content.trim()) { |
||||||
|
const v = validate30040EventSet(eventData.content); |
||||||
|
if (!v.valid) return v; |
||||||
|
if (v.warning) return { valid: true, warning: v.warning }; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if (eventData.kind === 30041 || eventData.kind === 30818) { |
||||||
|
const v = validateAsciiDoc(eventData.content); |
||||||
|
if (!v.valid) return v; |
||||||
|
} |
||||||
|
|
||||||
|
return { valid: true }; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Validates that a kind is within valid range |
||||||
|
*/ |
||||||
|
export function isValidKind(kind: number | string): boolean { |
||||||
|
const n = Number(kind); |
||||||
|
return Number.isInteger(n) && n >= 0 && n <= 65535; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Validates that a tag has a valid key |
||||||
|
*/ |
||||||
|
export function isValidTagKey(key: string): boolean { |
||||||
|
return key.trim().length > 0; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Validates that a tag has at least one value |
||||||
|
*/ |
||||||
|
export function isValidTag(tag: TagData): boolean { |
||||||
|
return isValidTagKey(tag.key) && tag.values.some(v => v.trim().length > 0); |
||||||
|
} |
||||||
Loading…
Reference in new issue