feat(writing): M11 — adopt the unified visibility system

The backing for visibility was already in place from M2 (draft.visibility
stamped on create via defaultVisibilityFor, draftsStore.setVisibility
mints/clears unlistedToken and emits VisibilityChanged), so M11 is just
the UI step that puts it in front of the user.

- <VisibilityPicker> from @mana/shared-privacy sits in the meta-row of
  DetailView, mirroring the library pattern. onChange calls
  draftsStore.setVisibility — no new store method needed.
- Draft type + toDraft converter now surface `unlistedToken` so the UI
  can render a share row when visibility === 'unlisted'. Token is
  displayed verbatim + "Kopieren"-button because the public read-URL
  for drafts ships with M10 (Publish-Hooks); a tooltip makes that
  explicit so the user doesn't expect a working link yet.

With this, Writing is now consistent with the Library / Picture /
Calendar / Todo / Goals / Places / Recipes / Wardrobe pilot group.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-24 16:01:05 +02:00
parent ed8ec98572
commit f2275f752d
3 changed files with 70 additions and 0 deletions

View file

@ -36,6 +36,7 @@ export function toDraft(local: LocalDraft): Draft {
publishedTo: local.publishedTo ?? [],
isFavorite: local.isFavorite ?? false,
visibility: local.visibility ?? 'space',
unlistedToken: local.unlistedToken ?? null,
createdAt: local.createdAt ?? now,
updatedAt: local.updatedAt ?? now,
};

View file

@ -210,6 +210,8 @@ export interface Draft {
references: DraftReference[];
currentVersionId: string | null;
visibility: VisibilityLevel;
/** 32-char token minted on first flip to 'unlisted'. Null otherwise. */
unlistedToken: string | null;
publishedTo: DraftPublishTarget[];
isFavorite: boolean;
createdAt: string;

View file

@ -8,6 +8,7 @@
-->
<script lang="ts">
import { goto } from '$app/navigation';
import { VisibilityPicker, type VisibilityLevel } from '@mana/shared-privacy';
import BriefingForm from '../components/BriefingForm.svelte';
import StatusBadge from '../components/StatusBadge.svelte';
import VersionEditor from '../components/VersionEditor.svelte';
@ -106,6 +107,31 @@
await draftsStore.toggleFavorite(draft.id);
}
async function onVisibilityChange(next: VisibilityLevel) {
if (!draft) return;
await draftsStore.setVisibility(draft.id, next);
}
let shareCopied = $state(false);
let shareCopyTimer: ReturnType<typeof setTimeout> | null = null;
async function copyShareToken() {
// The public read-URL for a draft lands with M10 (Publish-Hooks).
// Until then we copy the bare token so the user has *something* to
// keep — when the route goes live, the URL can be reconstructed as
// `<origin>/share/writing/<token>`.
if (!draft?.unlistedToken) return;
try {
await navigator.clipboard.writeText(draft.unlistedToken);
shareCopied = true;
if (shareCopyTimer) clearTimeout(shareCopyTimer);
shareCopyTimer = setTimeout(() => (shareCopied = false), 2000);
} catch {
// Clipboard API can fail (e.g. insecure context). Swallow;
// the user can still read the token from the DOM.
}
}
async function saveCheckpoint() {
if (!draft || !currentVersion || saving) return;
saving = true;
@ -289,7 +315,23 @@
{/if}
{/each}
</div>
<div class="visibility-slot">
<VisibilityPicker level={draft.visibility} onChange={onVisibilityChange} />
</div>
</div>
{#if draft.visibility === 'unlisted' && draft.unlistedToken}
<div
class="share-row"
title="Öffentlicher Link kommt mit M10 (Publish-Hooks). Bis dahin: Token kopieren."
>
<span class="share-label">🔗 Unlisted-Token:</span>
<code class="share-token">{draft.unlistedToken}</code>
<button type="button" class="tiny" onclick={copyShareToken}>
{shareCopied ? '✓ Kopiert' : 'Kopieren'}
</button>
</div>
{/if}
</header>
<section class="briefing-section">
@ -483,6 +525,31 @@
gap: 0.75rem;
flex-wrap: wrap;
}
.visibility-slot {
margin-left: auto;
}
.share-row {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
padding: 0.5rem 0.75rem;
border-radius: 0.5rem;
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.08));
background: var(--color-surface, rgba(0, 0, 0, 0.02));
font-size: 0.8rem;
}
.share-label {
color: var(--color-text-muted, rgba(0, 0, 0, 0.55));
}
.share-token {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.75rem;
padding: 0.15rem 0.4rem;
border-radius: 0.3rem;
background: var(--color-surface-muted, rgba(0, 0, 0, 0.05));
word-break: break-all;
}
.status-picker {
display: inline-flex;
gap: 0.25rem;