mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-18 00:29:40 +02:00
feat(writing): M10 — publish hooks (export + save-as-article)
Writing can now leave the module. Four outbound paths in this milestone: Markdown to clipboard, plain text to clipboard, .md file download, and a browser-native print / PDF. Plus a first cross-module hand-off: save as a read-later article. - utils/export.ts: pure helpers — draftToMarkdown (title as H1 + body), draftToPlainText, downloadFile via synthetic anchor + blob URL, fileStem (NFKD-normalise + slug-ify the title), and a copyTextToClipboard wrapper that falls back to document.execCommand for http contexts that can't touch navigator.clipboard. - draftsStore.recordPublish(draftId, module, targetId) — idempotent per (module, targetId), appends to draft.publishedTo with a publishedAt timestamp, emits WritingDraftPublished so the Workbench timeline picks up the hand-off. - ExportMenu.svelte: dropdown next to Generate / Checkpoint with five items. "Als Artikel speichern" calls articlesStore.saveFromExtracted with originalUrl='internal://writing/<draftId>' as both a dedupe-safe identifier and a back-reference to the source draft, then records the publish and navigates to the new article. - DetailView surfaces draft.publishedTo as green chips under the meta row, with a click-through to the articles reader. Only 'articles' has a landing page today; website / presi / mail / social-relay chips render the label without a link (they arrive with their own milestones). Not in M10: website-block publishing (M-website), presi import (M-presi), mail draft creation. Those each need per-target block / slide / message shapes that exceed this commit's scope. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
6432ef7e6b
commit
0dadc17771
4 changed files with 383 additions and 0 deletions
|
|
@ -0,0 +1,210 @@
|
|||
<!--
|
||||
ExportMenu — drop-down next to the Generate/Checkpoint buttons in the
|
||||
DetailView. Four M10 actions:
|
||||
- Markdown kopieren
|
||||
- .md herunterladen
|
||||
- Drucken / PDF (uses the browser's native print dialog)
|
||||
- Als Artikel speichern → hand-off to the articles module
|
||||
|
||||
The heavy lifting lives in utils/export.ts + the stores; this
|
||||
component is just the menu surface + confirmation toasts.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { articlesStore } from '$lib/modules/articles/stores/articles.svelte';
|
||||
import { draftsStore } from '../stores/drafts.svelte';
|
||||
import {
|
||||
draftToMarkdown,
|
||||
draftToPlainText,
|
||||
downloadFile,
|
||||
fileStem,
|
||||
copyTextToClipboard,
|
||||
} from '../utils/export';
|
||||
import type { Draft, DraftVersion } from '../types';
|
||||
|
||||
let {
|
||||
draft,
|
||||
currentVersion,
|
||||
}: {
|
||||
draft: Draft;
|
||||
currentVersion: DraftVersion | null;
|
||||
} = $props();
|
||||
|
||||
let open = $state(false);
|
||||
let feedback = $state<string | null>(null);
|
||||
let feedbackTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let busy = $state(false);
|
||||
|
||||
function flash(msg: string) {
|
||||
feedback = msg;
|
||||
if (feedbackTimer) clearTimeout(feedbackTimer);
|
||||
feedbackTimer = setTimeout(() => (feedback = null), 2200);
|
||||
}
|
||||
|
||||
async function copyMd() {
|
||||
const ok = await copyTextToClipboard(draftToMarkdown(draft, currentVersion));
|
||||
flash(ok ? '✓ Markdown kopiert' : 'Kopieren fehlgeschlagen');
|
||||
open = false;
|
||||
}
|
||||
|
||||
async function copyPlain() {
|
||||
const ok = await copyTextToClipboard(draftToPlainText(draft, currentVersion));
|
||||
flash(ok ? '✓ Text kopiert' : 'Kopieren fehlgeschlagen');
|
||||
open = false;
|
||||
}
|
||||
|
||||
function downloadMd() {
|
||||
downloadFile(
|
||||
`${fileStem(draft.title)}.md`,
|
||||
draftToMarkdown(draft, currentVersion),
|
||||
'text/markdown;charset=utf-8'
|
||||
);
|
||||
flash('↓ Heruntergeladen');
|
||||
open = false;
|
||||
}
|
||||
|
||||
function printDraft() {
|
||||
open = false;
|
||||
if (typeof window !== 'undefined') window.print();
|
||||
}
|
||||
|
||||
async function saveAsArticle() {
|
||||
if (busy) return;
|
||||
busy = true;
|
||||
try {
|
||||
const content = currentVersion?.content ?? '';
|
||||
const wordCount = content.trim().split(/\s+/).filter(Boolean).length;
|
||||
// `internal://` scheme avoids colliding with real URLs in the
|
||||
// articles module's dedupe path while still giving the row a
|
||||
// unique originalUrl — the format `internal://writing/<id>`
|
||||
// doubles as a back-reference to the source draft.
|
||||
const article = await articlesStore.saveFromExtracted({
|
||||
originalUrl: `internal://writing/${draft.id}`,
|
||||
title: draft.title || draft.briefing.topic || 'Unbenannt',
|
||||
excerpt: content.slice(0, 240).trim() || null,
|
||||
content,
|
||||
htmlContent: content, // no HTML body yet — the articles reader handles plain text fine
|
||||
author: null,
|
||||
siteName: 'Writing',
|
||||
wordCount,
|
||||
readingTimeMinutes: Math.max(1, Math.round(wordCount / 200)),
|
||||
});
|
||||
await draftsStore.recordPublish(draft.id, 'articles', article.id);
|
||||
flash('✓ Als Artikel gespeichert');
|
||||
open = false;
|
||||
// Give the toast a moment before navigating away.
|
||||
setTimeout(() => goto(`/articles/${article.id}`), 600);
|
||||
} catch (err) {
|
||||
flash(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="menu">
|
||||
<button
|
||||
type="button"
|
||||
class="trigger"
|
||||
class:active={open}
|
||||
onclick={() => (open = !open)}
|
||||
aria-expanded={open}
|
||||
title="Exportieren / Veröffentlichen"
|
||||
>
|
||||
📤 Export
|
||||
</button>
|
||||
{#if open}
|
||||
<div class="dropdown" role="menu">
|
||||
<button type="button" role="menuitem" onclick={copyMd} disabled={busy}>
|
||||
📋 Markdown kopieren
|
||||
</button>
|
||||
<button type="button" role="menuitem" onclick={copyPlain} disabled={busy}>
|
||||
📋 Text kopieren
|
||||
</button>
|
||||
<button type="button" role="menuitem" onclick={downloadMd} disabled={busy}>
|
||||
↓ Als .md herunterladen
|
||||
</button>
|
||||
<button type="button" role="menuitem" onclick={printDraft} disabled={busy}>
|
||||
🖨 Drucken / PDF
|
||||
</button>
|
||||
<hr />
|
||||
<button type="button" role="menuitem" onclick={saveAsArticle} disabled={busy}>
|
||||
📚 Als Artikel speichern
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{#if feedback}
|
||||
<span class="toast" role="status" aria-live="polite">{feedback}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.menu {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.trigger {
|
||||
padding: 0.4rem 0.8rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.1));
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.trigger:hover {
|
||||
border-color: #0ea5e9;
|
||||
color: #0ea5e9;
|
||||
}
|
||||
.trigger.active {
|
||||
background: color-mix(in srgb, #0ea5e9 10%, transparent);
|
||||
border-color: #0ea5e9;
|
||||
color: #0ea5e9;
|
||||
}
|
||||
.dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.3rem);
|
||||
right: 0;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 14rem;
|
||||
padding: 0.3rem;
|
||||
border-radius: 0.55rem;
|
||||
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.1));
|
||||
background: var(--color-surface, white);
|
||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
.dropdown button {
|
||||
text-align: left;
|
||||
padding: 0.45rem 0.7rem;
|
||||
border-radius: 0.4rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.dropdown button:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, #0ea5e9 10%, transparent);
|
||||
color: #0ea5e9;
|
||||
}
|
||||
.dropdown button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.dropdown hr {
|
||||
margin: 0.2rem 0.1rem;
|
||||
border: none;
|
||||
border-top: 1px solid var(--color-border, rgba(0, 0, 0, 0.08));
|
||||
}
|
||||
.toast {
|
||||
font-size: 0.8rem;
|
||||
color: #0284c7;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -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`);
|
||||
|
|
|
|||
93
apps/mana/apps/web/src/lib/modules/writing/utils/export.ts
Normal file
93
apps/mana/apps/web/src/lib/modules/writing/utils/export.ts
Normal file
|
|
@ -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<boolean> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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 @@
|
|||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if draft.publishedTo.length > 0}
|
||||
<div class="published-row">
|
||||
<span class="published-label">📤 Veröffentlicht:</span>
|
||||
{#each draft.publishedTo as target (`${target.module}:${target.targetId}`)}
|
||||
<span
|
||||
class="published-chip"
|
||||
title={new Date(target.publishedAt).toLocaleString('de-DE')}
|
||||
>
|
||||
{#if target.module === 'articles'}
|
||||
📚 <a href={`/articles/${target.targetId}`}>Artikel</a>
|
||||
{: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}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
<section class="briefing-section">
|
||||
|
|
@ -386,6 +413,7 @@
|
|||
>
|
||||
{saving ? 'Speichert…' : '+ Checkpoint'}
|
||||
</button>
|
||||
<ExportMenu {draft} {currentVersion} />
|
||||
</div>
|
||||
</div>
|
||||
{#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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue