Browse Source

Add editor, preview and publishing capabilities

master
limina1 8 months ago
parent
commit
b70929c0c9
  1. 1
      src/app.css
  2. 1
      src/lib/components/Navigation.svelte
  3. 154
      src/lib/components/ZettelEditor.svelte
  4. 113
      src/lib/services/publisher.ts
  5. 109
      src/lib/utils/ZettelParser.ts
  6. 100
      src/routes/new/compose/+page.svelte
  7. 53
      src/styles/asciidoc.css

1
src/app.css

@ -3,6 +3,7 @@ @@ -3,6 +3,7 @@
@import './styles/publications.css';
@import './styles/visualize.css';
@import "./styles/events.css";
@import './styles/asciidoc.css';
/* Custom styles */
@layer base {

1
src/lib/components/Navigation.svelte

@ -24,6 +24,7 @@ @@ -24,6 +24,7 @@
</div>
<NavUl class="ul-leather">
<NavLi href="/">Publications</NavLi>
<NavLi href="/new/compose">Compose</NavLi>
<NavLi href="/visualize">Visualize</NavLi>
<NavLi href="/start">Getting Started</NavLi>
<NavLi href="/events">Events</NavLi>

154
src/lib/components/ZettelEditor.svelte

@ -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>

113
src/lib/services/publisher.ts

@ -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, '-');
}

109
src/lib/utils/ZettelParser.ts

@ -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(...) { ... }

100
src/routes/new/compose/+page.svelte

@ -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";
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);
let treeNeedsUpdate: boolean = false;
let treeUpdateCount: number = 0;
let someIndexValue = 0;
// Handle content changes from ZettelEditor
function handleContentChange(newContent: string) {
content = newContent;
}
$: {
if (treeNeedsUpdate) {
treeUpdateCount++;
// 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>

53
src/styles/asciidoc.css

@ -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…
Cancel
Save