7 changed files with 518 additions and 19 deletions
@ -0,0 +1,154 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -1,24 +1,92 @@
|
||||
|
||||
<script lang='ts'> |
||||
import Preview from "$lib/components/Preview.svelte"; |
||||
import { pharosInstance } from "$lib/parser"; |
||||
import { Heading } from "flowbite-svelte"; |
||||
|
||||
let treeNeedsUpdate: boolean = false; |
||||
let treeUpdateCount: number = 0; |
||||
let someIndexValue = 0; |
||||
|
||||
$: { |
||||
if (treeNeedsUpdate) { |
||||
treeUpdateCount++; |
||||
} |
||||
import { Heading, Button, Alert } from "flowbite-svelte"; |
||||
import { PaperPlaneOutline } from "flowbite-svelte-icons"; |
||||
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; |
||||
} |
||||
|
||||
async function handlePublish() { |
||||
isPublishing = true; |
||||
publishResult = null; |
||||
|
||||
const result = await publishZettel({ |
||||
content, |
||||
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> |
||||
|
||||
<div class='w-full flex justify-center'> |
||||
<main class='main-leather flex flex-col space-y-4 max-w-2xl w-full mt-4 mb-4'> |
||||
<Heading tag='h1' class='h-leather mb-2'>Compose</Heading> |
||||
{#key treeUpdateCount} |
||||
<Preview rootId={$pharosInstance.getRootIndexId()} allowEditing={true} bind:needsUpdate={treeNeedsUpdate} index={someIndexValue} /> |
||||
{/key} |
||||
</main> |
||||
<svelte:head> |
||||
<title>Compose Note - Alexandria</title> |
||||
</svelte:head> |
||||
|
||||
<!-- Main container with 75% width and centered --> |
||||
<div class="w-3/4 mx-auto"> |
||||
<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> |
||||
|
||||
@ -0,0 +1,53 @@
@@ -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