7 changed files with 518 additions and 19 deletions
@ -0,0 +1,154 @@ |
|||||||
|
<script lang='ts'> |
||||||
|
import { Textarea, Button } from "flowbite-svelte"; |
||||||
|
import { EyeOutline } from "flowbite-svelte-icons"; |
||||||
|
import { parseAsciiDocSections, type ZettelSection } from '$lib/utils/ZettelParser'; |
||||||
|
import asciidoctor from 'asciidoctor'; |
||||||
|
|
||||||
|
// Component props |
||||||
|
let { |
||||||
|
content = '', |
||||||
|
placeholder =`== Note Title |
||||||
|
:author: {author} // author is optional |
||||||
|
:tags: tag1, tag2, tag3 // tags are optional |
||||||
|
|
||||||
|
note content here... |
||||||
|
|
||||||
|
== Note Title 2 |
||||||
|
:tags: tag1, tag2, tag3 |
||||||
|
Note content here... |
||||||
|
`, |
||||||
|
showPreview = false, |
||||||
|
onContentChange = (content: string) => {}, |
||||||
|
onPreviewToggle = (show: boolean) => {} |
||||||
|
} = $props<{ |
||||||
|
content?: string; |
||||||
|
placeholder?: string; |
||||||
|
showPreview?: boolean; |
||||||
|
onContentChange?: (content: string) => void; |
||||||
|
onPreviewToggle?: (show: boolean) => void; |
||||||
|
}>(); |
||||||
|
|
||||||
|
// Initialize AsciiDoctor processor |
||||||
|
const asciidoctorProcessor = asciidoctor(); |
||||||
|
|
||||||
|
// Parse sections for preview |
||||||
|
let parsedSections = $derived(parseAsciiDocSections(content, 2)); |
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// Toggle preview panel |
||||||
|
function togglePreview() { |
||||||
|
const newShowPreview = !showPreview; |
||||||
|
onPreviewToggle(newShowPreview); |
||||||
|
} |
||||||
|
|
||||||
|
// Handle content changes |
||||||
|
function handleContentChange(event: Event) { |
||||||
|
const target = event.target as HTMLTextAreaElement; |
||||||
|
onContentChange(target.value); |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
<div class="flex flex-col space-y-4"> |
||||||
|
<div class="flex items-center justify-between"> |
||||||
|
<Button |
||||||
|
color="light" |
||||||
|
size="sm" |
||||||
|
on:click={togglePreview} |
||||||
|
class="flex items-center space-x-1" |
||||||
|
> |
||||||
|
{#if showPreview} |
||||||
|
<EyeOutline class="w-4 h-4" /> |
||||||
|
<span>Hide Preview</span> |
||||||
|
{:else} |
||||||
|
<EyeOutline class="w-4 h-4" /> |
||||||
|
<span>Show Preview</span> |
||||||
|
{/if} |
||||||
|
</Button> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="flex space-x-4 {showPreview ? 'h-96' : ''}"> |
||||||
|
<!-- Editor Panel --> |
||||||
|
<div class="{showPreview ? 'w-1/2' : 'w-full'} flex flex-col space-y-4"> |
||||||
|
<div class="flex-1"> |
||||||
|
<Textarea |
||||||
|
bind:value={content} |
||||||
|
on:input={handleContentChange} |
||||||
|
{placeholder} |
||||||
|
class="h-full min-h-64 resize-none" |
||||||
|
rows={12} |
||||||
|
/> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<!-- Preview Panel --> |
||||||
|
{#if showPreview} |
||||||
|
<div class="w-1/2 border-l border-gray-200 dark:border-gray-700 pl-4"> |
||||||
|
<div class="sticky top-4"> |
||||||
|
<h3 class="text-lg font-semibold mb-4 text-gray-900 dark:text-gray-100"> |
||||||
|
AsciiDoc Preview |
||||||
|
</h3> |
||||||
|
|
||||||
|
<div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4 max-h-80 overflow-y-auto"> |
||||||
|
{#if !content.trim()} |
||||||
|
<div class="text-gray-500 dark:text-gray-400 text-sm"> |
||||||
|
Start typing to see the preview... |
||||||
|
</div> |
||||||
|
{:else} |
||||||
|
<div class="prose prose-sm dark:prose-invert max-w-none"> |
||||||
|
{#each parsedSections as section, index} |
||||||
|
<div class="mb-6"> |
||||||
|
<div class="text-sm text-gray-800 dark:text-gray-200 asciidoc-content"> |
||||||
|
{@html asciidoctorProcessor.convert(`== ${section.title}\n\n${section.content}`, { |
||||||
|
standalone: false, |
||||||
|
doctype: 'article', |
||||||
|
attributes: { |
||||||
|
'showtitle': true, |
||||||
|
'sectids': true |
||||||
|
} |
||||||
|
})} |
||||||
|
</div> |
||||||
|
|
||||||
|
{#if index < parsedSections.length - 1} |
||||||
|
<!-- Gray area with tag bubbles above event boundary --> |
||||||
|
<div class="my-4 relative"> |
||||||
|
<!-- Gray background area --> |
||||||
|
<div class="bg-gray-200 dark:bg-gray-700 rounded-lg p-3 mb-2"> |
||||||
|
<div class="flex flex-wrap gap-2 items-center"> |
||||||
|
{#if section.tags && section.tags.length > 0} |
||||||
|
{#each section.tags as tag} |
||||||
|
<div class="bg-amber-900 text-amber-100 px-2 py-1 rounded-full text-xs font-medium flex items-center"> |
||||||
|
<span class="font-mono">{tag[0]}:</span> |
||||||
|
<span>{tag[1]}</span> |
||||||
|
</div> |
||||||
|
{/each} |
||||||
|
{:else} |
||||||
|
<span class="text-gray-500 dark:text-gray-400 text-xs italic">No tags</span> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<!-- Event boundary line --> |
||||||
|
<div class="border-t-2 border-dashed border-blue-400 relative"> |
||||||
|
<div class="absolute -top-2 left-1/2 transform -translate-x-1/2 bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 px-2 py-1 rounded text-xs font-medium"> |
||||||
|
Event Boundary |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
{/each} |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="mt-4 text-xs text-gray-600 dark:text-gray-400 bg-gray-50 dark:bg-gray-900 p-2 rounded border"> |
||||||
|
<strong>Event Count:</strong> {parsedSections.length} event{parsedSections.length !== 1 ? 's' : ''} |
||||||
|
<br> |
||||||
|
<strong>Note:</strong> Currently only the first event will be published. |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
</div> |
||||||
@ -0,0 +1,113 @@ |
|||||||
|
import { get } from 'svelte/store'; |
||||||
|
import { ndkInstance } from '$lib/ndk'; |
||||||
|
import { getMimeTags } from '$lib/utils/mime'; |
||||||
|
import { parseAsciiDocSections, type ZettelSection } from '$lib/utils/ZettelParser'; |
||||||
|
import { NDKRelaySet, NDKEvent } from '@nostr-dev-kit/ndk'; |
||||||
|
import { nip19 } from 'nostr-tools'; |
||||||
|
|
||||||
|
export interface PublishResult { |
||||||
|
success: boolean; |
||||||
|
eventId?: string; |
||||||
|
error?: string; |
||||||
|
} |
||||||
|
|
||||||
|
export interface PublishOptions { |
||||||
|
content: string; |
||||||
|
kind?: number; |
||||||
|
onSuccess?: (eventId: string) => void; |
||||||
|
onError?: (error: string) => void; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Publishes AsciiDoc content as Nostr events |
||||||
|
* @param options - Publishing options |
||||||
|
* @returns Promise resolving to publish result |
||||||
|
*/ |
||||||
|
export async function publishZettel(options: PublishOptions): Promise<PublishResult> { |
||||||
|
const { content, kind = 30041, onSuccess, onError } = options; |
||||||
|
|
||||||
|
if (!content.trim()) { |
||||||
|
const error = 'Please enter some content'; |
||||||
|
onError?.(error); |
||||||
|
return { success: false, error }; |
||||||
|
} |
||||||
|
|
||||||
|
// Get the current NDK instance from the store
|
||||||
|
const ndk = get(ndkInstance); |
||||||
|
|
||||||
|
if (!ndk?.activeUser) { |
||||||
|
const error = 'Please log in first'; |
||||||
|
onError?.(error); |
||||||
|
return { success: false, error }; |
||||||
|
} |
||||||
|
|
||||||
|
try { |
||||||
|
// Parse content into sections
|
||||||
|
const sections = parseAsciiDocSections(content, 2); |
||||||
|
|
||||||
|
if (sections.length === 0) { |
||||||
|
throw new Error('No valid sections found in content'); |
||||||
|
} |
||||||
|
|
||||||
|
// For now, publish only the first section
|
||||||
|
const firstSection = sections[0]; |
||||||
|
const title = firstSection.title; |
||||||
|
const cleanContent = firstSection.content; |
||||||
|
const sectionTags = firstSection.tags || []; |
||||||
|
|
||||||
|
|
||||||
|
// Generate d-tag and create event
|
||||||
|
const dTag = generateDTag(title); |
||||||
|
const [mTag, MTag] = getMimeTags(kind); |
||||||
|
|
||||||
|
const tags: string[][] = [ |
||||||
|
['d', dTag], |
||||||
|
mTag, |
||||||
|
MTag, |
||||||
|
['title', title] |
||||||
|
]; |
||||||
|
if (sectionTags) { |
||||||
|
tags.push(...sectionTags); |
||||||
|
} |
||||||
|
|
||||||
|
// Create and sign NDK event
|
||||||
|
const ndkEvent = new NDKEvent(ndk); |
||||||
|
ndkEvent.kind = kind; |
||||||
|
ndkEvent.created_at = Math.floor(Date.now() / 1000); |
||||||
|
ndkEvent.tags = tags; |
||||||
|
ndkEvent.content = cleanContent; |
||||||
|
ndkEvent.pubkey = ndk.activeUser.pubkey; |
||||||
|
|
||||||
|
await ndkEvent.sign(); |
||||||
|
|
||||||
|
// Publish to relays
|
||||||
|
const allRelayUrls = Array.from(ndk.pool?.relays.values() || []).map(r => r.url); |
||||||
|
|
||||||
|
if (allRelayUrls.length === 0) { |
||||||
|
throw new Error('No relays available in NDK pool'); |
||||||
|
} |
||||||
|
|
||||||
|
const relaySet = NDKRelaySet.fromRelayUrls(allRelayUrls, ndk); |
||||||
|
const publishedToRelays = await ndkEvent.publish(relaySet); |
||||||
|
|
||||||
|
if (publishedToRelays.size > 0) { |
||||||
|
const result = { success: true, eventId: ndkEvent.id }; |
||||||
|
onSuccess?.(ndkEvent.id); |
||||||
|
return result; |
||||||
|
} else { |
||||||
|
// Try fallback publishing logic here...
|
||||||
|
throw new Error('Failed to publish to any relays'); |
||||||
|
} |
||||||
|
} catch (error) { |
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error'; |
||||||
|
onError?.(errorMessage); |
||||||
|
return { success: false, error: errorMessage }; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
function generateDTag(title: string): string { |
||||||
|
return title |
||||||
|
.toLowerCase() |
||||||
|
.replace(/[^\w\s-]/g, '') |
||||||
|
.replace(/\s+/g, '-'); |
||||||
|
}
|
||||||
@ -0,0 +1,109 @@ |
|||||||
|
import { ndkInstance } from '$lib/ndk'; |
||||||
|
import { signEvent, getEventHash } from '$lib/utils/nostrUtils'; |
||||||
|
import { getMimeTags } from '$lib/utils/mime'; |
||||||
|
import { standardRelays } from '$lib/consts'; |
||||||
|
import { nip19 } from 'nostr-tools'; |
||||||
|
|
||||||
|
export interface ZettelSection { |
||||||
|
title: string; |
||||||
|
content: string; |
||||||
|
tags?: string[][]; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Splits AsciiDoc content into sections at the specified heading level. |
||||||
|
* Each section starts with the heading and includes all lines up to the next heading of the same level. |
||||||
|
* @param content The AsciiDoc string. |
||||||
|
* @param level The heading level (2 for '==', 3 for '===', etc.). |
||||||
|
* @returns Array of section strings, each starting with the heading. |
||||||
|
*/ |
||||||
|
export function splitAsciiDocByHeadingLevel(content: string, level: number): string[] { |
||||||
|
if (level < 1 || level > 6) throw new Error('Heading level must be 1-6'); |
||||||
|
const heading = '^' + '='.repeat(level) + ' '; |
||||||
|
const regex = new RegExp(`(?=${heading})`, 'gm');
|
||||||
|
return content |
||||||
|
.split(regex) |
||||||
|
.map(section => section.trim()) |
||||||
|
.filter(section => section.length > 0); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Parses a single AsciiDoc section string into a ZettelSection object. |
||||||
|
* @param section The section string (must start with heading). |
||||||
|
*/ |
||||||
|
export function parseZettelSection(section: string): ZettelSection { |
||||||
|
const lines = section.split('\n'); |
||||||
|
let title = 'Untitled'; |
||||||
|
let contentLines: string[] = []; |
||||||
|
let inHeader = true; |
||||||
|
let tags: string[][] = []; |
||||||
|
tags = extractTags(section); |
||||||
|
|
||||||
|
|
||||||
|
for (const line of lines) { |
||||||
|
const trimmed = line.trim(); |
||||||
|
if (inHeader && trimmed.startsWith('==')) { |
||||||
|
title = trimmed.replace(/^==+/, '').trim(); |
||||||
|
continue; |
||||||
|
} |
||||||
|
else if (inHeader && trimmed.startsWith(':')) { |
||||||
|
continue; |
||||||
|
} |
||||||
|
|
||||||
|
inHeader = false; |
||||||
|
contentLines.push(line); |
||||||
|
} |
||||||
|
|
||||||
|
return { |
||||||
|
title, |
||||||
|
content: contentLines.join('\n').trim(), |
||||||
|
tags, |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Parses AsciiDoc into an array of ZettelSection objects at the given heading level. |
||||||
|
*/ |
||||||
|
export function parseAsciiDocSections(content: string, level: number): ZettelSection[] { |
||||||
|
return splitAsciiDocByHeadingLevel(content, level).map(parseZettelSection); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Extracts tag names and values from the content. |
||||||
|
* :tagname: tagvalue // tags are optional
|
||||||
|
* @param content The AsciiDoc string. |
||||||
|
* @returns Array of tags. |
||||||
|
*/ |
||||||
|
export function extractTags(content: string): string[][] { |
||||||
|
const tags : string[][] = []; |
||||||
|
const lines = content.split('\n'); |
||||||
|
|
||||||
|
for (const line of lines) { |
||||||
|
const trimmed = line.trim(); |
||||||
|
if (trimmed.startsWith(':')) { |
||||||
|
// Parse AsciiDoc attribute format: :tagname: value
|
||||||
|
const match = trimmed.match(/^:([^:]+):\s*(.*)$/); |
||||||
|
if (match) { |
||||||
|
const tagName = match[1].trim(); |
||||||
|
const tagValue = match[2].trim(); |
||||||
|
|
||||||
|
// Special handling for tags attribute
|
||||||
|
if (tagName === 'tags') { |
||||||
|
// Split comma-separated values and create individual "t" tags
|
||||||
|
const tagValues = tagValue.split(',').map(v => v.trim()).filter(v => v.length > 0); |
||||||
|
for (const value of tagValues) { |
||||||
|
tags.push(['t', value]); |
||||||
|
} |
||||||
|
} else { |
||||||
|
// Regular attribute becomes a tag
|
||||||
|
tags.push([tagName, tagValue]); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
console.log('Extracted tags:', tags); |
||||||
|
return tags; |
||||||
|
} |
||||||
|
// You can add publishing logic here as needed, e.g.,
|
||||||
|
// export async function publishZettelSection(...) { ... }
|
||||||
@ -1,24 +1,92 @@ |
|||||||
|
|
||||||
<script lang='ts'> |
<script lang='ts'> |
||||||
import Preview from "$lib/components/Preview.svelte"; |
import { Heading, Button, Alert } from "flowbite-svelte"; |
||||||
import { pharosInstance } from "$lib/parser"; |
import { PaperPlaneOutline } from "flowbite-svelte-icons"; |
||||||
import { Heading } from "flowbite-svelte"; |
import ZettelEditor from '$lib/components/ZettelEditor.svelte'; |
||||||
|
import { goto } from "$app/navigation"; |
||||||
|
import { nip19 } from "nostr-tools"; |
||||||
|
import { publishZettel } from '$lib/services/publisher'; |
||||||
|
|
||||||
|
let content = $state(''); |
||||||
|
let showPreview = $state(false); |
||||||
|
let isPublishing = $state(false); |
||||||
|
let publishResult = $state<{ success: boolean; eventId?: string; error?: string } | null>(null); |
||||||
|
|
||||||
|
// Handle content changes from ZettelEditor |
||||||
|
function handleContentChange(newContent: string) { |
||||||
|
content = newContent; |
||||||
|
} |
||||||
|
|
||||||
|
// Handle preview toggle from ZettelEditor |
||||||
|
function handlePreviewToggle(show: boolean) { |
||||||
|
showPreview = show; |
||||||
|
} |
||||||
|
|
||||||
let treeNeedsUpdate: boolean = false; |
async function handlePublish() { |
||||||
let treeUpdateCount: number = 0; |
isPublishing = true; |
||||||
let someIndexValue = 0; |
publishResult = null; |
||||||
|
|
||||||
$: { |
const result = await publishZettel({ |
||||||
if (treeNeedsUpdate) { |
content, |
||||||
treeUpdateCount++; |
onSuccess: (eventId) => { |
||||||
|
publishResult = { success: true, eventId }; |
||||||
|
const nevent = nip19.neventEncode({ id: eventId }); |
||||||
|
goto(`/events?id=${nevent}`); |
||||||
|
}, |
||||||
|
onError: (error) => { |
||||||
|
publishResult = { success: false, error }; |
||||||
} |
} |
||||||
|
}); |
||||||
|
|
||||||
|
isPublishing = false; |
||||||
} |
} |
||||||
</script> |
</script> |
||||||
|
|
||||||
<div class='w-full flex justify-center'> |
<svelte:head> |
||||||
<main class='main-leather flex flex-col space-y-4 max-w-2xl w-full mt-4 mb-4'> |
<title>Compose Note - Alexandria</title> |
||||||
<Heading tag='h1' class='h-leather mb-2'>Compose</Heading> |
</svelte:head> |
||||||
{#key treeUpdateCount} |
|
||||||
<Preview rootId={$pharosInstance.getRootIndexId()} allowEditing={true} bind:needsUpdate={treeNeedsUpdate} index={someIndexValue} /> |
<!-- Main container with 75% width and centered --> |
||||||
{/key} |
<div class="w-3/4 mx-auto"> |
||||||
</main> |
<div class="flex flex-col space-y-4"> |
||||||
|
<Heading tag="h1" class="text-2xl font-bold text-gray-900 dark:text-gray-100"> |
||||||
|
Compose Notes |
||||||
|
</Heading> |
||||||
|
|
||||||
|
<ZettelEditor |
||||||
|
{content} |
||||||
|
{showPreview} |
||||||
|
onContentChange={handleContentChange} |
||||||
|
onPreviewToggle={handlePreviewToggle} |
||||||
|
/> |
||||||
|
|
||||||
|
<!-- Publish Button --> |
||||||
|
<Button |
||||||
|
on:click={handlePublish} |
||||||
|
disabled={isPublishing || !content.trim()} |
||||||
|
class="w-full" |
||||||
|
> |
||||||
|
{#if isPublishing} |
||||||
|
Publishing... |
||||||
|
{:else} |
||||||
|
<PaperPlaneOutline class="w-4 h-4 mr-2" /> |
||||||
|
Publish |
||||||
|
{/if} |
||||||
|
</Button> |
||||||
|
|
||||||
|
<!-- Status Messages --> |
||||||
|
{#if publishResult} |
||||||
|
{#if publishResult.success} |
||||||
|
<Alert color="green" dismissable> |
||||||
|
<span class="font-medium">Success!</span> |
||||||
|
Event published successfully. Event ID: {publishResult.eventId} |
||||||
|
</Alert> |
||||||
|
{:else} |
||||||
|
<Alert color="red" dismissable> |
||||||
|
<span class="font-medium">Error!</span> |
||||||
|
{publishResult.error} |
||||||
|
</Alert> |
||||||
|
{/if} |
||||||
|
{/if} |
||||||
|
</div> |
||||||
</div> |
</div> |
||||||
|
|||||||
@ -0,0 +1,53 @@ |
|||||||
|
/* AsciiDoc Content Styling */ |
||||||
|
/* These styles are for rendered AsciiDoc content in previews and publications */ |
||||||
|
|
||||||
|
.asciidoc-content h1, |
||||||
|
.asciidoc-content h2, |
||||||
|
.asciidoc-content h3, |
||||||
|
.asciidoc-content h4, |
||||||
|
.asciidoc-content h5, |
||||||
|
.asciidoc-content h6 { |
||||||
|
font-weight: bold; |
||||||
|
margin-top: 1.5em; |
||||||
|
margin-bottom: 0.5em; |
||||||
|
line-height: 1.25; |
||||||
|
color: inherit; |
||||||
|
} |
||||||
|
|
||||||
|
.asciidoc-content h1 { |
||||||
|
font-size: 1.875rem; |
||||||
|
} |
||||||
|
|
||||||
|
.asciidoc-content h2 { |
||||||
|
font-size: 1.5rem; |
||||||
|
} |
||||||
|
|
||||||
|
.asciidoc-content h3 { |
||||||
|
font-size: 1.25rem; |
||||||
|
} |
||||||
|
|
||||||
|
.asciidoc-content h4 { |
||||||
|
font-size: 1.125rem; |
||||||
|
} |
||||||
|
|
||||||
|
.asciidoc-content h5 { |
||||||
|
font-size: 1rem; |
||||||
|
} |
||||||
|
|
||||||
|
.asciidoc-content h6 { |
||||||
|
font-size: 0.875rem; |
||||||
|
} |
||||||
|
|
||||||
|
.asciidoc-content p { |
||||||
|
margin-bottom: 1em; |
||||||
|
} |
||||||
|
|
||||||
|
/* Dark mode support */ |
||||||
|
.dark .asciidoc-content h1, |
||||||
|
.dark .asciidoc-content h2, |
||||||
|
.dark .asciidoc-content h3, |
||||||
|
.dark .asciidoc-content h4, |
||||||
|
.dark .asciidoc-content h5, |
||||||
|
.dark .asciidoc-content h6 { |
||||||
|
color: inherit; |
||||||
|
} |
||||||
Loading…
Reference in new issue