@ -20,17 +20,323 @@
@@ -20,17 +20,323 @@
{ value : 30817 , label : '30817 - AsciiDoc' } ,
{ value : 30041 , label : '30041 - AsciiDoc' } ,
{ value : 30040 , label : '30040 - Event Index (metadata-only)' } ,
{ value : 1068 , label : '1068 - Poll' }
{ value : 1068 , label : '1068 - Poll' } ,
{ value : - 1 , label : 'Unknown Kind' }
];
interface Props {
initialKind?: number | null;
}
let { initialKind = null } : Props = $props();
let selectedKind = $state< number > (1);
let customKindId = $state< string > ('');
let content = $state('');
let tags = $state< string [ ] [ ] > ([]);
let publishing = $state(false);
let publicationModalOpen = $state(false);
let publicationResults = $state< { success : string []; failed : Array < { relay : string ; error : string } > } | null>(null);
// Sync selectedKind when initialKind prop changes
$effect(() => {
if (initialKind !== null && initialKind !== undefined) {
selectedKind = initialKind;
}
});
const isKind30040 = $derived(selectedKind === 30040);
const isUnknownKind = $derived(selectedKind === -1);
const effectiveKind = $derived(isUnknownKind ? (parseInt(customKindId) || 1) : selectedKind);
function getKindHelpText(kind: number): { description : string ; suggestedTags : string [] } {
switch (kind) {
case 1:
return {
description: 'A simple plaintext note for social media. Use for short messages, replies, and general posts.',
suggestedTags: ['e (event references)', 'p (pubkey mentions)', 'q (quoted events)', 't (hashtags)']
};
case 11:
return {
description: 'A discussion thread. SHOULD include a title tag. Replies use kind 1111 comments (NIP-22).',
suggestedTags: ['title (required)', 't (topics/hashtags)']
};
case 9802:
return {
description: 'A highlight event to signal content you find valuable. Content is the highlighted text portion.',
suggestedTags: ['a (addressable event)', 'e (event reference)', 'r (URL reference)', 'p (author pubkeys)', 'context (surrounding text)', 'comment (for quote highlights)']
};
case 1222:
return {
description: 'A voice message (root). Content MUST be a URL to an audio file (audio/mp4 recommended). Duration SHOULD be ≤60 seconds.',
suggestedTags: ['imeta (with url, waveform, duration)', 't (hashtags)', 'g (geohash)']
};
case 20:
return {
description: 'A picture-first post. Content is a description. Images are referenced via imeta tags.',
suggestedTags: ['title', 'imeta (url, m, blurhash, dim, alt, x, fallback)', 'p (tagged users)', 'm (media type)', 'x (image hash)', 't (hashtags)', 'location', 'g (geohash)', 'L/l (language)', 'content-warning']
};
case 21:
case 22:
return {
description: kind === 21 ? 'A normal video post. Content is a summary/description.' : 'A short video post (stories/reels style). Content is a summary/description.',
suggestedTags: ['title (required)', 'imeta (url, dim, m, image, fallback, service, bitrate, duration)', 'published_at', 'text-track', 'content-warning', 'alt', 'segment', 't (hashtags)', 'p (participants)', 'r (web references)']
};
case 30023:
return {
description: 'A long-form article or blog post. Content is Markdown. Include a d tag for editability.',
suggestedTags: ['d (required for editability)', 'title', 'image', 'summary', 'published_at', 't (hashtags)']
};
case 30818:
return {
description: 'A wiki article (AsciiDoc). Content is AsciiDoc with wikilinks. Identified by lowercase, normalized d tag.',
suggestedTags: ['d (required, normalized)', 'title', 'summary', 'a (addressable event)', 'e (event reference)']
};
case 30817:
return {
description: 'An AsciiDoc article. Similar to 30818 but may have different conventions.',
suggestedTags: ['d (identifier)', 'title', 'summary', 'a (addressable event)', 'e (event reference)']
};
case 30041:
return {
description: 'Publication content section (AsciiDoc). Content is text/AsciiDoc with wikilinks. Part of a publication structure.',
suggestedTags: ['d (required)', 'title (required)', 'wikilink']
};
case 30040:
return {
description: 'Publication index (metadata-only). Content MUST be empty. Defines structure and metadata of a publication.',
suggestedTags: ['d (required)', 'title (required)', 'a (referenced events)', 'auto-update (yes|ask|no)', 'p (original author)', 'E (original event)', 'source', 'version', 'type', 'author', 'i (ISBN)', 't (hashtags)', 'published_on', 'published_by', 'image', 'summary']
};
case 1068:
return {
description: 'A poll event. Content is the poll label/question. Options and settings are in tags.',
suggestedTags: ['option (optionId, label)', 'relay (one or more)', 'polltype (singlechoice|multiplechoice)', 'endsAt (unix timestamp)']
};
default:
return {
description: `Custom kind ${ kind } . Refer to the relevant NIP specification for tag requirements.`,
suggestedTags: []
};
}
}
const helpText = $derived(getKindHelpText(effectiveKind));
function getExampleJSON(kind: number): string {
const examplePubkey = '3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d';
const exampleEventId = '67b48a14fb66c60c8f9070bdeb37afdfcc3d08ad01989460448e4081eddda446';
const exampleRelay = 'wss://relay.example.com';
const timestamp = Math.floor(Date.now() / 1000);
switch (kind) {
case 1:
return JSON.stringify({
kind: 1,
pubkey: examplePubkey,
created_at: timestamp,
content: 'Hello nostr! This is a simple text note.',
tags: [
['e', exampleEventId, exampleRelay],
['p', examplePubkey],
['t', 'nostr']
],
id: '...',
sig: '...'
}, null, 2);
case 11:
return JSON.stringify({
kind: 11,
pubkey: examplePubkey,
created_at: timestamp,
content: 'This is a discussion thread about a topic.',
tags: [
['title', 'Discussion Thread Title'],
['t', 'topic1'],
['t', 'topic2']
],
id: '...',
sig: '...'
}, null, 2);
case 9802:
return JSON.stringify({
kind: 9802,
pubkey: examplePubkey,
created_at: timestamp,
content: 'This is the highlighted text portion.',
tags: [
['e', exampleEventId, exampleRelay],
['p', examplePubkey, '', 'author'],
['context', 'This is the highlighted text portion within the surrounding context text...']
],
id: '...',
sig: '...'
}, null, 2);
case 1222:
return JSON.stringify({
kind: 1222,
pubkey: examplePubkey,
created_at: timestamp,
content: 'https://example.com/audio/voice-message.m4a',
tags: [
['imeta', 'url https://example.com/audio/voice-message.m4a', 'waveform 0 7 35 8 100 100 49', 'duration 8'],
['t', 'voice']
],
id: '...',
sig: '...'
}, null, 2);
case 20:
return JSON.stringify({
kind: 20,
pubkey: examplePubkey,
created_at: timestamp,
content: 'A beautiful sunset photo',
tags: [
['title', 'Sunset Photo'],
['imeta', 'url https://nostr.build/i/image.jpg', 'm image/jpeg', 'dim 3024x4032', 'alt A scenic sunset'],
['t', 'photography'],
['location', 'San Francisco, CA']
],
id: '...',
sig: '...'
}, null, 2);
case 21:
return JSON.stringify({
kind: 21,
pubkey: examplePubkey,
created_at: timestamp,
content: 'A detailed video about Nostr protocol',
tags: [
['title', 'Introduction to Nostr'],
['imeta', 'url https://example.com/video.mp4', 'dim 1920x1080', 'm video/mp4', 'duration 300', 'bitrate 3000000'],
['published_at', timestamp.toString()],
['t', 'tutorial']
],
id: '...',
sig: '...'
}, null, 2);
case 22:
return JSON.stringify({
kind: 22,
pubkey: examplePubkey,
created_at: timestamp,
content: 'Quick video update',
tags: [
['title', 'Quick Update'],
['imeta', 'url https://example.com/short.mp4', 'dim 1080x1920', 'm video/mp4', 'duration 15'],
['published_at', timestamp.toString()]
],
id: '...',
sig: '...'
}, null, 2);
case 30023:
return JSON.stringify({
kind: 30023,
pubkey: examplePubkey,
created_at: timestamp,
content: '# Long-form Article\n\nThis is a long-form article written in Markdown...',
tags: [
['d', 'article-slug'],
['title', 'My Long-form Article'],
['summary', 'A brief summary of the article'],
['published_at', timestamp.toString()],
['t', 'article'],
['t', 'longform']
],
id: '...',
sig: '...'
}, null, 2);
case 30818:
return JSON.stringify({
kind: 30818,
pubkey: examplePubkey,
created_at: timestamp,
content: '= Wiki Article\n\nThis is a wiki article written in AsciiDoc.',
tags: [
['d', 'wiki-article'],
['title', 'Wiki Article'],
['summary', 'A brief summary']
],
id: '...',
sig: '...'
}, null, 2);
case 30817:
return JSON.stringify({
kind: 30817,
pubkey: examplePubkey,
created_at: timestamp,
content: '= AsciiDoc Document\n\nContent in AsciiDoc format...',
tags: [
['d', 'asciidoc-doc'],
['title', 'AsciiDoc Document']
],
id: '...',
sig: '...'
}, null, 2);
case 30041:
return JSON.stringify({
kind: 30041,
pubkey: examplePubkey,
created_at: timestamp,
content: '= Chapter Title\n\nChapter content with [[wikilinks]]...',
tags: [
['d', 'publication-chapter-1'],
['title', 'Chapter 1: Introduction']
],
id: '...',
sig: '...'
}, null, 2);
case 30040:
return JSON.stringify({
kind: 30040,
pubkey: examplePubkey,
created_at: timestamp,
content: '',
tags: [
['d', 'publication-slug'],
['title', 'My Publication'],
['author', 'Author Name'],
['summary', 'Publication summary'],
['type', 'book'],
['a', '30041:' + examplePubkey + ':chapter-1', exampleRelay],
['a', '30041:' + examplePubkey + ':chapter-2', exampleRelay],
['auto-update', 'ask']
],
id: '...',
sig: '...'
}, null, 2);
case 1068:
return JSON.stringify({
kind: 1068,
pubkey: examplePubkey,
created_at: timestamp,
content: 'What is your favorite color?',
tags: [
['option', 'opt1', 'Red'],
['option', 'opt2', 'Blue'],
['option', 'opt3', 'Green'],
['relay', exampleRelay],
['polltype', 'singlechoice'],
['endsAt', (timestamp + 86400).toString()]
],
id: '...',
sig: '...'
}, null, 2);
default:
return JSON.stringify({
kind: kind,
pubkey: examplePubkey,
created_at: timestamp,
content: 'Custom event content',
tags: [
['example', 'tag', 'value']
],
id: '...',
sig: '...'
}, null, 2);
}
}
const exampleJSON = $derived(getExampleJSON(effectiveKind));
function addTag() {
tags = [...tags, ['', '']];
@ -60,11 +366,16 @@
@@ -60,11 +366,16 @@
return;
}
if (isUnknownKind && (!customKindId || isNaN(parseInt(customKindId)))) {
alert('Please enter a valid kind number');
return;
}
publishing = true;
try {
const eventTemplate = {
kind: selectedKind,
kind: effective Kind,
pubkey: session.pubkey,
created_at: Math.floor(Date.now() / 1000),
tags: tags.filter(t => t[0] & & t[1]),
@ -114,7 +425,7 @@
@@ -114,7 +425,7 @@
if (!session) return;
const eventTemplate = {
kind: selected Kind,
kind: effective Kind,
pubkey: session.pubkey,
created_at: Math.floor(Date.now() / 1000),
tags: tags.filter(t => t[0] & & t[1]),
@ -131,8 +442,33 @@
@@ -131,8 +442,33 @@
}
< / script >
< div class = "create-form" >
< div class = "create-form-container" >
< div class = "create-form" >
< div class = "form-header" >
< h2 class = "form-title" > Create Event< / h2 >
< div class = "help-text-panel" >
< div class = "help-header" >
< p class = "help-description" > { helpText . description } </ p >
< div class = "example-button-wrapper" >
< button class = "example-button" type = "button" title = "Show example JSON" > ?< / button >
< div class = "example-tooltip" >
< div class = "example-tooltip-header" > Example of { effectiveKind } event</ div >
< pre class = "example-json" > { exampleJSON } </ pre >
< / div >
< / div >
< / div >
{ #if helpText . suggestedTags . length > 0 }
< div class = "suggested-tags" >
< strong > Suggested tags:< / strong >
< ul >
{ #each helpText . suggestedTags as tag }
< li > { tag } </ li >
{ /each }
< / ul >
< / div >
{ /if }
< / div >
< / div >
< div class = "form-group" >
< label for = "kind-select" class = "form-label" > Kind< / label >
@ -141,11 +477,23 @@
@@ -141,11 +477,23 @@
< option value = { kind . value } > { kind . label } </option >
{ /each }
< / select >
{ #if isKind30040 }
< p class = "help-text" > Note: Kind 30040 is metadata-only. Sections must be added manually using the edit function.< / p >
{ #if isUnknownKind }
< div class = "custom-kind-input" >
< label for = "custom-kind-id" class = "form-label" > Kind ID< / label >
< input
id="custom-kind-id"
type="number"
bind:value={ customKindId }
placeholder="Enter kind number"
class="kind-id-input"
min="0"
max="65535"
/>
< / div >
{ /if }
< / div >
{ #if ! isKind30040 }
< div class = "form-group" >
< label for = "content-textarea" class = "form-label" > Content< / label >
< textarea
@ -154,9 +502,9 @@
@@ -154,9 +502,9 @@
class="content-input"
rows="10"
placeholder="Event content..."
disabled={ isKind30040 }
>< / textarea >
< / div >
{ /if }
< div class = "form-group" >
< fieldset >
@ -188,6 +536,8 @@
@@ -188,6 +536,8 @@
< button class = "tag-remove" onclick = {() => removeTag ( index )} > ×</button >
< / div >
{ /each }
< / div >
< div class = "add-tag-wrapper" >
< button class = "add-tag-button" onclick = { addTag } > Add Tag </ button >
< / div >
< / fieldset >
@ -202,6 +552,7 @@
@@ -202,6 +552,7 @@
{ publishing ? 'Publishing...' : 'Publish' }
< / button >
< / div >
< / div >
< / div >
< PublicationStatusModal bind:open = { publicationModalOpen } bind:results= { publicationResults } />
@ -216,23 +567,197 @@
@@ -216,23 +567,197 @@
{ /if }
< style >
.create-form-container {
display: flex;
gap: 2rem;
max-width: 1200px;
}
.create-form {
flex: 1;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.form-header {
display: flex;
gap: 2rem;
align-items: flex-start;
}
.form-title {
margin: 0;
font-size: 1.5rem;
font-weight: 600;
color: var(--fog-text, #1f2937);
flex-shrink: 0;
}
:global(.dark) .form-title {
color: var(--fog-dark-text, #f9fafb);
}
.help-text-panel {
flex: 1;
padding: 1rem;
background: var(--fog-highlight, #f3f4f6);
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.375rem;
font-size: 0.875rem;
}
:global(.dark) .help-text-panel {
background: var(--fog-dark-highlight, #374151);
border-color: var(--fog-dark-border, #475569);
}
.help-header {
display: flex;
align-items: flex-start;
gap: 0.5rem;
position: relative;
}
.help-description {
margin: 0 0 0.75rem 0;
color: var(--fog-text, #1f2937);
line-height: 1.5;
flex: 1;
}
:global(.dark) .help-description {
color: var(--fog-dark-text, #f9fafb);
}
.example-button-wrapper {
position: relative;
flex-shrink: 0;
}
.example-button {
width: 1.5rem;
height: 1.5rem;
border-radius: 50%;
border: 1px solid var(--fog-border, #e5e7eb);
background: var(--fog-post, #ffffff);
color: var(--fog-text, #1f2937);
cursor: pointer;
font-size: 0.875rem;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
transition: all 0.2s;
}
:global(.dark) .example-button {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-post, #1f2937);
color: var(--fog-dark-text, #f9fafb);
}
.example-button:hover {
background: var(--fog-accent, #64748b);
color: white;
border-color: var(--fog-accent, #64748b);
}
:global(.dark) .example-button:hover {
background: var(--fog-dark-accent, #94a3b8);
border-color: var(--fog-dark-accent, #94a3b8);
}
.example-tooltip {
position: absolute;
top: 100%;
right: 0;
margin-top: 0.5rem;
background: var(--fog-post, #ffffff);
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.375rem;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
padding: 1rem;
min-width: 400px;
max-width: 600px;
max-height: 500px;
overflow: auto;
z-index: 1000;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s;
}
:global(.dark) .example-tooltip {
background: var(--fog-dark-post, #1f2937);
border-color: var(--fog-dark-border, #374151);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.example-button-wrapper:hover .example-tooltip {
opacity: 1;
pointer-events: auto;
}
.example-tooltip-header {
font-weight: 600;
font-size: 0.875rem;
color: var(--fog-text, #1f2937);
margin-bottom: 0.75rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--fog-border, #e5e7eb);
}
:global(.dark) .example-tooltip-header {
color: var(--fog-dark-text, #f9fafb);
border-bottom-color: var(--fog-dark-border, #374151);
}
.example-json {
margin: 0;
font-size: 0.75rem;
font-family: 'Courier New', Courier, monospace;
color: var(--fog-text, #1f2937);
white-space: pre-wrap;
word-wrap: break-word;
line-height: 1.4;
}
:global(.dark) .example-json {
color: var(--fog-dark-text, #f9fafb);
}
.suggested-tags {
margin-top: 0.75rem;
}
.suggested-tags strong {
display: block;
margin-bottom: 0.5rem;
color: var(--fog-text, #1f2937);
font-size: 0.8125rem;
}
:global(.dark) .suggested-tags strong {
color: var(--fog-dark-text, #f9fafb);
}
.suggested-tags ul {
margin: 0;
padding-left: 1.25rem;
color: var(--fog-text-light, #6b7280);
font-size: 0.8125rem;
}
:global(.dark) .suggested-tags ul {
color: var(--fog-dark-text-light, #9ca3af);
}
.suggested-tags li {
margin-bottom: 0.25rem;
}
.form-group {
display: flex;
flex-direction: column;
@ -264,15 +789,26 @@
@@ -264,15 +789,26 @@
color: var(--fog-dark-text, #f9fafb);
}
.help-tex t {
margin: 0 ;
font-size: 0.75rem ;
color: var(--fog-text-light, #6b7280) ;
font-style: italic ;
.custom-kind-inpu t {
display: flex ;
flex-direction: column ;
gap: 0.5rem ;
margin-top: 0.5rem ;
}
:global(.dark) .help-text {
color: var(--fog-dark-text-light, #9ca3af);
.kind-id-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;
}
:global(.dark) .kind-id-input {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-post, #1f2937);
color: var(--fog-dark-text, #f9fafb);
}
.content-input {
@ -292,15 +828,11 @@
@@ -292,15 +828,11 @@
color: var(--fog-dark-text, #f9fafb);
}
.content-input:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.tags-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
.tag-row {
@ -354,6 +886,10 @@
@@ -354,6 +886,10 @@
background: var(--fog-dark-border, #475569);
}
.add-tag-wrapper {
margin-top: 0.5rem;
}
.add-tag-button {
padding: 0.5rem 1rem;
border: 1px solid var(--fog-border, #e5e7eb);
@ -362,7 +898,6 @@
@@ -362,7 +898,6 @@
color: var(--fog-text, #1f2937);
cursor: pointer;
font-size: 0.875rem;
align-self: flex-start;
}
:global(.dark) .add-tag-button {
@ -381,6 +916,7 @@
@@ -381,6 +916,7 @@
.form-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}