Browse Source

Compose

master
Nuša Pukšič 6 months ago committed by buttercat1791
parent
commit
f0c40bc9ee
  1. 5
      src/lib/a/primitives/AAlert.svelte
  2. 306
      src/lib/components/ZettelEditor.svelte
  3. 22
      src/routes/new/compose/+page.svelte

5
src/lib/a/primitives/AAlert.svelte

@ -1,15 +1,16 @@
<script lang="ts"> <script lang="ts">
import { Alert } from "flowbite-svelte"; import { Alert } from "flowbite-svelte";
let { color, dismissable, children, title } = $props<{ let { color, dismissable, children, title, classes } = $props<{
color?: string; color?: string;
dismissable?: boolean; dismissable?: boolean;
children?: any; children?: any;
title?: any; title?: any;
classes?: string;
}>(); }>();
</script> </script>
<Alert {color} {dismissable} class="alert-leather mb-4"> <Alert {color} {dismissable} class="alert-leather mb-4 {classes}">
{#if title} {#if title}
<div class="flex"> <div class="flex">
<span class="text-lg font-medium">{@render title()}</span> <span class="text-lg font-medium">{@render title()}</span>

306
src/lib/components/ZettelEditor.svelte

@ -1,34 +1,15 @@
<script lang="ts"> <script lang="ts">
import { Textarea, Button } from "flowbite-svelte"; import { Textarea, Toolbar, ToolbarGroup, ToolbarButton } from "flowbite-svelte";
import { EyeOutline } from "flowbite-svelte-icons"; import { Bold, Italic, Link2, Image as ImageIcon, FilePlus, Eye } from "@lucide/svelte";
import { import { extractSmartMetadata, parseAsciiDocWithMetadata, type AsciiDocMetadata, metadataToTags } from "$lib/utils/asciidoc_metadata";
extractSmartMetadata, import asciidoctor from "asciidoctor";
parseAsciiDocWithMetadata, import { AAlert } from "$lib/a";
type AsciiDocMetadata, import { onMount } from 'svelte';
metadataToTags,
} from "$lib/utils/asciidoc_metadata";
import asciidoctor from "asciidoctor";
// Component props // Component props
let { let {
content = "", content = "",
placeholder = `== Note Title placeholder = `== Note Title\n:author: Your Name\n:version: 1.0\n:published_on: 2024-01-01\n:published_by: Alexandria\n:summary: A brief description of this note\n:tags: note, example, metadata\n:image: https://example.com/image.jpg\n\nnote content here...\n\n== Note Title 2\nSome Other Author (this works even if there is no :author: attribute)\n:keywords: second, note, example (keywords are converted to tags)\n:description: This is a description of the note (description is converted to a summary tag)\nNote content here...\n `,
:author: Your Name
:version: 1.0
:published_on: 2024-01-01
:published_by: Alexandria
:summary: A brief description of this note
:tags: note, example, metadata
:image: https://example.com/image.jpg
note content here...
== Note Title 2
Some Other Author (this weeks even if there is no :author: attribute)
:keywords: second, note, example (keywords are converted to tags)
:description: This is a description of the note (description is converted to a summary tag)
Note content here...
`,
showPreview = false, showPreview = false,
onContentChange = (content: string) => {}, onContentChange = (content: string) => {},
onPreviewToggle = (show: boolean) => {}, onPreviewToggle = (show: boolean) => {},
@ -40,27 +21,76 @@ Note content here...
onPreviewToggle?: (show: boolean) => void; onPreviewToggle?: (show: boolean) => void;
}>(); }>();
// --- New toolbar insertion helpers ---
let wrapper: HTMLElement | null = null;
function getTextarea(): HTMLTextAreaElement | null {
return wrapper?.querySelector('textarea') as HTMLTextAreaElement | null;
}
function insertMarkup(prefix: string, suffix: string) {
const ta = getTextarea();
if (!ta || ta.disabled) return;
const start = ta.selectionStart;
const end = ta.selectionEnd;
const selected = content.substring(start, end);
content = content.slice(0, start) + prefix + selected + suffix + content.slice(end);
onContentChange(content);
queueMicrotask(() => {
ta.focus();
const pos = start + prefix.length + selected.length + suffix.length;
ta.selectionStart = ta.selectionEnd = pos;
});
}
function insertPlain(text: string) {
const ta = getTextarea();
if (!ta || ta.disabled) return;
const start = ta.selectionStart;
const end = ta.selectionEnd;
content = content.slice(0, start) + text + content.slice(end);
onContentChange(content);
queueMicrotask(() => {
ta.focus();
const pos = start + text.length;
ta.selectionStart = ta.selectionEnd = pos;
});
}
const newNoteTemplate = `\n== Note Title\n:author: \n:version: 1.0\n:published_on: 2024-01-01\n:published_by: Alexandria\n:summary: \n:tags: \n:image: \n\nNote content here...\n`;
function insertNoteTemplate() {
const ta = getTextarea();
if (!ta || ta.disabled) return;
const start = ta.selectionStart;
const before = content.slice(0, start);
const needsNL = before.length > 0 && !before.endsWith('\n\n');
insertPlain((needsNL ? (before.endsWith('\n') ? '\n' : '\n\n') : '') + newNoteTemplate);
}
const markupButtons = [
{ label: 'Bold', icon: Bold, action: () => insertMarkup('*', '*') },
{ label: 'Italic', icon: Italic, action: () => insertMarkup('_', '_') },
{ label: 'Link', icon: Link2, action: () => insertPlain('link:url[Text]') },
{ label: 'Image', icon: ImageIcon, action: () => insertPlain('image::url[]') },
{ label: 'Insert Note Template', icon: FilePlus, action: () => insertNoteTemplate() },
];
// Parse sections for preview using the smart metadata service // Parse sections for preview using the smart metadata service
let parsedSections = $derived.by(() => { let parsedSections = $derived.by(() => {
if (!content.trim()) return []; if (!content.trim()) return [];
// Use smart metadata extraction that handles both document headers and section-only content // Use smart metadata extraction that handles both document headers and section-only content
const { metadata: docMetadata } = extractSmartMetadata(content); const { metadata: _docMetadata } = extractSmartMetadata(content);
// Parse the content using the standardized parser // Parse the content using the standardized parser
const parsed = parseAsciiDocWithMetadata(content); const parsed = parseAsciiDocWithMetadata(content);
// Debug logging // Debug logging
console.log("Parsed sections:", parsed.sections); console.log("Parsed sections:", parsed.sections);
return parsed.sections.map((section: { metadata: AsciiDocMetadata; content: string; title: string }) => { return parsed.sections.map((section: { metadata: AsciiDocMetadata; content: string; title: string }) => {
// Use only section metadata for each section // Use only section metadata for each section
// Don't combine with document metadata to avoid overriding section-specific metadata // Don't combine with document metadata to avoid overriding section-specific metadata
const tags = metadataToTags(section.metadata); const tags = metadataToTags(section.metadata);
// Debug logging // Debug logging
console.log(`Section "${section.title}":`, { metadata: section.metadata, tags }); console.log(`Section "${section.title}":`, { metadata: section.metadata, tags });
return { return {
title: section.title || "Untitled", title: section.title || "Untitled",
content: section.content.trim(), content: section.content.trim(),
@ -72,7 +102,7 @@ Note content here...
// Check for 30040-style document headers (publication format) // Check for 30040-style document headers (publication format)
let hasPublicationHeader = $derived.by(() => { let hasPublicationHeader = $derived.by(() => {
if (!content.trim()) return false; if (!content.trim()) return false;
const lines = content.split(/\r?\n/); const lines = content.split(/\r?\n/);
for (const line of lines) { for (const line of lines) {
// Check for document title (level 0 header) // Check for document title (level 0 header)
@ -93,6 +123,14 @@ Note content here...
onPreviewToggle(newShowPreview); onPreviewToggle(newShowPreview);
} }
// Auto-open preview on desktop (lg >= 1024px) first load
onMount(() => {
const mq = window.matchMedia('(min-width: 1024px)');
if (mq.matches && !showPreview) {
onPreviewToggle(true);
}
});
// Handle content changes // Handle content changes
function handleContentChange(event: Event) { function handleContentChange(event: Event) {
const target = event.target as HTMLTextAreaElement; const target = event.target as HTMLTextAreaElement;
@ -100,14 +138,14 @@ Note content here...
} }
</script> </script>
<div class="flex flex-col space-y-4"> <div class="w-full flex flex-col space-y-4 mt-3" bind:this={wrapper}>
<!-- Error message for publication format --> <!-- Error message for publication format -->
{#if hasPublicationHeader} {#if hasPublicationHeader}
<div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 mb-4"> <div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 mb-4">
<div class="flex items-start space-x-3"> <div class="flex items-start space-x-3">
<div class="flex-shrink-0"> <div class="flex-shrink-0">
<svg class="w-5 h-5 text-red-600 dark:text-red-400" fill="currentColor" viewBox="0 0 20 20"> <svg class="w-5 h-5 text-red-600 dark:text-red-400" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"></path> <path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zm-4 4a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"></path>
</svg> </svg>
</div> </div>
<div class="flex-1"> <div class="flex-1">
@ -115,14 +153,14 @@ Note content here...
Publication Format Detected Publication Format Detected
</h3> </h3>
<p class="text-sm text-red-700 dark:text-red-300 mb-3"> <p class="text-sm text-red-700 dark:text-red-300 mb-3">
You're using a publication format (document title with <code>=</code> or "index card"). You're using a publication format (document title with <code>=</code> or "index card").
This editor is for individual notes only. Use the This editor is for individual notes only. Use the
<a href="/events?kind=30040" class="font-medium underline hover:text-red-600 dark:hover:text-red-400">Events form</a> <a href="/events?kind=30040" class="font-medium underline hover:text-red-600 dark:hover:text-red-400">Events form</a>
to create structured publications. to create structured publications.
</p> </p>
<div class="flex space-x-2"> <div class="flex space-x-2">
<a <a
href="/events?kind=30040" href="/events?kind=30040"
onclick={() => { onclick={() => {
// Store the content in sessionStorage so it can be loaded in the Events form // Store the content in sessionStorage so it can be loaded in the Events form
sessionStorage.setItem('zettelEditorContent', content); sessionStorage.setItem('zettelEditorContent', content);
@ -132,7 +170,7 @@ Note content here...
> >
Switch to Publication Editor Switch to Publication Editor
</a> </a>
<button <button
onclick={() => { onclick={() => {
// Remove publication format by converting document title to section title // Remove publication format by converting document title to section title
let convertedContent = content.replace(/^=\s+(.+)$/gm, '== $1'); let convertedContent = content.replace(/^=\s+(.+)$/gm, '== $1');
@ -153,157 +191,91 @@ Note content here...
</div> </div>
{:else} {:else}
<!-- Informative text about ZettelEditor purpose --> <!-- Informative text about ZettelEditor purpose -->
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4 mb-4"> <AAlert color="blue" classes="max-w-lg self-center">
<div class="flex items-start space-x-3"> {#snippet title()}Note-Taking Tool{/snippet}
<div class="flex-shrink-0"> <p class="text-sm mb-3">
<svg class="w-5 h-5 text-blue-600 dark:text-blue-400" fill="currentColor" viewBox="0 0 20 20"> This editor is for creating individual notes (30041 events) only. Each section becomes a separate note event.
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 00-1-1H9z" clip-rule="evenodd"></path> You can add metadata like author, version, publication date, summary, and tags using AsciiDoc attributes.
</svg> To create structured publications with a 30040 index event that ties multiple notes together,
</div> use the <a href="/events/compose?kind=30040" class="font-medium underline">Events form</a>.
<div class="flex-1"> </p>
<h3 class="text-sm font-medium text-blue-800 dark:text-blue-200 mb-1"> <div class="flex space-x-2">
Note-Taking Tool <a
</h3> href="/events/compose?kind=30040"
<p class="text-sm text-blue-700 dark:text-blue-300 mb-3"> class="inline-flex items-center px-3 py-1.5 text-xs font-medium text-blue-700 dark:text-blue-300 bg-blue-100 dark:bg-blue-800 border border-blue-200 dark:border-blue-700 rounded-md hover:bg-blue-200 dark:hover:bg-blue-700 transition-colors"
This editor is for creating individual notes (30041 events) only. Each section becomes a separate note event. >
You can add metadata like author, version, publication date, summary, and tags using AsciiDoc attributes. Create Publication
To create structured publications with a 30040 index event that ties multiple notes together, </a>
use the <a href="/events?kind=30040" class="font-medium underline hover:text-blue-600 dark:hover:text-blue-400">Events form</a>.
</p>
<div class="flex space-x-2">
<a
href="/events?kind=30040"
class="inline-flex items-center px-3 py-1.5 text-xs font-medium text-blue-700 dark:text-blue-300 bg-blue-100 dark:bg-blue-800 border border-blue-200 dark:border-blue-700 rounded-md hover:bg-blue-200 dark:hover:bg-blue-700 transition-colors"
>
Create Publication
</a>
</div>
</div>
</div> </div>
</div> </AAlert>
{/if} {/if}
<div class="flex items-center justify-between"> <!-- Editor + Preview (two columns on desktop) -->
<Button <div class={`w-full grid gap-4 ${showPreview && !hasPublicationHeader ? 'lg:grid-cols-2' : 'lg:grid-cols-1'}`}>
color="light" <Textarea
size="sm" bind:value={content}
onclick={togglePreview} oninput={handleContentChange}
class="flex items-center space-x-1" {placeholder}
disabled={hasPublicationHeader} rows={20}
> disabled={hasPublicationHeader}
{#if showPreview} classes={{
<EyeOutline class="w-4 h-4" /> wrapper: '!m-0 p-0 h-full',
<span>Hide Preview</span> inner: '!m-0 !bg-transparent !dark:bg-transparent',
{:else} header: '!m-0 !bg-transparent !dark:bg-transparent',
<EyeOutline class="w-4 h-4" /> footer: '!m-0 !bg-transparent',
<span>Show Preview</span> addon: '!m-0 top-3 hidden md:flex',
{/if} div: '!m-0 !bg-transparent !dark:bg-transparent focus:!ring-0 h-full',
</Button> }}
</div> >
{#snippet header()}
<div class="flex flex-col lg:flex-row lg:space-x-4 {showPreview ? 'lg:h-96' : ''}"> <Toolbar embedded class="flex-row !m-0 !dark:bg-transparent !bg-transparent overflow-x-auto">
<!-- Editor Panel --> <ToolbarGroup class="flex-row flex-nowrap gap-1 !m-0">
<div class="{showPreview ? 'lg:w-1/2' : 'w-full'} flex flex-col space-y-4"> {#each markupButtons as btn}
<div class="flex-1"> {@const Icon = btn.icon}
<Textarea <ToolbarButton title={btn.label} color="dark" size="sm" onclick={btn.action} disabled={hasPublicationHeader}>
bind:value={content} <Icon size={24} />
on:input={handleContentChange} </ToolbarButton>
{placeholder} {/each}
class="h-full min-h-64 resize-none {hasPublicationHeader ? 'opacity-50 cursor-not-allowed' : ''}" <ToolbarButton title={showPreview ? 'Hide Preview' : 'Show Preview'} color="dark" size="sm" onclick={togglePreview} disabled={hasPublicationHeader}>
rows={12} <Eye size={24} />
disabled={hasPublicationHeader} </ToolbarButton>
/> </ToolbarGroup>
</div> </Toolbar>
</div> {/snippet}
</Textarea>
<!-- Preview Panel -->
{#if showPreview && !hasPublicationHeader} {#if showPreview && !hasPublicationHeader}
<div class="lg:w-1/2 lg:border-l lg:border-gray-200 lg:dark:border-gray-700 lg:pl-4 mt-4 lg:mt-0"> <div class="flex flex-col max-h-[600px]">
<div class="lg:sticky lg:top-4"> <div class="lg:sticky lg:top-4">
<h3 <h3 class="text-lg font-semibold mb-2 text-gray-900 dark:text-gray-100 hidden">AsciiDoc Preview</h3>
class="text-lg font-semibold mb-4 text-gray-900 dark:text-gray-100" <div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4 max-h-[560px] overflow-y-auto">
>
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()} {#if !content.trim()}
<div class="text-gray-500 dark:text-gray-400 text-sm"> <div class="text-gray-500 dark:text-gray-400 text-sm">Start typing to see the preview...</div>
Start typing to see the preview...
</div>
{:else} {:else}
<div class="prose prose-sm dark:prose-invert max-w-none"> <div class="prose prose-sm dark:prose-invert max-w-none">
{#each parsedSections as section, index} {#each parsedSections as section, index}
<div class="mb-6"> <div class="mb-6">
<div <div class="text-sm text-gray-800 dark:text-gray-200 asciidoc-content">{@html asciidoctor().convert(`== ${section.title}\n\n${section.content}`, { standalone:false, doctype:'article', attributes:{ showtitle:true, sectids:true } })}</div>
class="text-sm text-gray-800 dark:text-gray-200 asciidoc-content"
>
{@html asciidoctor().convert(
`== ${section.title}\n\n${section.content}`,
{
standalone: false,
doctype: "article",
attributes: {
showtitle: true,
sectids: true,
},
},
)}
</div>
<!-- Gray area with tag bubbles for all sections -->
<div class="my-4 relative"> <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="bg-gray-200 dark:bg-gray-700 rounded-lg p-3 mb-2"
>
<div class="flex flex-wrap gap-2 items-center"> <div class="flex flex-wrap gap-2 items-center">
{#if section.tags && section.tags.length > 0} {#if section.tags && section.tags.length > 0}
{#each section.tags as tag} {#each section.tags as tag}
<div <div class="bg-amber-900 text-amber-100 px-2 py-1 rounded-full text-xs font-medium flex items-baseline"><span class="font-mono">{tag[0]}:</span><span>{tag[1]}</span></div>
class="bg-amber-900 text-amber-100 px-2 py-1 rounded-full text-xs font-medium flex items-baseline"
>
<span class="font-mono">{tag[0]}:</span>
<span>{tag[1]}</span>
</div>
{/each} {/each}
{:else} {:else}
<span <span class="text-gray-500 dark:text-gray-400 text-xs italic">No tags</span>
class="text-gray-500 dark:text-gray-400 text-xs italic"
>No tags</span
>
{/if} {/if}
</div> </div>
</div> </div>
{#if index < parsedSections.length - 1} {#if index < parsedSections.length - 1}
<!-- Event boundary line only between sections --> <div class="border-t-2 border-dashed border-blue-400 relative"><div class="absolute -top-2 left-1/2 -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
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>
{/if} {/if}
</div> </div>
</div> </div>
{/each} {/each}
</div> </div>
<div class="mt-2 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' : ''}</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 />
</div>
{/if} {/if}
</div> </div>
</div> </div>

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

@ -1,12 +1,12 @@
<script lang="ts"> <script lang="ts">
import { Heading, Button, Alert } from "flowbite-svelte"; import { Heading, Button } from "flowbite-svelte";
import { PaperPlaneOutline } from "flowbite-svelte-icons"; import { PaperPlaneOutline } from "flowbite-svelte-icons";
import ZettelEditor from "$lib/components/ZettelEditor.svelte"; import ZettelEditor from "$lib/components/ZettelEditor.svelte";
import { goto } from "$app/navigation";
import { nip19 } from "nostr-tools"; import { nip19 } from "nostr-tools";
import { publishMultipleZettels } from "$lib/services/publisher"; import { publishMultipleZettels } from "$lib/services/publisher";
import { parseAsciiDocWithMetadata } from "$lib/utils/asciidoc_metadata"; import { parseAsciiDocWithMetadata } from "$lib/utils/asciidoc_metadata";
import { getNdkContext } from "$lib/ndk"; import { getNdkContext } from "$lib/ndk";
import { AAlert } from "$lib/a/index";
const ndk = getNdkContext(); const ndk = getNdkContext();
@ -131,12 +131,9 @@
</svelte:head> </svelte:head>
<!-- Main container with 75% width and centered --> <!-- Main container with 75% width and centered -->
<div class="w-3/4 mx-auto"> <div class="flex flex-col self-center items-center w-full px-2 space-y-4">
<div class="flex flex-col space-y-4">
<Heading <Heading
tag="h1" tag="h1" class="h-leather mb-2">
class="text-2xl font-bold text-gray-900 dark:text-gray-100"
>
Compose Notes Compose Notes
</Heading> </Heading>
@ -151,7 +148,7 @@
<Button <Button
onclick={handlePublish} onclick={handlePublish}
disabled={isPublishing || !content.trim()} disabled={isPublishing || !content.trim()}
class="w-full" class="self-end my-2"
> >
{#if isPublishing} {#if isPublishing}
Publishing... Publishing...
@ -164,7 +161,7 @@
<!-- Status Messages --> <!-- Status Messages -->
{#if publishResults} {#if publishResults}
{#if publishResults.successCount === publishResults.total} {#if publishResults.successCount === publishResults.total}
<Alert color="green" dismissable> <AAlert color="green" dismissable>
<span class="font-medium">Success!</span> <span class="font-medium">Success!</span>
{publishResults.successCount} events published. {publishResults.successCount} events published.
{#if publishResults.successfulEvents.length > 0} {#if publishResults.successfulEvents.length > 0}
@ -185,9 +182,9 @@
</div> </div>
</div> </div>
{/if} {/if}
</Alert> </AAlert>
{:else} {:else}
<Alert color="red" dismissable> <AAlert color="red" dismissable>
<span class="font-medium">Some events failed to publish.</span> <span class="font-medium">Some events failed to publish.</span>
{publishResults.successCount} of {publishResults.total} events published. {publishResults.successCount} of {publishResults.total} events published.
@ -232,8 +229,7 @@
</div> </div>
</div> </div>
{/if} {/if}
</Alert> </AAlert>
{/if} {/if}
{/if} {/if}
</div>
</div> </div>

Loading…
Cancel
Save