diff --git a/apps/mana/apps/web/src/lib/modules/writing/components/ExportMenu.svelte b/apps/mana/apps/web/src/lib/modules/writing/components/ExportMenu.svelte new file mode 100644 index 000000000..189868364 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/writing/components/ExportMenu.svelte @@ -0,0 +1,210 @@ + + + + + + diff --git a/apps/mana/apps/web/src/lib/modules/writing/stores/drafts.svelte.ts b/apps/mana/apps/web/src/lib/modules/writing/stores/drafts.svelte.ts index 71d9a09b8..2d0eb4d65 100644 --- a/apps/mana/apps/web/src/lib/modules/writing/stores/drafts.svelte.ts +++ b/apps/mana/apps/web/src/lib/modules/writing/stores/drafts.svelte.ts @@ -32,6 +32,8 @@ import type { DraftBriefing, DraftReference, DraftStyleOverrides, + DraftPublishTarget, + DraftPublishModule, } from '../types'; function wordCountOf(text: string): number { @@ -283,6 +285,33 @@ export const draftsStore = { }); }, + /** + * Record that a draft has been handed off to another module (M10 + * Publish-Hooks). Idempotent per (module, targetId) pair — the same + * destination doesn't accumulate duplicate rows if the user exports + * twice. Emits a cross-module event so the Workbench timeline can + * surface the hand-off. + */ + async recordPublish(draftId: string, target: DraftPublishModule, targetId: string) { + const existing = await draftTable.get(draftId); + if (!existing) return; + const publishedTo = existing.publishedTo ?? []; + const now = new Date().toISOString(); + const next: DraftPublishTarget[] = [ + ...publishedTo.filter((t) => !(t.module === target && t.targetId === targetId)), + { module: target, targetId, publishedAt: now }, + ]; + await draftTable.update(draftId, { + publishedTo: next, + updatedAt: now, + }); + emitDomainEvent('WritingDraftPublished', 'writing', 'writingDrafts', draftId, { + draftId, + module: target, + targetId, + }); + }, + async setVisibility(id: string, next: VisibilityLevel) { const existing = await draftTable.get(id); if (!existing) throw new Error(`Draft ${id} not found`); diff --git a/apps/mana/apps/web/src/lib/modules/writing/utils/export.ts b/apps/mana/apps/web/src/lib/modules/writing/utils/export.ts new file mode 100644 index 000000000..738493c68 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/writing/utils/export.ts @@ -0,0 +1,93 @@ +/** + * Export helpers — turn a draft + its current version into something + * the user can share outside the app. M10 exports: Markdown string, + * .md/.txt file download, print/PDF via the browser, and hand-off to + * the articles module as a saved read-later entry. + * + * Kept as pure utilities (no Svelte state) so they compose cleanly from + * the DetailView export menu + future AI tools / MCP endpoints. + */ + +import type { Draft, DraftVersion } from '../types'; + +/** + * Assemble the user-facing Markdown for a draft. The title becomes an H1; + * the version body is appended verbatim. We trim trailing whitespace so + * the clipboard doesn't end with empty lines. + */ +export function draftToMarkdown(draft: Draft, version: DraftVersion | null): string { + const title = draft.title || draft.briefing.topic || 'Unbenannt'; + const body = (version?.content ?? '').trimEnd(); + return `# ${title}\n\n${body}\n`.trimEnd() + '\n'; +} + +export function draftToPlainText(draft: Draft, version: DraftVersion | null): string { + const title = draft.title || draft.briefing.topic || 'Unbenannt'; + const body = (version?.content ?? '').trimEnd(); + return `${title}\n\n${body}\n`.trimEnd() + '\n'; +} + +/** + * Browser-safe file download via a synthetic anchor. Revokes the blob + * URL asynchronously so the download completes first; Safari + Firefox + * are both fine with revoke-on-next-tick. + */ +export function downloadFile(filename: string, content: string, mime: string): void { + if (typeof document === 'undefined') return; + const blob = new Blob([content], { type: mime }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + a.style.display = 'none'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + setTimeout(() => URL.revokeObjectURL(url), 1000); +} + +/** Sanitise a draft title into a valid, short filename stem. */ +export function fileStem(title: string): string { + const cleaned = title + .normalize('NFKD') + .replace(/[^\w\s-]/g, '') + .trim() + .replace(/\s+/g, '-') + .toLowerCase() + .slice(0, 80); + return cleaned || 'draft'; +} + +/** + * Wrapper around the Clipboard API with a fallback for insecure + * contexts (HTTP on anything other than localhost). Returns true on + * success so the caller can show a "Kopiert" confirmation. + */ +export async function copyTextToClipboard(text: string): Promise { + try { + if (navigator.clipboard && window.isSecureContext) { + await navigator.clipboard.writeText(text); + return true; + } + } catch { + // fall through to the legacy path + } + // Legacy fallback — works on http, deprecated but still widely + // supported. The hidden textarea avoids layout shift. + const textarea = document.createElement('textarea'); + textarea.value = text; + textarea.setAttribute('readonly', ''); + textarea.style.position = 'fixed'; + textarea.style.top = '-1000px'; + textarea.style.opacity = '0'; + document.body.appendChild(textarea); + textarea.select(); + try { + document.execCommand('copy'); + document.body.removeChild(textarea); + return true; + } catch { + document.body.removeChild(textarea); + return false; + } +} diff --git a/apps/mana/apps/web/src/lib/modules/writing/views/DetailView.svelte b/apps/mana/apps/web/src/lib/modules/writing/views/DetailView.svelte index 17630b049..1d8933f02 100644 --- a/apps/mana/apps/web/src/lib/modules/writing/views/DetailView.svelte +++ b/apps/mana/apps/web/src/lib/modules/writing/views/DetailView.svelte @@ -16,6 +16,7 @@ import VersionHistory from '../components/VersionHistory.svelte'; import GenerationStatus from '../components/GenerationStatus.svelte'; import SelectionToolbar from '../components/SelectionToolbar.svelte'; + import ExportMenu from '../components/ExportMenu.svelte'; import type { SelectionToolKind, SelectionToolInvocation, @@ -332,6 +333,32 @@ {/if} + + {#if draft.publishedTo.length > 0} +
+ 📤 Veröffentlicht: + {#each draft.publishedTo as target (`${target.module}:${target.targetId}`)} + + {#if target.module === 'articles'} + 📚 Artikel + {:else if target.module === 'website'} + 🌐 Website + {:else if target.module === 'presi'} + 🎞 Präsi + {:else if target.module === 'mail'} + ✉️ Mail + {:else if target.module === 'social-relay'} + 💬 Social + {:else} + {target.module} + {/if} + + {/each} +
+ {/if}
@@ -386,6 +413,7 @@ > {saving ? 'Speichert…' : '+ Checkpoint'} + {#if visibleGeneration} @@ -550,6 +578,29 @@ background: var(--color-surface-muted, rgba(0, 0, 0, 0.05)); word-break: break-all; } + .published-row { + display: flex; + align-items: center; + gap: 0.4rem; + flex-wrap: wrap; + font-size: 0.8rem; + color: var(--color-text-muted, rgba(0, 0, 0, 0.55)); + } + .published-label { + font-size: 0.75rem; + } + .published-chip { + padding: 0.15rem 0.55rem; + border-radius: 999px; + background: color-mix(in srgb, #22c55e 10%, transparent); + color: #16a34a; + border: 1px solid color-mix(in srgb, #22c55e 30%, transparent); + font-size: 0.75rem; + } + .published-chip a { + color: inherit; + text-decoration: underline; + } .status-picker { display: inline-flex; gap: 0.25rem;