feat(writing): M1+M2 — new Ghostwriter module with manual draft CRUD

M1 (skeleton):
- Module `writing` registered: 4 Dexie tables (writingDrafts,
  writingDraftVersions, writingGenerations, writingStyles) in v43,
  encrypted via typed registry entries, space-scoped via the Dexie hook.
- App entry in mana-apps.ts (sky-cyan #0ea5e9, LOCAL TIER PATCH guest),
  fountain-pen icon in app-icons.ts.
- Plan: docs/plans/writing-module.md — 12 milestones, Ghostwriter-first
  with Canvas deferred to M9, Picture-pattern analogue (Draft + Version
  + Generation), 9 preset styles, Space-Kontext-as-default.

M2 (manual CRUD):
- drafts store: createDraft (atomic draft + initial v1), updateBriefing,
  setStatus, toggleFavorite, deleteDraft (cascade soft-delete versions),
  updateVersionContent (live edit), createCheckpointVersion,
  restoreVersion (pointer flip, non-destructive), setVisibility.
- styles store: createStyle, updateStyle, upsertExtractedPrinciples,
  setSpaceDefault (exclusive flip), deleteStyle.
- queries: useAllDrafts, useDraft, useVersionsForDraft,
  useCurrentVersionForDraft (follows the pointer so restoreVersion shows
  up in the editor), useGenerationsForDraft, useAllStyles + helpers.
- UI: KindTabs (shows only kinds with drafts), StatusBadge, StatusFilter,
  DraftCard (<button> for a11y), BriefingForm (topic/kind/audience/tone/
  length/language/extra), VersionEditor (500ms debounce + onBlur flush),
  VersionHistory (restore button per version).
- Routes: /writing list + /writing/draft/[id] with {#key id} remounting.

User flow: create draft from briefing → land in detail view → type →
autosave → "Als Checkpoint speichern" for a new version → restore any
older version from the history panel. No AI yet; M3 wires mana-llm for
short-form generation and M7 switches to mana-ai missions for long-form.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-24 14:59:56 +02:00
parent 259f6fb316
commit 3c3b2ebbc7
27 changed files with 3484 additions and 0 deletions

View file

@ -91,6 +91,12 @@ import type {
import type { LocalArticle, LocalHighlight } from '../../modules/articles/types';
import type { LocalMeImage } from '../../modules/profile/types';
import type { LocalWardrobeGarment, LocalWardrobeOutfit } from '../../modules/wardrobe/types';
import type {
LocalDraft,
LocalDraftVersion,
LocalGeneration,
LocalWritingStyle,
} from '../../modules/writing/types';
export const ENCRYPTION_REGISTRY: Record<string, EncryptionConfig> = {
// ─── Chat ────────────────────────────────────────────────
@ -718,6 +724,36 @@ export const ENCRYPTION_REGISTRY: Record<string, EncryptionConfig> = {
fields: ['title', 'originalTitle', 'creators', 'review', 'tags'],
},
// ─── Writing ─────────────────────────────────────────────
// Ghostwriter module — drafts, version snapshots, generation records,
// and user-defined styles. The full prose is the most sensitive
// surface: a draft's content, briefing (topic / audience / extra
// instructions) and any user-supplied reference notes can disclose
// anything from unannounced launches to personal letters.
//
// Plaintext (intentional):
// - kind, status, publish targets, isFavorite, visibility fields
// drive tabs / chips / sort — query-critical.
// - versionNumber, wordCount, generationId, isAiGenerated — used
// for ordering and the history panel.
// - generation.status, provider, model, params, tokenUsage,
// durationMs, missionId — purely operational, no user content.
// - style.source, presetId, isSpaceDefault, isFavorite — query.
//
// `references` on a draft is an array of { kind, targetId/url, note }.
// targetId + url + kind are plaintext (FKs and public URLs); the
// per-reference `note` travels encrypted with the whole array via
// array-path encryption (same pattern as food.foods / quiz.options).
writingDrafts: entry<LocalDraft>(['title', 'briefing', 'styleOverrides', 'references']),
writingDraftVersions: entry<LocalDraftVersion>(['content', 'summary']),
writingGenerations: entry<LocalGeneration>(['prompt', 'output']),
writingStyles: entry<LocalWritingStyle>([
'name',
'description',
'samples',
'extractedPrinciples',
]),
// ─── Invoices ────────────────────────────────────────────
// Outbound finance. Sensitive surface is non-trivial: client name and
// address, the free-text subject/notes/terms, and the line items

View file

@ -1011,6 +1011,27 @@ db.version(42).stores({
'id, isFavorite, isPublic, isArchived, prompt, updatedAt, wardrobeOutfitId, wardrobeGarmentId',
});
// v43 — Writing module (docs/plans/writing-module.md M1).
// Four space-scoped tables:
// - writingDrafts: briefing + currentVersionId pointer. Indices: kind
// (tab filter), status (chip filter), updatedAt (default sort),
// isFavorite (favourites toggle).
// - writingDraftVersions: immutable snapshots of the draft body. Indexed
// on draftId for version-history fetch, versionNumber for ordering.
// - writingGenerations: provider-level call records (prompt, status,
// tokens, model). Indexed on draftId for per-draft runs and status
// so the UI can find in-flight work on reload.
// - writingStyles: reusable style definitions (preset + custom). Indexed
// on source (preset vs custom), isSpaceDefault, isFavorite.
// All four get standard spaceId/authorId/visibility stamping via the Dexie
// hook (NOT in USER_LEVEL_TABLES).
db.version(43).stores({
writingDrafts: 'id, kind, status, updatedAt, isFavorite',
writingDraftVersions: 'id, draftId, versionNumber, createdAt',
writingGenerations: 'id, draftId, status, createdAt',
writingStyles: 'id, source, isSpaceDefault, isFavorite, updatedAt',
});
// ─── Sync Routing ──────────────────────────────────────────
// SYNC_APP_MAP, TABLE_TO_SYNC_NAME, TABLE_TO_APP, SYNC_NAME_TO_TABLE,
// toSyncName() and fromSyncName() are now derived from per-module

View file

@ -105,6 +105,7 @@ import { broadcastModuleConfig } from '$lib/modules/broadcast/module.config';
import { wetterModuleConfig } from '$lib/modules/wetter/module.config';
import { websiteModuleConfig } from '$lib/modules/website/module.config';
import { wardrobeModuleConfig } from '$lib/modules/wardrobe/module.config';
import { writingModuleConfig } from '$lib/modules/writing/module.config';
import { aiModuleConfig } from '$lib/data/ai/module.config';
export const MODULE_CONFIGS: readonly ModuleConfig[] = [
@ -166,6 +167,7 @@ export const MODULE_CONFIGS: readonly ModuleConfig[] = [
wetterModuleConfig,
websiteModuleConfig,
wardrobeModuleConfig,
writingModuleConfig,
aiModuleConfig,
];

View file

@ -0,0 +1,11 @@
<!--
Writing — Module-root ListView.
Mirrors the library/ convention: a thin wrapper that delegates to the
real view in views/ so the route file doesn't need to know about the
internal structure.
-->
<script lang="ts">
import ListView from './views/ListView.svelte';
</script>
<ListView />

View file

@ -0,0 +1,65 @@
/**
* Writing module Dexie accessors and guest seed.
*
* Four tables: `writingDrafts` (the briefing + pointer to current version),
* `writingDraftVersions` (immutable snapshots of the text body), `writingGenerations`
* (provider-level call records prompt, status, duration, tokens), and
* `writingStyles` (reusable named style definitions, preset or custom).
*/
import { db } from '$lib/data/database';
import type { LocalDraft, LocalDraftVersion, LocalGeneration, LocalWritingStyle } from './types';
export const draftTable = db.table<LocalDraft>('writingDrafts');
export const draftVersionTable = db.table<LocalDraftVersion>('writingDraftVersions');
export const generationTable = db.table<LocalGeneration>('writingGenerations');
export const writingStyleTable = db.table<LocalWritingStyle>('writingStyles');
// ─── Guest Seed ────────────────────────────────────────────
//
// One example draft + its initial version + a custom style, so first-run
// users can open the module and immediately see the briefing / version /
// style shape. Intentionally small — the interesting seeds are the
// preset styles (see `presets/styles.ts`), not sample drafts.
export const WRITING_GUEST_SEED = {
writingDrafts: [
{
id: 'demo-draft-welcome',
kind: 'blog' as const,
status: 'draft' as const,
title: 'Willkommen bei Writing',
briefing: {
topic: 'Was dieses Modul für dich tut',
audience: 'Neue Mana-Nutzer',
tone: 'freundlich, konkret',
language: 'de',
targetLength: { type: 'words' as const, value: 220 },
extraInstructions: null,
useResearch: false,
},
styleId: null,
styleOverrides: null,
references: [],
currentVersionId: 'demo-draft-welcome-v1',
isFavorite: false,
publishedTo: [],
},
],
writingDraftVersions: [
{
id: 'demo-draft-welcome-v1',
draftId: 'demo-draft-welcome',
versionNumber: 1,
content:
'Writing ist dein Ghostwriter in Mana. Beschreibe kurz, was du brauchst — Thema, Zielgruppe, Länge, Ton — und wähle einen Stil. Das Modul erzeugt einen ersten Entwurf, den du Absatz für Absatz verfeinern kannst. Bereit für deinen ersten Text?',
wordCount: 41,
generationId: null,
isAiGenerated: false,
parentVersionId: null,
summary: null,
},
],
writingGenerations: [],
writingStyles: [],
};

View file

@ -0,0 +1,304 @@
<!--
BriefingForm — create a new draft or edit an existing draft's briefing.
In M2 this is the only way a draft comes into existence. M3+ will add a
one-field "what do you want to write?" shortcut that proposes a briefing,
but the full form remains the canonical source-of-truth view.
-->
<script lang="ts">
import { KIND_LABELS, TONE_PRESETS, LENGTH_PRESETS, DEFAULT_LANGUAGE } from '../constants';
import { draftsStore } from '../stores/drafts.svelte';
import type { Draft, DraftKind, DraftBriefing } from '../types';
let {
mode,
draft,
initialKind,
onclose,
oncreated,
}: {
mode: 'create' | 'edit';
draft?: Draft;
initialKind?: DraftKind;
onclose: () => void;
oncreated?: (draft: Draft) => void;
} = $props();
const KIND_ORDER: DraftKind[] = [
'blog',
'essay',
'email',
'social',
'story',
'letter',
'speech',
'cover-letter',
'product-description',
'press-release',
'bio',
'other',
];
// Form state is seeded once from `draft` / `initialKind`; it never
// re-syncs because the form gets a fresh mount every time it opens.
// The svelte-ignore comments silence the (correct-in-general) warning.
/* svelte-ignore state_referenced_locally */
let title = $state(draft?.title ?? '');
/* svelte-ignore state_referenced_locally */
let kind = $state<DraftKind>(draft?.kind ?? initialKind ?? 'blog');
/* svelte-ignore state_referenced_locally */
let topic = $state(draft?.briefing.topic ?? '');
/* svelte-ignore state_referenced_locally */
let audience = $state(draft?.briefing.audience ?? '');
/* svelte-ignore state_referenced_locally */
let tone = $state(draft?.briefing.tone ?? '');
/* svelte-ignore state_referenced_locally */
let language = $state(draft?.briefing.language ?? DEFAULT_LANGUAGE);
/* svelte-ignore state_referenced_locally */
let targetLengthValue = $state<number>(
draft?.briefing.targetLength?.value ?? LENGTH_PRESETS[draft?.kind ?? 'blog'].value
);
/* svelte-ignore state_referenced_locally */
let extraInstructions = $state(draft?.briefing.extraInstructions ?? '');
let saving = $state(false);
let error = $state<string | null>(null);
// When the user switches kind while creating a new draft, nudge the
// target length to that kind's preset so they don't have to touch it.
// On edit we leave existing custom values alone.
/* svelte-ignore state_referenced_locally */
let lastKindSeen = $state(kind);
$effect(() => {
if (mode !== 'create') return;
if (kind === lastKindSeen) return;
targetLengthValue = LENGTH_PRESETS[kind].value;
lastKindSeen = kind;
});
const isValid = $derived(title.trim().length > 0 && topic.trim().length > 0);
async function submit(ev: Event) {
ev.preventDefault();
if (!isValid || saving) return;
saving = true;
error = null;
try {
const briefing: Partial<DraftBriefing> & { topic: string } = {
topic: topic.trim(),
audience: audience.trim() || null,
tone: tone.trim() || null,
language,
targetLength: { type: 'words', value: targetLengthValue },
extraInstructions: extraInstructions.trim() || null,
};
if (mode === 'create') {
const { draft: created } = await draftsStore.createDraft({
kind,
title: title.trim(),
briefing,
});
oncreated?.(created);
} else if (draft) {
await draftsStore.updateDraft(draft.id, {
title: title.trim(),
kind,
});
await draftsStore.updateBriefing(draft.id, briefing);
}
onclose();
} catch (err) {
error = err instanceof Error ? err.message : String(err);
} finally {
saving = false;
}
}
</script>
<form class="briefing" onsubmit={submit}>
<div class="row">
<label>
<span>Titel</span>
<!-- svelte-ignore a11y_autofocus -->
<input type="text" bind:value={title} placeholder="Mein Blogpost über …" required autofocus />
</label>
<label class="kind-select">
<span>Textart</span>
<select bind:value={kind}>
{#each KIND_ORDER as k (k)}
<option value={k}>
{KIND_LABELS[k].emoji}
{KIND_LABELS[k].de}
</option>
{/each}
</select>
</label>
</div>
<label>
<span>Worum geht's? <small>(wird als Kern-Briefing an die KI übergeben)</small></span>
<textarea
bind:value={topic}
rows="3"
placeholder="z.B. 'Was Mana von klassischen Produktivitätstools unterscheidet, aus Nutzersicht'"
required
></textarea>
</label>
<div class="row">
<label>
<span>Zielgruppe</span>
<input type="text" bind:value={audience} placeholder="z.B. Gründer, Eltern, …" />
</label>
<label>
<span>Ton</span>
<select bind:value={tone}>
<option value="">— kein fester Ton —</option>
{#each TONE_PRESETS as preset (preset.id)}
<option value={preset.id}>{preset.de}</option>
{/each}
</select>
</label>
</div>
<div class="row">
<label>
<span>Länge (Wörter)</span>
<input type="number" min="20" max="20000" step="20" bind:value={targetLengthValue} />
</label>
<label>
<span>Sprache</span>
<select bind:value={language}>
<option value="de">Deutsch</option>
<option value="en">English</option>
<option value="fr">Français</option>
<option value="es">Español</option>
<option value="it">Italiano</option>
</select>
</label>
</div>
<label>
<span>Zusatzhinweise <small>(optional)</small></span>
<textarea
bind:value={extraInstructions}
rows="2"
placeholder="z.B. 'keine Buzzwords', 'mit einem Zitat beginnen', …"
></textarea>
</label>
{#if error}
<p class="error">{error}</p>
{/if}
<div class="actions">
<button type="button" class="secondary" onclick={onclose} disabled={saving}> Abbrechen </button>
<button type="submit" class="primary" disabled={!isValid || saving}>
{#if saving}
Speichert…
{:else if mode === 'create'}
Draft anlegen
{:else}
Speichern
{/if}
</button>
</div>
</form>
<style>
.briefing {
display: flex;
flex-direction: column;
gap: 0.9rem;
padding: 1rem 1.25rem;
}
.row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
}
label {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.85rem;
}
label > span {
color: var(--color-text-muted, rgba(0, 0, 0, 0.55));
}
small {
font-weight: normal;
opacity: 0.7;
}
input,
select,
textarea {
padding: 0.5rem 0.7rem;
border-radius: 0.5rem;
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.1));
background: var(--color-surface, transparent);
font: inherit;
color: inherit;
}
textarea {
resize: vertical;
min-height: 3rem;
}
input:focus,
select:focus,
textarea:focus {
outline: 2px solid #0ea5e9;
outline-offset: 1px;
border-color: transparent;
}
.kind-select {
max-width: 220px;
}
.actions {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
margin-top: 0.25rem;
}
button {
padding: 0.5rem 1rem;
border-radius: 0.55rem;
font: inherit;
font-weight: 500;
cursor: pointer;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.primary {
background: #0ea5e9;
color: white;
border: 1px solid #0ea5e9;
}
.primary:hover:not(:disabled) {
background: #0284c7;
border-color: #0284c7;
}
.secondary {
background: transparent;
color: var(--color-text, inherit);
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.1));
}
.secondary:hover:not(:disabled) {
background: var(--color-surface, rgba(0, 0, 0, 0.04));
}
.error {
color: #ef4444;
font-size: 0.85rem;
margin: 0;
}
@media (max-width: 600px) {
.row {
grid-template-columns: 1fr;
}
.kind-select {
max-width: none;
}
}
</style>

View file

@ -0,0 +1,140 @@
<script lang="ts">
import StatusBadge from './StatusBadge.svelte';
import { KIND_LABELS } from '../constants';
import type { Draft, DraftVersion } from '../types';
let {
draft,
currentVersion,
onopen,
}: {
draft: Draft;
currentVersion: DraftVersion | null;
onopen: (draft: Draft) => void;
} = $props();
const kind = $derived(KIND_LABELS[draft.kind]);
const preview = $derived.by(() => {
const text = (currentVersion?.content ?? '').trim();
if (!text) return draft.briefing.topic;
return text.length > 160 ? text.slice(0, 160) + '…' : text;
});
const updatedLabel = $derived(formatRelative(draft.updatedAt));
function formatRelative(iso: string): string {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '';
const diff = Date.now() - d.getTime();
const mins = Math.round(diff / 60000);
if (mins < 1) return 'gerade eben';
if (mins < 60) return `vor ${mins} min`;
const hrs = Math.round(mins / 60);
if (hrs < 24) return `vor ${hrs} h`;
const days = Math.round(hrs / 24);
if (days < 30) return `vor ${days} d`;
return d.toLocaleDateString('de-DE');
}
function open() {
onopen(draft);
}
</script>
<button type="button" class="card" onclick={open}>
<header>
<span class="kind" title={kind.de}>
<span aria-hidden="true">{kind.emoji}</span>
{kind.de}
</span>
{#if draft.isFavorite}
<span class="fav" aria-label="Favorit"></span>
{/if}
</header>
<h3 class="title">{draft.title || draft.briefing.topic || 'Unbenannt'}</h3>
<p class="preview">{preview}</p>
<footer>
<StatusBadge status={draft.status} />
<span class="words">
{currentVersion?.wordCount ?? 0} Wörter
</span>
<span class="updated">{updatedLabel}</span>
</footer>
</button>
<style>
.card {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.9rem 1rem;
border-radius: 0.75rem;
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.08));
background: var(--color-surface, rgba(255, 255, 255, 0.04));
cursor: pointer;
transition:
background 0.15s ease,
border-color 0.15s ease;
text-align: left;
font: inherit;
color: inherit;
width: 100%;
}
.card:hover,
.card:focus-visible {
background: var(--color-surface-hover, rgba(0, 0, 0, 0.04));
border-color: color-mix(in srgb, #0ea5e9 40%, transparent);
}
.card:focus-visible {
outline: 2px solid #0ea5e9;
outline-offset: 1px;
}
header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
}
.kind {
display: inline-flex;
align-items: center;
gap: 0.3rem;
font-size: 0.75rem;
color: var(--color-text-muted, rgba(0, 0, 0, 0.55));
text-transform: uppercase;
letter-spacing: 0.04em;
}
.fav {
color: #f59e0b;
}
.title {
font-size: 1rem;
margin: 0;
line-height: 1.3;
}
.preview {
margin: 0;
font-size: 0.85rem;
line-height: 1.4;
color: var(--color-text-muted, rgba(0, 0, 0, 0.55));
display: -webkit-box;
-webkit-line-clamp: 3;
line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
footer {
display: flex;
align-items: center;
gap: 0.5rem;
justify-content: space-between;
font-size: 0.75rem;
color: var(--color-text-muted, rgba(0, 0, 0, 0.55));
flex-wrap: wrap;
}
.words {
white-space: nowrap;
}
.updated {
margin-left: auto;
}
</style>

View file

@ -0,0 +1,106 @@
<script lang="ts">
import { KIND_LABELS } from '../constants';
import type { DraftKind } from '../types';
let {
active,
counts,
onselect,
}: {
active: DraftKind | 'all';
counts: Record<DraftKind, number>;
onselect: (kind: DraftKind | 'all') => void;
} = $props();
const ORDER: DraftKind[] = [
'blog',
'essay',
'email',
'social',
'story',
'letter',
'speech',
'cover-letter',
'product-description',
'press-release',
'bio',
'other',
];
const total = $derived(ORDER.reduce((s, k) => s + counts[k], 0));
</script>
<div class="tabs" role="tablist">
<button
type="button"
class="tab"
class:active={active === 'all'}
onclick={() => onselect('all')}
role="tab"
aria-selected={active === 'all'}
>
Alle <span class="count">{total}</span>
</button>
{#each ORDER as kind (kind)}
{#if counts[kind] > 0 || active === kind}
<button
type="button"
class="tab"
class:active={active === kind}
onclick={() => onselect(kind)}
role="tab"
aria-selected={active === kind}
>
<span class="emoji" aria-hidden="true">{KIND_LABELS[kind].emoji}</span>
{KIND_LABELS[kind].de}
<span class="count">{counts[kind]}</span>
</button>
{/if}
{/each}
</div>
<style>
.tabs {
display: flex;
gap: 0.25rem;
overflow-x: auto;
padding-bottom: 0.25rem;
}
.tab {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.45rem 0.85rem;
border-radius: 0.6rem;
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.08));
background: transparent;
cursor: pointer;
font-size: 0.9rem;
color: var(--color-text, inherit);
white-space: nowrap;
transition:
background 0.15s ease,
border-color 0.15s ease;
}
.tab:hover {
background: var(--color-surface, rgba(0, 0, 0, 0.04));
}
.tab.active {
background: color-mix(in srgb, #0ea5e9 12%, transparent);
border-color: #0ea5e9;
color: #0ea5e9;
font-weight: 500;
}
.emoji {
font-size: 1rem;
}
.count {
font-size: 0.75rem;
opacity: 0.7;
background: var(--color-surface-muted, rgba(0, 0, 0, 0.05));
padding: 0.05rem 0.4rem;
border-radius: 999px;
}
.tab.active .count {
background: color-mix(in srgb, #0ea5e9 20%, transparent);
}
</style>

View file

@ -0,0 +1,34 @@
<script lang="ts">
import { STATUS_LABELS, STATUS_COLORS } from '../constants';
import type { DraftStatus } from '../types';
let { status }: { status: DraftStatus } = $props();
const color = $derived(STATUS_COLORS[status]);
const label = $derived(STATUS_LABELS[status].de);
</script>
<span class="badge" style:--badge={color}>
<span class="dot" aria-hidden="true"></span>
{label}
</span>
<style>
.badge {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.15rem 0.55rem;
border-radius: 999px;
font-size: 0.75rem;
color: var(--badge);
background: color-mix(in srgb, var(--badge) 12%, transparent);
border: 1px solid color-mix(in srgb, var(--badge) 25%, transparent);
white-space: nowrap;
}
.dot {
width: 0.4rem;
height: 0.4rem;
border-radius: 999px;
background: var(--badge);
}
</style>

View file

@ -0,0 +1,56 @@
<script lang="ts">
import { STATUS_LABELS } from '../constants';
import type { DraftStatus } from '../types';
let {
active,
onselect,
}: {
active: DraftStatus | null;
onselect: (status: DraftStatus | null) => void;
} = $props();
const ORDER: DraftStatus[] = ['draft', 'refining', 'complete', 'published'];
</script>
<div class="chips" role="toolbar" aria-label="Status-Filter">
<button type="button" class="chip" class:active={active === null} onclick={() => onselect(null)}>
Alle
</button>
{#each ORDER as status (status)}
<button
type="button"
class="chip"
class:active={active === status}
onclick={() => onselect(active === status ? null : status)}
>
{STATUS_LABELS[status].de}
</button>
{/each}
</div>
<style>
.chips {
display: inline-flex;
gap: 0.25rem;
flex-wrap: wrap;
}
.chip {
padding: 0.3rem 0.65rem;
border-radius: 999px;
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.08));
background: transparent;
cursor: pointer;
font-size: 0.8rem;
color: var(--color-text-muted, rgba(0, 0, 0, 0.55));
}
.chip:hover {
background: var(--color-surface, rgba(0, 0, 0, 0.04));
}
.chip.active {
background: color-mix(in srgb, #0ea5e9 10%, transparent);
border-color: #0ea5e9;
color: #0ea5e9;
font-weight: 500;
}
</style>

View file

@ -0,0 +1,130 @@
<!--
Editable textarea bound to the draft's current version.
Saves with a short debounce so every keystroke doesn't hit Dexie; on
blur it force-flushes any pending edit. The word-count is computed
locally for live feedback and re-derived on save.
-->
<script lang="ts">
import { draftsStore } from '../stores/drafts.svelte';
import type { DraftVersion } from '../types';
let {
version,
targetWords = null,
onchange,
}: {
version: DraftVersion;
targetWords?: number | null;
onchange?: (content: string) => void;
} = $props();
// Snapshot id so we reset local state when the active version flips
// (e.g. after "Restore this version" on the history panel).
/* svelte-ignore state_referenced_locally */
let lastVersionId = $state<string>(version.id);
/* svelte-ignore state_referenced_locally */
let text = $state<string>(version.content);
$effect(() => {
if (version.id !== lastVersionId) {
lastVersionId = version.id;
text = version.content;
}
});
const wordCount = $derived.by(() => {
const trimmed = text.trim();
return trimmed ? trimmed.split(/\s+/).length : 0;
});
let saveTimer: ReturnType<typeof setTimeout> | null = null;
let pending = $state(false);
function queueSave(next: string) {
if (saveTimer) clearTimeout(saveTimer);
pending = true;
saveTimer = setTimeout(async () => {
saveTimer = null;
await draftsStore.updateVersionContent(version.id, next);
pending = false;
}, 500);
}
function handleInput() {
onchange?.(text);
queueSave(text);
}
async function flush() {
if (saveTimer) {
clearTimeout(saveTimer);
saveTimer = null;
await draftsStore.updateVersionContent(version.id, text);
pending = false;
}
}
</script>
<div class="editor">
<textarea
bind:value={text}
oninput={handleInput}
onblur={flush}
placeholder="Hier schreibst du (oder die KI). Leer lassen für Generate."
spellcheck="true"
></textarea>
<footer>
<span>
{wordCount} Wörter{#if targetWords}
<span class="target"> / Ziel ~{targetWords}</span>
{/if}
</span>
<span class="status" aria-live="polite">
{#if pending}
Speichert…
{:else}
Gespeichert
{/if}
</span>
</footer>
</div>
<style>
.editor {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
textarea {
width: 100%;
min-height: 50vh;
padding: 1rem 1.25rem;
border-radius: 0.75rem;
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.08));
background: var(--color-surface, rgba(255, 255, 255, 0.04));
font-family: ui-serif, Georgia, Cambria, 'Times New Roman', Times, serif;
font-size: 1.05rem;
line-height: 1.6;
color: inherit;
resize: vertical;
}
textarea:focus {
outline: 2px solid #0ea5e9;
outline-offset: 1px;
border-color: transparent;
}
footer {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.75rem;
font-size: 0.75rem;
color: var(--color-text-muted, rgba(0, 0, 0, 0.55));
}
.target {
opacity: 0.7;
}
.status {
font-style: italic;
}
</style>

View file

@ -0,0 +1,136 @@
<!--
List of all LocalDraftVersions for a draft. Highlights the current one;
"Wiederherstellen" flips the draft's `currentVersionId` back via the
store. Versions are immutable snapshots so Restore is a pointer change,
not a destructive revert.
-->
<script lang="ts">
import { draftsStore } from '../stores/drafts.svelte';
import type { DraftVersion } from '../types';
let {
versions,
currentVersionId,
draftId,
}: {
versions: DraftVersion[];
currentVersionId: string | null;
draftId: string;
} = $props();
// Newest on top.
const sorted = $derived([...versions].sort((a, b) => b.versionNumber - a.versionNumber));
async function restore(versionId: string) {
await draftsStore.restoreVersion(draftId, versionId);
}
function formatDate(iso: string): string {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '';
return d.toLocaleString('de-DE', {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
</script>
<ul class="history">
{#each sorted as version (version.id)}
{@const isCurrent = version.id === currentVersionId}
<li class="version" class:current={isCurrent}>
<div class="meta">
<strong>v{version.versionNumber}</strong>
{#if version.isAiGenerated}
<span class="tag ai" title="KI-generiert">KI</span>
{/if}
{#if isCurrent}
<span class="tag current">Aktiv</span>
{/if}
</div>
<div class="stats">
<span>{version.wordCount} Wörter</span>
<span class="date">{formatDate(version.createdAt)}</span>
</div>
{#if version.summary}
<p class="summary">{version.summary}</p>
{/if}
{#if !isCurrent}
<button type="button" class="restore" onclick={() => restore(version.id)}>
Wiederherstellen
</button>
{/if}
</li>
{/each}
</ul>
<style>
.history {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.version {
padding: 0.6rem 0.75rem;
border-radius: 0.55rem;
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.08));
background: var(--color-surface, rgba(255, 255, 255, 0.04));
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.version.current {
border-color: #0ea5e9;
background: color-mix(in srgb, #0ea5e9 6%, transparent);
}
.meta {
display: flex;
align-items: center;
gap: 0.4rem;
}
.tag {
font-size: 0.65rem;
padding: 0.05rem 0.4rem;
border-radius: 999px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.tag.ai {
background: color-mix(in srgb, #a855f7 15%, transparent);
color: #a855f7;
}
.tag.current {
background: color-mix(in srgb, #0ea5e9 15%, transparent);
color: #0ea5e9;
}
.stats {
display: flex;
justify-content: space-between;
font-size: 0.75rem;
color: var(--color-text-muted, rgba(0, 0, 0, 0.55));
}
.summary {
margin: 0;
font-size: 0.8rem;
color: var(--color-text-muted, rgba(0, 0, 0, 0.55));
}
.restore {
align-self: flex-start;
padding: 0.25rem 0.6rem;
font-size: 0.75rem;
border-radius: 0.4rem;
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.1));
background: transparent;
cursor: pointer;
color: inherit;
font: inherit;
}
.restore:hover {
background: var(--color-surface-hover, rgba(0, 0, 0, 0.05));
}
</style>

View file

@ -0,0 +1,85 @@
import type { DraftKind, DraftStatus, GenerationStatus, StyleSource } from './types';
export const KIND_LABELS: Record<DraftKind, { de: string; en: string; emoji: string }> = {
blog: { de: 'Blog', en: 'Blog', emoji: '📝' },
essay: { de: 'Essay', en: 'Essay', emoji: '📄' },
email: { de: 'E-Mail', en: 'Email', emoji: '✉️' },
social: { de: 'Social', en: 'Social', emoji: '💬' },
story: { de: 'Story', en: 'Story', emoji: '📖' },
letter: { de: 'Brief', en: 'Letter', emoji: '💌' },
speech: { de: 'Rede', en: 'Speech', emoji: '🎤' },
'cover-letter': { de: 'Bewerbung', en: 'Cover letter', emoji: '💼' },
'product-description': { de: 'Produkttext', en: 'Product', emoji: '🛍️' },
'press-release': { de: 'Pressetext', en: 'Press', emoji: '📰' },
bio: { de: 'Bio', en: 'Bio', emoji: '👤' },
other: { de: 'Sonstiges', en: 'Other', emoji: '✏️' },
};
export const STATUS_LABELS: Record<DraftStatus, { de: string; en: string }> = {
draft: { de: 'Entwurf', en: 'Draft' },
refining: { de: 'In Überarbeitung', en: 'Refining' },
complete: { de: 'Fertig', en: 'Complete' },
published: { de: 'Veröffentlicht', en: 'Published' },
};
export const STATUS_COLORS: Record<DraftStatus, string> = {
draft: '#64748b',
refining: '#3b82f6',
complete: '#22c55e',
published: '#a855f7',
};
export const GENERATION_STATUS_LABELS: Record<GenerationStatus, { de: string; en: string }> = {
queued: { de: 'In Warteschlange', en: 'Queued' },
running: { de: 'Läuft', en: 'Running' },
succeeded: { de: 'Fertig', en: 'Succeeded' },
failed: { de: 'Fehlgeschlagen', en: 'Failed' },
cancelled: { de: 'Abgebrochen', en: 'Cancelled' },
};
export const STYLE_SOURCE_LABELS: Record<StyleSource, { de: string; en: string }> = {
preset: { de: 'Vorlage', en: 'Preset' },
'custom-description': { de: 'Eigene Beschreibung', en: 'Custom description' },
'sample-trained': { de: 'Aus Textproben trainiert', en: 'Trained from samples' },
'self-trained': { de: 'Schreibe wie ich', en: 'Write like me' },
};
/** Default word-count targets per kind — used in briefing defaults. */
export const LENGTH_PRESETS: Record<DraftKind, { type: 'words'; value: number }> = {
blog: { type: 'words', value: 800 },
essay: { type: 'words', value: 1500 },
email: { type: 'words', value: 180 },
social: { type: 'words', value: 80 },
story: { type: 'words', value: 1200 },
letter: { type: 'words', value: 350 },
speech: { type: 'words', value: 600 },
'cover-letter': { type: 'words', value: 400 },
'product-description': { type: 'words', value: 220 },
'press-release': { type: 'words', value: 450 },
bio: { type: 'words', value: 120 },
other: { type: 'words', value: 500 },
};
export const TONE_PRESETS: ReadonlyArray<{ id: string; de: string; en: string }> = [
{ id: 'neutral', de: 'Neutral', en: 'Neutral' },
{ id: 'warm', de: 'Warm', en: 'Warm' },
{ id: 'formal', de: 'Formell', en: 'Formal' },
{ id: 'casual', de: 'Locker', en: 'Casual' },
{ id: 'professional', de: 'Professionell', en: 'Professional' },
{ id: 'playful', de: 'Verspielt', en: 'Playful' },
{ id: 'urgent', de: 'Dringlich', en: 'Urgent' },
{ id: 'empathetic', de: 'Einfühlsam', en: 'Empathetic' },
{ id: 'assertive', de: 'Selbstbewusst', en: 'Assertive' },
{ id: 'humorous', de: 'Humorvoll', en: 'Humorous' },
];
export const DEFAULT_LANGUAGE = 'de';
/** Kinds for which the runner should produce an outline before the full draft. */
export const AUTO_OUTLINE_KINDS: ReadonlyArray<DraftKind> = [
'blog',
'essay',
'speech',
'cover-letter',
'story',
];

View file

@ -0,0 +1,86 @@
/**
* Writing module barrel exports.
*/
export { draftsStore } from './stores/drafts.svelte';
export type { CreateDraftInput, UpdateDraftPatch } from './stores/drafts.svelte';
export { stylesStore } from './stores/styles.svelte';
export type { CreateStyleInput, UpdateStylePatch } from './stores/styles.svelte';
export {
useAllDrafts,
useDraft,
useVersionsForDraft,
useVersion,
useCurrentVersionForDraft,
useGenerationsForDraft,
useAllStyles,
toDraft,
toDraftVersion,
toGeneration,
toWritingStyle,
filterByKind,
filterByStatus,
searchDrafts,
sortByUpdated,
groupByKind,
computeStats,
} from './queries';
export type { WritingStats } from './queries';
export {
draftTable,
draftVersionTable,
generationTable,
writingStyleTable,
WRITING_GUEST_SEED,
} from './collections';
export {
KIND_LABELS,
STATUS_LABELS,
STATUS_COLORS,
GENERATION_STATUS_LABELS,
STYLE_SOURCE_LABELS,
LENGTH_PRESETS,
TONE_PRESETS,
DEFAULT_LANGUAGE,
AUTO_OUTLINE_KINDS,
} from './constants';
export { STYLE_PRESETS, getStylePreset } from './presets/styles';
export type { StylePreset } from './presets/styles';
export type {
// Enums
DraftKind,
DraftStatus,
DraftLengthUnit,
GenerationStatus,
GenerationKind,
GenerationProvider,
StyleSource,
DraftReferenceKind,
DraftPublishModule,
// Sub-objects
DraftBriefing,
DraftStyleOverrides,
DraftReference,
DraftPublishTarget,
DraftGenerationParams,
DraftSelection,
DraftTokenUsage,
StyleSample,
StyleExtractedPrinciples,
// Dexie records
LocalDraft,
LocalDraftVersion,
LocalGeneration,
LocalWritingStyle,
// Domain types
Draft,
DraftVersion,
Generation,
WritingStyle,
} from './types';

View file

@ -0,0 +1,11 @@
import type { ModuleConfig } from '$lib/data/module-registry';
export const writingModuleConfig: ModuleConfig = {
appId: 'writing',
tables: [
{ name: 'writingDrafts' },
{ name: 'writingDraftVersions' },
{ name: 'writingGenerations' },
{ name: 'writingStyles' },
],
};

View file

@ -0,0 +1,175 @@
/**
* Preset style definitions the "ready-made" styles a user can pick from
* in the briefing without having to train anything. Each preset ships with
* a German + English description plus extracted principles so the prompt
* builder can treat presets and custom-trained styles identically.
*
* Keeping presets in code (not in Dexie) means they're versioned with the
* app, never need syncing, and can be edited in a PR. A user's "custom"
* style is a row in `writingStyles`; a preset is referenced by its `id`
* via `LocalDraft.styleId`. When a user "favourites" a preset we still
* write a row (source='preset', presetId=<id>) so the picker can show it.
*/
import type { StyleExtractedPrinciples } from '../types';
export interface StylePreset {
id: string;
name: { de: string; en: string };
description: { de: string; en: string };
principles: StyleExtractedPrinciples;
}
const now = '2026-04-24T00:00:00.000Z';
export const STYLE_PRESETS: ReadonlyArray<StylePreset> = [
{
id: 'academic',
name: { de: 'Akademisch', en: 'Academic' },
description: {
de: 'Dicht, passive Voice erlaubt, Zitate, Konjunktiv — für wissenschaftliche Texte.',
en: 'Dense, passive voice allowed, citations, subjunctive — for scholarly prose.',
},
principles: {
toneTraits: ['formal', 'precise', 'hedged'],
sentenceLengthAvg: 28,
vocabulary: ['furthermore', 'notwithstanding', 'consequently'],
examples: [],
rawAnalysis:
'Passive constructions allowed. Qualifier-heavy ("it may be argued that…"). References by author + year. No contractions. No rhetorical questions.',
extractedAt: now,
},
},
{
id: 'casual-blog',
name: { de: 'Casual Blog', en: 'Casual blog' },
description: {
de: 'Du-Ansprache, kurze Absätze, rhetorische Fragen — persönlicher Blog-Ton.',
en: 'Second-person, short paragraphs, rhetorical questions — personal blog voice.',
},
principles: {
toneTraits: ['conversational', 'direct', 'warm'],
sentenceLengthAvg: 16,
examples: [],
rawAnalysis:
'Address the reader as "du" / "you". Contractions fine. 23-sentence paragraphs. Occasional rhetorical question to punctuate sections.',
extractedAt: now,
},
},
{
id: 'linkedin',
name: { de: 'LinkedIn-Post', en: 'LinkedIn post' },
description: {
de: 'Hook in Zeile 1, 1-Satz-Absätze, sparsamer Emoji-Einsatz, Call-to-Action am Ende.',
en: 'Hook on line 1, one-sentence paragraphs, sparing emoji, CTA at the end.',
},
principles: {
toneTraits: ['hook-first', 'confident', 'accessible'],
sentenceLengthAvg: 12,
examples: [],
rawAnalysis:
'Line 1 must hook. Short paragraphs (often one sentence each). Bullet-style mid-post lists are OK. End with a question or explicit CTA. Emoji only to structure, not decorate.',
extractedAt: now,
},
},
{
id: 'twitter-thread',
name: { de: 'Twitter/X-Thread', en: 'Twitter/X thread' },
description: {
de: 'Nummerierte Tweets ≤280 Zeichen, Cliffhanger zwischen den Posts.',
en: 'Numbered tweets ≤280 chars, cliffhanger between posts.',
},
principles: {
toneTraits: ['punchy', 'cliffhanger-driven'],
sentenceLengthAvg: 10,
examples: [],
rawAnalysis:
'Split into numbered tweets (1/, 2/, …). Every tweet ≤280 chars. Each tweet should reward a stop, and incentivise a continue. Open with a strong claim.',
extractedAt: now,
},
},
{
id: 'hemingway',
name: { de: 'Hemingway', en: 'Hemingway' },
description: {
de: 'Deklarativ, kurze Sätze, minimale Adjektive — nüchtern und klar.',
en: 'Declarative, short sentences, minimal adjectives — lean and clear.',
},
principles: {
toneTraits: ['declarative', 'lean', 'concrete'],
sentenceLengthAvg: 9,
examples: [],
rawAnalysis:
'Mostly simple declarative sentences. Adverbs used sparingly. Concrete nouns over abstract. No meta-commentary. Show, do not tell.',
extractedAt: now,
},
},
{
id: 'news',
name: { de: 'Nachrichtlich', en: 'Newswire' },
description: {
de: 'Inverted Pyramid, nüchtern, keine Meinung — wie eine Nachrichtenagentur.',
en: 'Inverted pyramid, neutral, opinion-free — newswire style.',
},
principles: {
toneTraits: ['neutral', 'factual', 'inverted-pyramid'],
sentenceLengthAvg: 18,
examples: [],
rawAnalysis:
'Lead with the 5 Ws. Most important fact first, background last. Attribute every claim. No first-person. No opinion.',
extractedAt: now,
},
},
{
id: 'listicle',
name: { de: 'Listicle', en: 'Listicle' },
description: {
de: 'Nummerierte Liste mit überspitzten Einleitungen — Buzzfeed-Format.',
en: 'Numbered list with punchy intros — Buzzfeed-style.',
},
principles: {
toneTraits: ['punchy', 'listicle-structured', 'irreverent'],
sentenceLengthAvg: 14,
examples: [],
rawAnalysis:
'Numbered headings (e.g. "1. The thing that changed everything"). 24 sentences per item. Short opener, strong closing sentence per item. OK to use surprise and hyperbole.',
extractedAt: now,
},
},
{
id: 'pitch',
name: { de: 'Pitch / Sales', en: 'Pitch / sales' },
description: {
de: 'Problem → Agitation → Solution — Verkaufstext mit klarer Struktur.',
en: 'Problem → Agitation → Solution — sales writing with a clear arc.',
},
principles: {
toneTraits: ['persuasive', 'outcome-focused'],
sentenceLengthAvg: 15,
examples: [],
rawAnalysis:
"Open with the reader's problem. Agitate (cost of inaction). Introduce solution. Close with specific next step. Short paragraphs, concrete benefits, social proof when available.",
extractedAt: now,
},
},
{
id: 'memoir',
name: { de: 'Memoir', en: 'Memoir' },
description: {
de: '1. Person, sensorisch, Szenen statt Zusammenfassungen — persönlicher Erinnerungsstil.',
en: 'First-person, sensory, scenes over summary — personal memoir voice.',
},
principles: {
toneTraits: ['introspective', 'sensory', 'scene-driven'],
sentenceLengthAvg: 17,
examples: [],
rawAnalysis:
'First-person throughout. Anchor in time + place + body. Prefer scenes ("It was the Tuesday after…") to summaries. Interior thought marked by rhythm change rather than italics.',
extractedAt: now,
},
},
];
export function getStylePreset(id: string): StylePreset | undefined {
return STYLE_PRESETS.find((p) => p.id === id);
}

View file

@ -0,0 +1,267 @@
/**
* Reactive queries + pure helpers for the Writing module.
*/
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
import { decryptRecords } from '$lib/data/crypto';
import { db } from '$lib/data/database';
import { scopedForModule } from '$lib/data/scope';
import type {
LocalDraft,
LocalDraftVersion,
LocalGeneration,
LocalWritingStyle,
Draft,
DraftVersion,
Generation,
WritingStyle,
DraftKind,
DraftStatus,
} from './types';
// ─── Type Converters ─────────────────────────────────────
export function toDraft(local: LocalDraft): Draft {
const now = new Date().toISOString();
return {
id: local.id,
kind: local.kind,
status: local.status,
title: local.title,
briefing: local.briefing,
styleId: local.styleId ?? null,
styleOverrides: local.styleOverrides ?? null,
references: local.references ?? [],
currentVersionId: local.currentVersionId ?? null,
publishedTo: local.publishedTo ?? [],
isFavorite: local.isFavorite ?? false,
visibility: local.visibility ?? 'space',
createdAt: local.createdAt ?? now,
updatedAt: local.updatedAt ?? now,
};
}
export function toDraftVersion(local: LocalDraftVersion): DraftVersion {
const now = new Date().toISOString();
return {
id: local.id,
draftId: local.draftId,
versionNumber: local.versionNumber,
content: local.content,
wordCount: local.wordCount ?? 0,
generationId: local.generationId ?? null,
isAiGenerated: local.isAiGenerated ?? false,
parentVersionId: local.parentVersionId ?? null,
summary: local.summary ?? null,
createdAt: local.createdAt ?? now,
};
}
export function toGeneration(local: LocalGeneration): Generation {
const now = new Date().toISOString();
return {
id: local.id,
draftId: local.draftId,
kind: local.kind,
status: local.status,
prompt: local.prompt,
provider: local.provider,
model: local.model ?? null,
params: local.params ?? null,
inputSelection: local.inputSelection ?? null,
output: local.output ?? null,
outputVersionId: local.outputVersionId ?? null,
startedAt: local.startedAt ?? null,
completedAt: local.completedAt ?? null,
durationMs: local.durationMs ?? null,
tokenUsage: local.tokenUsage ?? null,
error: local.error ?? null,
missionId: local.missionId ?? null,
createdAt: local.createdAt ?? now,
};
}
export function toWritingStyle(local: LocalWritingStyle): WritingStyle {
const now = new Date().toISOString();
return {
id: local.id,
name: local.name,
description: local.description,
source: local.source,
presetId: local.presetId ?? null,
samples: local.samples ?? [],
extractedPrinciples: local.extractedPrinciples ?? null,
isSpaceDefault: local.isSpaceDefault ?? false,
isFavorite: local.isFavorite ?? false,
createdAt: local.createdAt ?? now,
updatedAt: local.updatedAt ?? now,
};
}
// ─── Live Queries ─────────────────────────────────────────
export function useAllDrafts() {
return useLiveQueryWithDefault(async () => {
const locals = await scopedForModule<LocalDraft, string>('writing', 'writingDrafts').toArray();
const visible = locals.filter((d) => !d.deletedAt);
const decrypted = await decryptRecords('writingDrafts', visible);
return decrypted.map(toDraft);
}, [] as Draft[]);
}
export function useDraft(id: string) {
return useLiveQueryWithDefault(
async () => {
if (!id) return null;
const row = await db.table<LocalDraft>('writingDrafts').get(id);
if (!row || row.deletedAt) return null;
const [decrypted] = await decryptRecords('writingDrafts', [row]);
return decrypted ? toDraft(decrypted) : null;
},
null as Draft | null
);
}
export function useVersionsForDraft(draftId: string) {
return useLiveQueryWithDefault(async () => {
if (!draftId) return [] as DraftVersion[];
const rows = await db
.table<LocalDraftVersion>('writingDraftVersions')
.where('draftId')
.equals(draftId)
.toArray();
const visible = rows.filter((v) => !v.deletedAt);
const decrypted = await decryptRecords('writingDraftVersions', visible);
return decrypted.map(toDraftVersion).sort((a, b) => a.versionNumber - b.versionNumber);
}, [] as DraftVersion[]);
}
export function useVersion(versionId: string) {
return useLiveQueryWithDefault(
async () => {
if (!versionId) return null;
const row = await db.table<LocalDraftVersion>('writingDraftVersions').get(versionId);
if (!row || row.deletedAt) return null;
const [decrypted] = await decryptRecords('writingDraftVersions', [row]);
return decrypted ? toDraftVersion(decrypted) : null;
},
null as DraftVersion | null
);
}
/**
* Live-track a draft's *current* version by following the pointer on the
* draft row. Re-runs whenever either the draft or the version table
* changes so flipping `currentVersionId` via `restoreVersion` shows up
* automatically in the editor.
*/
export function useCurrentVersionForDraft(draftId: string) {
return useLiveQueryWithDefault(
async () => {
if (!draftId) return null;
const draftRow = await db.table<LocalDraft>('writingDrafts').get(draftId);
if (!draftRow || draftRow.deletedAt || !draftRow.currentVersionId) return null;
const versionRow = await db
.table<LocalDraftVersion>('writingDraftVersions')
.get(draftRow.currentVersionId);
if (!versionRow || versionRow.deletedAt) return null;
const [decrypted] = await decryptRecords('writingDraftVersions', [versionRow]);
return decrypted ? toDraftVersion(decrypted) : null;
},
null as DraftVersion | null
);
}
export function useGenerationsForDraft(draftId: string) {
return useLiveQueryWithDefault(async () => {
if (!draftId) return [] as Generation[];
const rows = await db
.table<LocalGeneration>('writingGenerations')
.where('draftId')
.equals(draftId)
.toArray();
const visible = rows.filter((g) => !g.deletedAt);
const decrypted = await decryptRecords('writingGenerations', visible);
return decrypted.map(toGeneration).sort((a, b) => b.createdAt.localeCompare(a.createdAt));
}, [] as Generation[]);
}
export function useAllStyles() {
return useLiveQueryWithDefault(async () => {
const rows = await scopedForModule<LocalWritingStyle, string>(
'writing',
'writingStyles'
).toArray();
const visible = rows.filter((s) => !s.deletedAt);
const decrypted = await decryptRecords('writingStyles', visible);
return decrypted.map(toWritingStyle);
}, [] as WritingStyle[]);
}
// ─── Pure Helpers ─────────────────────────────────────────
export function filterByKind(drafts: Draft[], kind: DraftKind): Draft[] {
return drafts.filter((d) => d.kind === kind);
}
export function filterByStatus(drafts: Draft[], status: DraftStatus): Draft[] {
return drafts.filter((d) => d.status === status);
}
export function searchDrafts(drafts: Draft[], query: string): Draft[] {
const lower = query.toLowerCase();
return drafts.filter(
(d) => d.title.toLowerCase().includes(lower) || d.briefing.topic.toLowerCase().includes(lower)
);
}
export function sortByUpdated(drafts: Draft[]): Draft[] {
return [...drafts].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
}
export function groupByKind(drafts: Draft[]): Record<DraftKind, Draft[]> {
const out: Record<DraftKind, Draft[]> = {
blog: [],
essay: [],
email: [],
social: [],
story: [],
letter: [],
speech: [],
'cover-letter': [],
'product-description': [],
'press-release': [],
bio: [],
other: [],
};
for (const d of drafts) out[d.kind].push(d);
return out;
}
export interface WritingStats {
totalDrafts: number;
byStatus: Record<DraftStatus, number>;
totalWords: number;
currentlyActive: number;
}
export function computeStats(drafts: Draft[], versions: DraftVersion[]): WritingStats {
const byStatus: Record<DraftStatus, number> = {
draft: 0,
refining: 0,
complete: 0,
published: 0,
};
let currentlyActive = 0;
for (const d of drafts) {
byStatus[d.status]++;
if (d.status === 'draft' || d.status === 'refining') currentlyActive++;
}
const totalWords = versions.reduce((acc, v) => acc + v.wordCount, 0);
return {
totalDrafts: drafts.length,
byStatus,
totalWords,
currentlyActive,
};
}

View file

@ -0,0 +1,312 @@
/**
* Writing drafts store mutation-only service for drafts + draft versions.
*
* Creating a draft always creates an initial empty version (v1) in the same
* transaction so the UI can always render a "current version" without
* having to handle a null/missing body. Live typing mutates the current
* version in-place; `createCheckpointVersion` snapshots the current content
* as a new numbered version. `restoreVersion` sets `currentVersionId` to an
* older version without destroying history.
*
* Full-regeneration flows (M3+) will write new versions via the generations
* store and then call `pointToVersion` never append to an existing version.
*/
import { encryptRecord } from '$lib/data/crypto';
import { emitDomainEvent } from '$lib/data/events';
import { getActiveSpace } from '$lib/data/scope';
import { getEffectiveUserId } from '$lib/data/current-user';
import {
defaultVisibilityFor,
generateUnlistedToken,
type VisibilityLevel,
} from '@mana/shared-privacy';
import { draftTable, draftVersionTable } from '../collections';
import { toDraft, toDraftVersion } from '../queries';
import { LENGTH_PRESETS, DEFAULT_LANGUAGE } from '../constants';
import type {
LocalDraft,
LocalDraftVersion,
DraftKind,
DraftStatus,
DraftBriefing,
DraftReference,
DraftStyleOverrides,
} from '../types';
function wordCountOf(text: string): number {
const trimmed = text.trim();
if (!trimmed) return 0;
return trimmed.split(/\s+/).length;
}
function defaultBriefing(kind: DraftKind, topic: string): DraftBriefing {
return {
topic,
audience: null,
tone: null,
language: DEFAULT_LANGUAGE,
targetLength: LENGTH_PRESETS[kind],
extraInstructions: null,
useResearch: false,
};
}
export interface CreateDraftInput {
kind: DraftKind;
title: string;
briefing?: Partial<DraftBriefing> & { topic: string };
styleId?: string | null;
references?: DraftReference[];
initialContent?: string;
status?: DraftStatus;
isFavorite?: boolean;
}
export type UpdateDraftPatch = Partial<
Pick<
LocalDraft,
| 'title'
| 'kind'
| 'status'
| 'briefing'
| 'styleId'
| 'styleOverrides'
| 'references'
| 'isFavorite'
>
>;
export const draftsStore = {
/**
* Create a draft + its first (empty or pre-filled) version atomically.
* Returns the plaintext Draft snapshot + initial version id.
*/
async createDraft(input: CreateDraftInput) {
const draftId = crypto.randomUUID();
const versionId = crypto.randomUUID();
const briefingInput = input.briefing ?? { topic: input.title };
const briefing: DraftBriefing = {
...defaultBriefing(input.kind, briefingInput.topic),
...briefingInput,
};
const initialContent = input.initialContent ?? '';
const newDraft: LocalDraft = {
id: draftId,
kind: input.kind,
status: input.status ?? 'draft',
title: input.title,
briefing,
styleId: input.styleId ?? null,
styleOverrides: null,
references: input.references ?? [],
currentVersionId: versionId,
publishedTo: [],
isFavorite: input.isFavorite ?? false,
visibility: defaultVisibilityFor(getActiveSpace()?.type),
};
const newVersion: LocalDraftVersion = {
id: versionId,
draftId,
versionNumber: 1,
content: initialContent,
wordCount: wordCountOf(initialContent),
generationId: null,
isAiGenerated: false,
parentVersionId: null,
summary: null,
};
const draftSnapshot = toDraft({ ...newDraft });
const versionSnapshot = toDraftVersion({ ...newVersion });
await encryptRecord('writingDrafts', newDraft);
await encryptRecord('writingDraftVersions', newVersion);
await draftTable.add(newDraft);
await draftVersionTable.add(newVersion);
emitDomainEvent('WritingDraftCreated', 'writing', 'writingDrafts', draftId, {
draftId,
kind: input.kind,
title: input.title,
});
return { draft: draftSnapshot, version: versionSnapshot };
},
async updateDraft(id: string, patch: UpdateDraftPatch) {
const wrapped = { ...patch } as Record<string, unknown>;
await encryptRecord('writingDrafts', wrapped);
await draftTable.update(id, {
...wrapped,
updatedAt: new Date().toISOString(),
});
},
async updateBriefing(id: string, briefingPatch: Partial<DraftBriefing>) {
const existing = await draftTable.get(id);
if (!existing) return;
const merged: DraftBriefing = { ...existing.briefing, ...briefingPatch };
await draftsStore.updateDraft(id, { briefing: merged });
},
async updateStyleOverrides(id: string, overrides: DraftStyleOverrides | null) {
await draftsStore.updateDraft(id, { styleOverrides: overrides });
},
async setStatus(id: string, status: DraftStatus) {
const existing = await draftTable.get(id);
if (!existing || existing.status === status) return;
await draftTable.update(id, {
status,
updatedAt: new Date().toISOString(),
});
emitDomainEvent('WritingDraftStatusChanged', 'writing', 'writingDrafts', id, {
draftId: id,
before: existing.status,
after: status,
});
},
async toggleFavorite(id: string) {
const existing = await draftTable.get(id);
if (!existing) return;
await draftTable.update(id, {
isFavorite: !existing.isFavorite,
updatedAt: new Date().toISOString(),
});
},
async deleteDraft(id: string) {
const now = new Date().toISOString();
await draftTable.update(id, { deletedAt: now, updatedAt: now });
// Soft-delete every version belonging to the draft so they stop
// showing up in version-history queries. Generations we leave —
// they're audit records and shouldn't disappear silently.
const versions = await draftVersionTable.where('draftId').equals(id).toArray();
await Promise.all(
versions.map((v) => draftVersionTable.update(v.id, { deletedAt: now, updatedAt: now }))
);
emitDomainEvent('WritingDraftDeleted', 'writing', 'writingDrafts', id, { draftId: id });
},
/**
* In-place edit of the draft's current version. This is the path for
* live typing in the editor and for selection-refinement application;
* it does NOT create a new version record. Use `createCheckpointVersion`
* when the user wants to freeze the current state.
*/
async updateVersionContent(versionId: string, content: string) {
const existing = await draftVersionTable.get(versionId);
if (!existing) return;
const wrapped: Record<string, unknown> = {
content,
wordCount: wordCountOf(content),
};
await encryptRecord('writingDraftVersions', wrapped);
const now = new Date().toISOString();
await draftVersionTable.update(versionId, { ...wrapped, updatedAt: now });
// Bump the owning draft's updatedAt so ListView sorting reflects
// the edit even though nothing on the draft itself changed.
await draftTable.update(existing.draftId, { updatedAt: now });
},
/**
* Take the current content of `sourceVersionId` and copy it into a
* new version with the next versionNumber; point the draft at it.
* Used by the "Als Checkpoint speichern" button.
*/
async createCheckpointVersion(
draftId: string,
sourceVersionId: string,
opts: { isAiGenerated?: boolean; generationId?: string | null; summary?: string | null } = {}
) {
const source = await draftVersionTable.get(sourceVersionId);
if (!source) throw new Error(`Version ${sourceVersionId} not found`);
const existing = await draftVersionTable.where('draftId').equals(draftId).toArray();
const nextNumber = Math.max(0, ...existing.map((v) => v.versionNumber)) + 1;
const newVersion: LocalDraftVersion = {
id: crypto.randomUUID(),
draftId,
versionNumber: nextNumber,
content: source.content,
wordCount: source.wordCount,
generationId: opts.generationId ?? null,
isAiGenerated: opts.isAiGenerated ?? false,
parentVersionId: sourceVersionId,
summary: opts.summary ?? null,
};
const snapshot = toDraftVersion({ ...newVersion });
await encryptRecord('writingDraftVersions', newVersion);
await draftVersionTable.add(newVersion);
await draftTable.update(draftId, {
currentVersionId: newVersion.id,
updatedAt: new Date().toISOString(),
});
emitDomainEvent(
'WritingDraftVersionCreated',
'writing',
'writingDraftVersions',
newVersion.id,
{
draftId,
versionId: newVersion.id,
versionNumber: nextNumber,
isAiGenerated: newVersion.isAiGenerated,
}
);
return snapshot;
},
/**
* Restore an older version as the draft's current version. Does NOT
* destroy newer versions the version history still shows them so
* the user can re-restore. Implemented as a pointer flip, not a copy,
* because the user wants the old text back verbatim.
*/
async restoreVersion(draftId: string, versionId: string) {
const version = await draftVersionTable.get(versionId);
if (!version || version.draftId !== draftId) {
throw new Error(`Version ${versionId} does not belong to draft ${draftId}`);
}
await draftTable.update(draftId, {
currentVersionId: versionId,
updatedAt: new Date().toISOString(),
});
emitDomainEvent('WritingDraftVersionReverted', 'writing', 'writingDrafts', draftId, {
draftId,
restoredVersionId: versionId,
versionNumber: version.versionNumber,
});
},
async setVisibility(id: string, next: VisibilityLevel) {
const existing = await draftTable.get(id);
if (!existing) throw new Error(`Draft ${id} not found`);
const before: VisibilityLevel = existing.visibility ?? 'space';
if (before === next) return;
const now = new Date().toISOString();
const patch: Partial<LocalDraft> = {
visibility: next,
visibilityChangedAt: now,
visibilityChangedBy: getEffectiveUserId(),
updatedAt: now,
};
if (next === 'unlisted' && !existing.unlistedToken) {
patch.unlistedToken = generateUnlistedToken();
} else if (next !== 'unlisted' && existing.unlistedToken) {
patch.unlistedToken = undefined;
}
await draftTable.update(id, patch);
emitDomainEvent('VisibilityChanged', 'writing', 'writingDrafts', id, {
recordId: id,
collection: 'writingDrafts',
before,
after: next,
});
},
};

View file

@ -0,0 +1,117 @@
/**
* Writing styles store mutation service for user-defined style records.
*
* Preset styles are not stored in Dexie (they live in `presets/styles.ts`)
* unless a user explicitly "favourites" a preset that writes a row with
* `source='preset'` + `presetId`. Custom styles (typed description, sample-
* trained, self-trained) are always rows.
*
* Sample-extraction (training) lives in M4.1 and calls into this store via
* `upsertExtractedPrinciples`.
*/
import { encryptRecord } from '$lib/data/crypto';
import { emitDomainEvent } from '$lib/data/events';
import { writingStyleTable } from '../collections';
import { toWritingStyle } from '../queries';
import type {
LocalWritingStyle,
StyleSource,
StyleSample,
StyleExtractedPrinciples,
} from '../types';
export interface CreateStyleInput {
name: string;
description: string;
source: StyleSource;
presetId?: string | null;
samples?: StyleSample[];
extractedPrinciples?: StyleExtractedPrinciples | null;
isSpaceDefault?: boolean;
isFavorite?: boolean;
}
export type UpdateStylePatch = Partial<
Pick<
LocalWritingStyle,
'name' | 'description' | 'samples' | 'extractedPrinciples' | 'isSpaceDefault' | 'isFavorite'
>
>;
export const stylesStore = {
async createStyle(input: CreateStyleInput) {
const newLocal: LocalWritingStyle = {
id: crypto.randomUUID(),
name: input.name,
description: input.description,
source: input.source,
presetId: input.presetId ?? null,
samples: input.samples ?? [],
extractedPrinciples: input.extractedPrinciples ?? null,
isSpaceDefault: input.isSpaceDefault ?? false,
isFavorite: input.isFavorite ?? false,
};
const snapshot = toWritingStyle({ ...newLocal });
await encryptRecord('writingStyles', newLocal);
await writingStyleTable.add(newLocal);
emitDomainEvent('WritingStyleCreated', 'writing', 'writingStyles', newLocal.id, {
styleId: newLocal.id,
source: input.source,
name: input.name,
});
return snapshot;
},
async updateStyle(id: string, patch: UpdateStylePatch) {
const wrapped = { ...patch } as Record<string, unknown>;
await encryptRecord('writingStyles', wrapped);
await writingStyleTable.update(id, {
...wrapped,
updatedAt: new Date().toISOString(),
});
},
async upsertExtractedPrinciples(id: string, principles: StyleExtractedPrinciples) {
await stylesStore.updateStyle(id, { extractedPrinciples: principles });
emitDomainEvent('WritingStyleTrainedFromSamples', 'writing', 'writingStyles', id, {
styleId: id,
toneTraitsCount: principles.toneTraits.length,
});
},
async toggleFavorite(id: string) {
const existing = await writingStyleTable.get(id);
if (!existing) return;
await writingStyleTable.update(id, {
isFavorite: !existing.isFavorite,
updatedAt: new Date().toISOString(),
});
},
async setSpaceDefault(id: string, isDefault: boolean) {
// Only one style per space can be the default; flip the others off first.
if (isDefault) {
const existing = await writingStyleTable
.filter((s) => s.isSpaceDefault && s.id !== id)
.toArray();
await Promise.all(
existing.map((s) =>
writingStyleTable.update(s.id, {
isSpaceDefault: false,
updatedAt: new Date().toISOString(),
})
)
);
}
await writingStyleTable.update(id, {
isSpaceDefault: isDefault,
updatedAt: new Date().toISOString(),
});
},
async deleteStyle(id: string) {
const now = new Date().toISOString();
await writingStyleTable.update(id, { deletedAt: now, updatedAt: now });
},
};

View file

@ -0,0 +1,265 @@
/**
* Writing module types an AI-first Ghostwriter surface for intentionally
* produced prose. A `Draft` carries briefing + references + a pointer to its
* current version; every full (re)generation writes a new immutable
* `DraftVersion`; `Generation` records capture the provider-level call so
* we can show live progress and audit which model/prompt produced what.
* `WritingStyle` lives as its own table so a style can be reused across
* drafts, trained from samples, and (later) linked from agent personas.
*
* Plan: `docs/plans/writing-module.md`.
*/
import type { BaseRecord } from '@mana/local-store';
import type { VisibilityLevel } from '@mana/shared-privacy';
// ─── Discriminators & Enums ──────────────────────────────
export type DraftKind =
| 'blog'
| 'essay'
| 'email'
| 'social'
| 'story'
| 'letter'
| 'speech'
| 'cover-letter'
| 'product-description'
| 'press-release'
| 'bio'
| 'other';
export type DraftStatus = 'draft' | 'refining' | 'complete' | 'published';
export type DraftLengthUnit = 'words' | 'chars' | 'minutes';
export type GenerationStatus = 'queued' | 'running' | 'succeeded' | 'failed' | 'cancelled';
export type GenerationKind =
| 'outline'
| 'draft-from-brief'
| 'draft-from-outline'
| 'selection-rewrite'
| 'selection-shorten'
| 'selection-expand'
| 'selection-tone'
| 'selection-translate'
| 'full-regenerate';
export type GenerationProvider = 'mana-ai' | 'mana-llm' | 'local-llm';
export type StyleSource = 'preset' | 'custom-description' | 'sample-trained' | 'self-trained';
export type DraftReferenceKind =
| 'article'
| 'note'
| 'library'
| 'kontext'
| 'goal'
| 'url'
| 'me-image';
export type DraftPublishModule = 'website' | 'articles' | 'social-relay' | 'mail' | 'presi';
// ─── Sub-objects ─────────────────────────────────────────
export interface DraftBriefing {
topic: string;
audience?: string | null;
tone?: string | null;
/** ISO language code; default 'de'. */
language: string;
targetLength?: {
type: DraftLengthUnit;
value: number;
} | null;
extraInstructions?: string | null;
/** When true, the runner injects mana-research results as standing context. */
useResearch?: boolean;
}
export interface DraftStyleOverrides {
tone?: string | null;
styleNotes?: string | null;
}
export interface DraftReference {
kind: DraftReferenceKind;
/** Module-local id; present for every kind except 'url'. */
targetId?: string | null;
/** External URL; present for kind='url', optional otherwise. */
url?: string | null;
/** Free-form note about why this reference matters for the draft. */
note?: string | null;
}
export interface DraftPublishTarget {
module: DraftPublishModule;
targetId: string;
publishedAt: string;
}
export interface DraftGenerationParams {
temperature?: number | null;
maxTokens?: number | null;
}
export interface DraftSelection {
start: number;
end: number;
}
export interface DraftTokenUsage {
input: number;
output: number;
}
export interface StyleSample {
label: string;
text: string;
/** Optional pointer back to the source (e.g. 'journal:abc', 'articles:xyz'). */
sourceRef?: string | null;
}
export interface StyleExtractedPrinciples {
toneTraits: string[];
sentenceLengthAvg?: number | null;
vocabulary?: string[];
examples?: string[];
rawAnalysis?: string | null;
extractedAt: string;
}
// ─── Local Records (Dexie) ───────────────────────────────
export interface LocalDraft extends BaseRecord {
kind: DraftKind;
status: DraftStatus;
title: string;
briefing: DraftBriefing;
/** FK to writingStyles; null = ad-hoc (no saved style). */
styleId?: string | null;
styleOverrides?: DraftStyleOverrides | null;
references: DraftReference[];
/** Points at the current LocalDraftVersion.id; null until first generation. */
currentVersionId?: string | null;
visibility?: VisibilityLevel;
visibilityChangedAt?: string;
visibilityChangedBy?: string;
unlistedToken?: string;
publishedTo?: DraftPublishTarget[];
isFavorite: boolean;
}
export interface LocalDraftVersion extends BaseRecord {
draftId: string;
versionNumber: number;
/** Markdown body of this version. */
content: string;
wordCount: number;
generationId?: string | null;
isAiGenerated: boolean;
parentVersionId?: string | null;
/** Short auto-summary for the version-history panel. */
summary?: string | null;
}
export interface LocalGeneration extends BaseRecord {
draftId: string;
kind: GenerationKind;
status: GenerationStatus;
prompt: string;
provider: GenerationProvider;
model?: string | null;
params?: DraftGenerationParams | null;
/** Only set for selection-* kinds. */
inputSelection?: DraftSelection | null;
output?: string | null;
outputVersionId?: string | null;
startedAt?: string | null;
completedAt?: string | null;
durationMs?: number | null;
tokenUsage?: DraftTokenUsage | null;
error?: string | null;
/** FK into a mana-ai mission when the generation ran server-side. */
missionId?: string | null;
}
export interface LocalWritingStyle extends BaseRecord {
name: string;
description: string;
source: StyleSource;
presetId?: string | null;
samples?: StyleSample[];
extractedPrinciples?: StyleExtractedPrinciples | null;
/** True when this style is the Space-wide default for team spaces. */
isSpaceDefault: boolean;
isFavorite: boolean;
}
// ─── Domain Types (plaintext, for UI) ────────────────────
export interface Draft {
id: string;
kind: DraftKind;
status: DraftStatus;
title: string;
briefing: DraftBriefing;
styleId: string | null;
styleOverrides: DraftStyleOverrides | null;
references: DraftReference[];
currentVersionId: string | null;
visibility: VisibilityLevel;
publishedTo: DraftPublishTarget[];
isFavorite: boolean;
createdAt: string;
updatedAt: string;
}
export interface DraftVersion {
id: string;
draftId: string;
versionNumber: number;
content: string;
wordCount: number;
generationId: string | null;
isAiGenerated: boolean;
parentVersionId: string | null;
summary: string | null;
createdAt: string;
}
export interface Generation {
id: string;
draftId: string;
kind: GenerationKind;
status: GenerationStatus;
prompt: string;
provider: GenerationProvider;
model: string | null;
params: DraftGenerationParams | null;
inputSelection: DraftSelection | null;
output: string | null;
outputVersionId: string | null;
startedAt: string | null;
completedAt: string | null;
durationMs: number | null;
tokenUsage: DraftTokenUsage | null;
error: string | null;
missionId: string | null;
createdAt: string;
}
export interface WritingStyle {
id: string;
name: string;
description: string;
source: StyleSource;
presetId: string | null;
samples: StyleSample[];
extractedPrinciples: StyleExtractedPrinciples | null;
isSpaceDefault: boolean;
isFavorite: boolean;
createdAt: string;
updatedAt: string;
}

View file

@ -0,0 +1,355 @@
<!--
Writing — Detail view. Three panels on desktop (collapsing to tabs on
mobile later): briefing summary, text editor, version history. In M2
the editor is a plain textarea; M6 adds selection-based refinement
tools. "Als Checkpoint speichern" freezes the current draft content
as a numbered version (otherwise typing just edits version v1 in
place).
-->
<script lang="ts">
import { goto } from '$app/navigation';
import BriefingForm from '../components/BriefingForm.svelte';
import StatusBadge from '../components/StatusBadge.svelte';
import VersionEditor from '../components/VersionEditor.svelte';
import VersionHistory from '../components/VersionHistory.svelte';
import { draftsStore } from '../stores/drafts.svelte';
import { useDraft, useVersionsForDraft, useCurrentVersionForDraft } from '../queries';
import { KIND_LABELS, STATUS_LABELS } from '../constants';
import type { DraftStatus } from '../types';
let { id }: { id: string } = $props();
// The parent route wraps this component in `{#key id}` so each draft
// gets a fresh mount — the live queries are safe to seed from the
// initial `id` without reacting to prop changes.
/* svelte-ignore state_referenced_locally */
const draft$ = useDraft(id);
/* svelte-ignore state_referenced_locally */
const versions$ = useVersionsForDraft(id);
/* svelte-ignore state_referenced_locally */
const currentVersion$ = useCurrentVersionForDraft(id);
const draft = $derived(draft$.value);
const versions = $derived(versions$.value);
const currentVersion = $derived(currentVersion$.value);
let briefingOpen = $state(false);
let saving = $state(false);
async function setStatus(next: DraftStatus) {
if (!draft) return;
await draftsStore.setStatus(draft.id, next);
}
async function toggleFavorite() {
if (!draft) return;
await draftsStore.toggleFavorite(draft.id);
}
async function saveCheckpoint() {
if (!draft || !currentVersion || saving) return;
saving = true;
try {
await draftsStore.createCheckpointVersion(draft.id, currentVersion.id);
} finally {
saving = false;
}
}
async function remove() {
if (!draft) return;
if (!confirm(`"${draft.title}" wirklich löschen?`)) return;
await draftsStore.deleteDraft(draft.id);
goto('/writing');
}
const kind = $derived(draft ? KIND_LABELS[draft.kind] : null);
const targetWords = $derived(draft?.briefing.targetLength?.value ?? null);
const STATUS_ORDER: DraftStatus[] = ['draft', 'refining', 'complete', 'published'];
</script>
{#if draft$.loading}
<p class="muted center">Lädt…</p>
{:else if !draft}
<div class="empty">
<p>Dieser Draft existiert nicht (mehr).</p>
<a href="/writing">Zurück zur Übersicht</a>
</div>
{:else}
<div class="shell">
<header class="head">
<div class="title-row">
<a href="/writing" class="back">← Alle Drafts</a>
<div class="title-block">
<div class="kind" title={kind?.de}>
<span aria-hidden="true">{kind?.emoji}</span>
{kind?.de}
</div>
<h1>{draft.title || draft.briefing.topic || 'Unbenannt'}</h1>
</div>
<div class="actions">
<button
type="button"
class="ghost"
onclick={toggleFavorite}
aria-pressed={draft.isFavorite}
title="Favorit"
>
{draft.isFavorite ? '★' : '☆'}
</button>
<button type="button" class="ghost danger" onclick={remove}>Löschen</button>
</div>
</div>
<div class="meta-row">
<StatusBadge status={draft.status} />
<div class="status-picker">
{#each STATUS_ORDER as s (s)}
{#if s !== draft.status}
<button type="button" class="tiny" onclick={() => setStatus(s)}>
{STATUS_LABELS[s].de}
</button>
{/if}
{/each}
</div>
</div>
</header>
<section class="briefing-section">
<button type="button" class="briefing-toggle" onclick={() => (briefingOpen = !briefingOpen)}>
{briefingOpen ? '▾' : '▸'} Briefing
{#if !briefingOpen}
<span class="preview">{draft.briefing.topic}</span>
{/if}
</button>
{#if briefingOpen}
<BriefingForm mode="edit" {draft} onclose={() => (briefingOpen = false)} />
{/if}
</section>
<div class="columns">
<section class="editor-column">
{#if currentVersion}
<div class="editor-head">
<div>
<strong>Version {currentVersion.versionNumber}</strong>
{#if currentVersion.isAiGenerated}
<span class="ai-tag">KI</span>
{/if}
</div>
<button
type="button"
class="checkpoint"
onclick={saveCheckpoint}
disabled={saving}
title="Aktuellen Text als neue Version einfrieren"
>
{saving ? 'Speichert…' : ' Als Checkpoint speichern'}
</button>
</div>
<VersionEditor version={currentVersion} {targetWords} />
{:else}
<p class="muted">Diese Version existiert nicht mehr.</p>
{/if}
</section>
<aside class="history-column">
<h2>Versionen</h2>
<VersionHistory
versions={versions ?? []}
currentVersionId={draft.currentVersionId}
draftId={draft.id}
/>
</aside>
</div>
</div>
{/if}
<style>
.shell {
max-width: 1100px;
margin: 0 auto;
padding: 1.25rem 1.5rem 2rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.muted {
color: var(--color-text-muted, rgba(0, 0, 0, 0.55));
}
.muted.center {
text-align: center;
margin-top: 2rem;
}
.empty {
max-width: 600px;
margin: 4rem auto;
text-align: center;
}
.empty a {
color: #0ea5e9;
}
.head {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.title-row {
display: flex;
align-items: flex-start;
gap: 0.75rem;
}
.title-block {
flex: 1;
min-width: 0;
}
.back {
font-size: 0.85rem;
color: var(--color-text-muted, rgba(0, 0, 0, 0.55));
text-decoration: none;
padding-top: 0.5rem;
}
.back:hover {
color: #0ea5e9;
}
.kind {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-muted, rgba(0, 0, 0, 0.55));
}
h1 {
margin: 0;
font-size: 1.5rem;
line-height: 1.2;
}
.actions {
display: inline-flex;
gap: 0.4rem;
}
.ghost {
padding: 0.4rem 0.7rem;
border-radius: 0.5rem;
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.1));
background: transparent;
cursor: pointer;
color: inherit;
font: inherit;
}
.ghost:hover {
background: var(--color-surface, rgba(0, 0, 0, 0.04));
}
.ghost.danger:hover {
border-color: #ef4444;
color: #ef4444;
}
.meta-row {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.status-picker {
display: inline-flex;
gap: 0.25rem;
flex-wrap: wrap;
}
.tiny {
padding: 0.15rem 0.5rem;
border-radius: 0.35rem;
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.08));
background: transparent;
font-size: 0.75rem;
cursor: pointer;
color: var(--color-text-muted, rgba(0, 0, 0, 0.55));
}
.tiny:hover {
border-color: #0ea5e9;
color: #0ea5e9;
}
.briefing-section {
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.08));
border-radius: 0.75rem;
background: var(--color-surface, rgba(255, 255, 255, 0.02));
}
.briefing-toggle {
width: 100%;
text-align: left;
padding: 0.75rem 1rem;
background: transparent;
border: none;
cursor: pointer;
font: inherit;
font-weight: 500;
display: flex;
align-items: center;
gap: 0.5rem;
color: inherit;
}
.briefing-toggle .preview {
font-weight: normal;
color: var(--color-text-muted, rgba(0, 0, 0, 0.55));
font-size: 0.85rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
flex: 1;
}
.columns {
display: grid;
grid-template-columns: 1fr 280px;
gap: 1.25rem;
}
.editor-column {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.editor-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
}
.ai-tag {
font-size: 0.65rem;
padding: 0.05rem 0.4rem;
border-radius: 999px;
background: color-mix(in srgb, #a855f7 15%, transparent);
color: #a855f7;
margin-left: 0.4rem;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.checkpoint {
padding: 0.4rem 0.8rem;
border-radius: 0.5rem;
border: 1px solid #0ea5e9;
background: transparent;
color: #0ea5e9;
cursor: pointer;
font: inherit;
font-size: 0.85rem;
}
.checkpoint:hover:not(:disabled) {
background: color-mix(in srgb, #0ea5e9 10%, transparent);
}
.checkpoint:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.history-column h2 {
font-size: 0.8rem;
margin: 0 0 0.5rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-muted, rgba(0, 0, 0, 0.55));
font-weight: 500;
}
@media (max-width: 900px) {
.columns {
grid-template-columns: 1fr;
}
}
</style>

View file

@ -0,0 +1,259 @@
<!--
Writing — ListView.
Grid of drafts with KindTabs + status chips + search + "+ Neu" inline-create.
Clicking a card routes to /writing/draft/[id]. The draft preview shows the
first ~160 chars of the current version so the card isn't empty for an
unstarted draft.
-->
<script lang="ts">
import { goto } from '$app/navigation';
import KindTabs from '../components/KindTabs.svelte';
import StatusFilter from '../components/StatusFilter.svelte';
import DraftCard from '../components/DraftCard.svelte';
import BriefingForm from '../components/BriefingForm.svelte';
import {
useAllDrafts,
filterByKind,
filterByStatus,
searchDrafts,
sortByUpdated,
} from '../queries';
import { draftVersionTable } from '../collections';
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
import { decryptRecords } from '$lib/data/crypto';
import { toDraftVersion } from '../queries';
import type { Draft, DraftVersion, DraftKind, DraftStatus, LocalDraftVersion } from '../types';
const drafts$ = useAllDrafts();
const drafts = $derived(drafts$.value);
// Pull every version for the drafts we show so we can look up each
// card's current version by id without a per-card live query. On the
// list page we only need the wordCount + first 160 chars, so the whole
// content is fine to read (decryption is the same either way).
const currentVersions$ = useLiveQueryWithDefault(async () => {
const ids = drafts.map((d) => d.currentVersionId).filter((id): id is string => !!id);
if (ids.length === 0) return new Map<string, DraftVersion>();
const rows = (await draftVersionTable.bulkGet(ids)).filter(
(r): r is LocalDraftVersion => !!r && !r.deletedAt
);
const decrypted = await decryptRecords('writingDraftVersions', rows);
const map = new Map<string, DraftVersion>();
for (const v of decrypted.map(toDraftVersion)) map.set(v.id, v);
return map;
}, new Map<string, DraftVersion>());
const currentVersionsById = $derived(currentVersions$.value);
let activeKind = $state<DraftKind | 'all'>('all');
let activeStatus = $state<DraftStatus | null>(null);
let searchQuery = $state('');
let showFavoritesOnly = $state(false);
let showCreate = $state(false);
const counts = $derived<Record<DraftKind, number>>({
blog: drafts.filter((d) => d.kind === 'blog').length,
essay: drafts.filter((d) => d.kind === 'essay').length,
email: drafts.filter((d) => d.kind === 'email').length,
social: drafts.filter((d) => d.kind === 'social').length,
story: drafts.filter((d) => d.kind === 'story').length,
letter: drafts.filter((d) => d.kind === 'letter').length,
speech: drafts.filter((d) => d.kind === 'speech').length,
'cover-letter': drafts.filter((d) => d.kind === 'cover-letter').length,
'product-description': drafts.filter((d) => d.kind === 'product-description').length,
'press-release': drafts.filter((d) => d.kind === 'press-release').length,
bio: drafts.filter((d) => d.kind === 'bio').length,
other: drafts.filter((d) => d.kind === 'other').length,
});
const filtered = $derived.by(() => {
let result = drafts;
if (activeKind !== 'all') result = filterByKind(result, activeKind);
if (activeStatus) result = filterByStatus(result, activeStatus);
if (showFavoritesOnly) result = result.filter((d) => d.isFavorite);
if (searchQuery.trim()) result = searchDrafts(result, searchQuery.trim());
return sortByUpdated(result);
});
function openDraft(d: Draft) {
goto(`/writing/draft/${d.id}`);
}
function presetKind(): DraftKind | undefined {
return activeKind === 'all' ? undefined : activeKind;
}
function onCreated(d: Draft) {
showCreate = false;
openDraft(d);
}
</script>
<div class="writing-shell">
<div class="controls">
<div class="search-row">
<input
type="search"
class="search"
bind:value={searchQuery}
placeholder="Nach Titel oder Thema suchen…"
/>
<button
type="button"
class="create-btn"
class:active={showCreate}
onclick={() => (showCreate = !showCreate)}
aria-expanded={showCreate}
>
{showCreate ? '× Schließen' : '+ Neuer Draft'}
</button>
</div>
<KindTabs active={activeKind} {counts} onselect={(k) => (activeKind = k)} />
<div class="filter-row">
<StatusFilter active={activeStatus} onselect={(s) => (activeStatus = s)} />
<label class="fav-toggle">
<input type="checkbox" bind:checked={showFavoritesOnly} />
<span>Nur Favoriten</span>
</label>
</div>
</div>
{#if showCreate}
<div class="inline-create">
<BriefingForm
mode="create"
initialKind={presetKind()}
onclose={() => (showCreate = false)}
oncreated={onCreated}
/>
</div>
{/if}
{#if drafts$.loading}
<p class="muted center">Lädt…</p>
{:else if filtered.length === 0}
<div class="empty">
{#if drafts.length === 0}
<h2>Noch keine Drafts</h2>
<p>
Klick auf <strong>+ Neuer Draft</strong>, brief dem Ghostwriter Thema, Stil und Länge — M3
ergänzt die Generate-Funktion. Bis dahin kannst du Drafts manuell erstellen und editieren.
</p>
{:else}
<p class="muted">Keine Drafts passen zum aktuellen Filter.</p>
{/if}
</div>
{:else}
<div class="grid">
{#each filtered as draft (draft.id)}
<DraftCard
{draft}
currentVersion={currentVersionsById.get(draft.currentVersionId ?? '') ?? null}
onopen={openDraft}
/>
{/each}
</div>
{/if}
</div>
<style>
.writing-shell {
max-width: 1100px;
margin: 0 auto;
padding: 1.5rem;
}
.muted {
color: var(--color-text-muted, rgba(0, 0, 0, 0.55));
font-size: 0.9rem;
}
.muted.center {
text-align: center;
margin-top: 2rem;
}
.controls {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-bottom: 1.5rem;
}
.search-row {
display: flex;
align-items: center;
gap: 0.75rem;
}
.create-btn {
padding: 0.45rem 0.9rem;
border-radius: 0.55rem;
border: 1px solid #0ea5e9;
background: #0ea5e9;
color: white;
cursor: pointer;
font: inherit;
font-weight: 500;
white-space: nowrap;
flex-shrink: 0;
}
.create-btn:hover {
background: #0284c7;
border-color: #0284c7;
}
.create-btn.active {
background: transparent;
color: #0ea5e9;
}
.filter-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
flex-wrap: wrap;
}
.fav-toggle {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-size: 0.85rem;
color: var(--color-text-muted, rgba(0, 0, 0, 0.55));
cursor: pointer;
}
.search {
flex: 1;
min-width: 0;
padding: 0.55rem 0.85rem;
border-radius: 0.5rem;
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.1));
background: var(--color-surface, transparent);
font: inherit;
color: inherit;
}
.search:focus {
outline: 2px solid #0ea5e9;
outline-offset: 1px;
border-color: transparent;
}
.inline-create {
margin-bottom: 1.25rem;
border: 1px solid color-mix(in srgb, #0ea5e9 30%, transparent);
border-radius: 0.75rem;
background: color-mix(in srgb, #0ea5e9 4%, transparent);
}
.empty {
max-width: 540px;
margin: 3rem auto;
text-align: center;
}
.empty h2 {
margin: 0 0 0.5rem;
font-size: 1.2rem;
}
.empty p {
color: var(--color-text-muted, rgba(0, 0, 0, 0.55));
line-height: 1.5;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 0.9rem;
}
</style>

View file

@ -0,0 +1,12 @@
<script lang="ts">
import ListView from '$lib/modules/writing/ListView.svelte';
import { RoutePage } from '$lib/components/shell';
</script>
<svelte:head>
<title>Writing - Mana</title>
</svelte:head>
<RoutePage appId="writing">
<ListView />
</RoutePage>

View file

@ -0,0 +1,17 @@
<script lang="ts">
import DetailView from '$lib/modules/writing/views/DetailView.svelte';
import { RoutePage } from '$lib/components/shell';
import { page } from '$app/state';
const id = $derived(page.params.id ?? '');
</script>
<svelte:head>
<title>Draft - Writing - Mana</title>
</svelte:head>
<RoutePage appId="writing">
{#key id}
<DetailView {id} />
{/key}
</RoutePage>