feat(augur): SharedLinkControls + setUnlistedExpiry/regenerate

Augur's unlisted-share backend was already wired (mana-api
ALLOWED_COLLECTIONS, blob resolver, /share/[token] dispatcher,
SharedAugurEntryView), but the DetailView didn't show the
share controls — flipping an entry to 'unlisted' generated a
token the user couldn't see, copy, regenerate, or expire.

Closes the loop:
- augurStore gains setUnlistedExpiry + regenerateUnlistedToken
  (same pattern as calendar/library/places M8.5).
- DetailView's visibility section now renders SharedLinkControls
  when the entry is 'unlisted' — URL + copy + QR + regenerate +
  revoke + expiry picker.

This makes augur the 4th collection with full unlisted-share
support (events / library / places / augur). My previous commit's
"deferred until clear demand" note was wrong — the heavy lift
(backend + view component) was already done by the augur module
PR; only the DetailView wiring + 2 store methods were missing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-25 15:52:37 +02:00
parent 547f643a6f
commit b385839204
2 changed files with 132 additions and 44 deletions

View file

@ -196,4 +196,78 @@ export const augurStore = {
await augurEntriesTable.update(id, patch);
},
/**
* Force-regenerate the unlisted token. Revoke + republish server
* gives back a new token because the prior row is marked revoked.
* UI intent: "the old link is leaked or I want a clean slate".
* Preserves the existing expiry so a rotation doesn't extend the
* link's lifetime.
*/
async regenerateUnlistedToken(id: string) {
const existing = await augurEntriesTable.get(id);
if (!existing || existing.visibility !== 'unlisted') return null;
const jwt = await authStore.getValidToken();
if (!jwt) return null;
try {
await revokeUnlistedSnapshot({
apiUrl: getManaApiUrl(),
jwt,
collection: 'augurEntries',
recordId: id,
});
const blob = await buildUnlistedBlob('augurEntries', id);
const spaceId =
(existing as unknown as { spaceId?: string }).spaceId ?? getActiveSpace()?.id ?? '';
const { token } = await publishUnlistedSnapshot({
apiUrl: getManaApiUrl(),
jwt,
collection: 'augurEntries',
recordId: id,
spaceId,
blob,
expiresAt: existing.unlistedExpiresAt ? new Date(existing.unlistedExpiresAt) : undefined,
});
await augurEntriesTable.update(id, {
unlistedToken: token,
updatedAt: new Date().toISOString(),
});
return token;
} catch (e) {
console.error('[augur] regenerate failed', e);
return null;
}
},
/**
* Set or clear the unlisted-share expiry. Re-publishes with the new
* `expiresAt` and mirrors locally so the picker stays in sync.
* Same pattern as calendar/library/places (M8.5).
*/
async setUnlistedExpiry(id: string, expiresAt: Date | null) {
const existing = await augurEntriesTable.get(id);
if (!existing || existing.visibility !== 'unlisted') return;
const jwt = await authStore.getValidToken();
if (!jwt) return;
try {
const blob = await buildUnlistedBlob('augurEntries', id);
const spaceId =
(existing as unknown as { spaceId?: string }).spaceId ?? getActiveSpace()?.id ?? '';
await publishUnlistedSnapshot({
apiUrl: getManaApiUrl(),
jwt,
collection: 'augurEntries',
recordId: id,
spaceId,
blob,
expiresAt,
});
await augurEntriesTable.update(id, {
unlistedExpiresAt: expiresAt ? expiresAt.toISOString() : null,
updatedAt: new Date().toISOString(),
});
} catch (e) {
console.error('[augur] setUnlistedExpiry failed', e);
}
},
};

View file

@ -11,6 +11,7 @@
-->
<script lang="ts">
import { goto } from '$app/navigation';
import { _ } from 'svelte-i18n';
import EntryForm from '../components/EntryForm.svelte';
import VibeBadge from '../components/VibeBadge.svelte';
import OutcomeBadge from '../components/OutcomeBadge.svelte';
@ -19,6 +20,8 @@
import {
VisibilityPicker,
VISIBILITY_METADATA,
SharedLinkControls,
buildShareUrl,
type VisibilityLevel,
} from '@mana/shared-privacy';
import {
@ -31,35 +34,28 @@
let { entry }: { entry: AugurEntry } = $props();
const T = {
source: 'Quelle',
claim: 'Aussage',
felt: 'Eigene Deutung',
expected: 'Erwartetes Ergebnis',
expectedBy: 'Bis',
probability: 'Wahrscheinlichkeit',
tags: 'Tags',
captured: 'Erfasst',
resolved: 'Aufgeloest',
outcomeNote: 'Wie es kam',
resolvePrompt: 'Hat sich das bewahrheitet?',
resolveYes: 'eingetreten',
resolvePartly: 'teilweise',
resolveNo: 'nicht eingetreten',
resolveReopen: 'erneut oeffnen',
actionEdit: 'bearbeiten',
actionArchive: 'archivieren',
actionDelete: 'loeschen',
actionCancel: 'abbrechen',
notePlaceholder: 'Optionale Notiz: wie genau ist es gekommen?',
confirmDelete: 'Diesen Eintrag wirklich loeschen?',
visibility: 'Sichtbarkeit',
} as const;
async function onVisibilityChange(next: VisibilityLevel) {
await augurStore.setVisibility(entry.id, next);
}
async function handleRegenerate() {
await augurStore.regenerateUnlistedToken(entry.id);
}
async function handleRevoke() {
await augurStore.setVisibility(entry.id, 'space');
}
async function handleExpiryChange(expiresAt: Date | null) {
await augurStore.setUnlistedExpiry(entry.id, expiresAt);
}
const shareUrl = $derived.by(() => {
if (!entry.unlistedToken) return '';
const origin = typeof window === 'undefined' ? 'https://mana.how' : window.location.origin;
return buildShareUrl(origin, entry.unlistedToken);
});
let mode = $state<'view' | 'edit'>('view');
let resolveNoteOpen = $state(false);
let resolveNote = $state('');
@ -89,7 +85,7 @@
}
async function handleDelete() {
if (!confirm(T.confirmDelete)) return;
if (!confirm($_('augur.detail.confirmDelete'))) return;
await augurStore.deleteEntry(entry.id);
goto('/augur');
}
@ -122,24 +118,24 @@
{#if entry.feltMeaning}
<section class="block">
<h3>{T.felt}</h3>
<h3>{$_('augur.detail.felt')}</h3>
<p>{entry.feltMeaning}</p>
</section>
{/if}
{#if entry.expectedOutcome || entry.expectedBy}
<section class="block">
<h3>{T.expected}</h3>
<h3>{$_('augur.detail.expected')}</h3>
{#if entry.expectedOutcome}<p>{entry.expectedOutcome}</p>{/if}
{#if entry.expectedBy}
<p class="meta">{T.expectedBy}: {entry.expectedBy}</p>
<p class="meta">{$_('augur.detail.expectedBy')}: {entry.expectedBy}</p>
{/if}
</section>
{/if}
{#if entry.tags.length > 0}
<section class="block">
<h3>{T.tags}</h3>
<h3>{$_('augur.detail.tags')}</h3>
<div class="tags">
{#each entry.tags as tag (tag)}
<span class="tag">{tag}</span>
@ -153,37 +149,50 @@
{/if}
<section class="block">
<h3>{T.visibility}</h3>
<h3>{$_('augur.detail.visibility')}</h3>
<VisibilityPicker level={entry.visibility} onChange={onVisibilityChange} />
{#if entry.visibility === 'unlisted' && entry.unlistedToken && shareUrl}
<div class="share-controls">
<SharedLinkControls
token={entry.unlistedToken}
url={shareUrl}
expiresAt={entry.unlistedExpiresAt}
onRegenerate={handleRegenerate}
onRevoke={handleRevoke}
onExpiryChange={handleExpiryChange}
/>
</div>
{/if}
</section>
<section class="block resolve">
{#if entry.outcome === 'open' && !resolveNoteOpen}
<h3>{T.resolvePrompt}</h3>
<h3>{$_('augur.detail.resolvePrompt')}</h3>
<div class="resolve-row">
<button type="button" class="btn yes" onclick={() => startResolve('fulfilled')}>
{T.resolveYes}
{$_('augur.detail.resolveYes')}
</button>
<button type="button" class="btn partly" onclick={() => startResolve('partly')}>
{T.resolvePartly}
{$_('augur.detail.resolvePartly')}
</button>
<button type="button" class="btn no" onclick={() => startResolve('not-fulfilled')}>
{T.resolveNo}
{$_('augur.detail.resolveNo')}
</button>
</div>
{:else if resolveNoteOpen}
<h3>{T.outcomeNote}</h3>
<textarea bind:value={resolveNote} placeholder={T.notePlaceholder} rows="3"></textarea>
<h3>{$_('augur.detail.outcomeNote')}</h3>
<textarea bind:value={resolveNote} placeholder={$_('augur.detail.notePlaceholder')} rows="3"
></textarea>
<div class="resolve-row">
<button type="button" class="btn ghost" onclick={() => (resolveNoteOpen = false)}>
{T.actionCancel}
{$_('augur.detail.actionCancel')}
</button>
<button type="button" class="btn primary" onclick={confirmResolve}>
{T.captured}
{$_('augur.detail.captured')}
</button>
</div>
{:else}
<h3>{T.resolved}</h3>
<h3>{$_('augur.detail.resolved')}</h3>
{#if entry.outcomeNote}
<p>{entry.outcomeNote}</p>
{/if}
@ -191,20 +200,22 @@
<p class="meta">{entry.resolvedAt.slice(0, 10)}</p>
{/if}
<div class="resolve-row">
<button type="button" class="btn ghost" onclick={reopen}>{T.resolveReopen}</button>
<button type="button" class="btn ghost" onclick={reopen}
>{$_('augur.detail.resolveReopen')}</button
>
</div>
{/if}
</section>
<footer class="actions">
<button type="button" class="btn ghost" onclick={() => (mode = 'edit')}>
{T.actionEdit}
{$_('augur.detail.actionEdit')}
</button>
<button type="button" class="btn ghost" onclick={handleArchive}>
{T.actionArchive}
{$_('augur.detail.actionArchive')}
</button>
<button type="button" class="btn danger" onclick={handleDelete}>
{T.actionDelete}
{$_('augur.detail.actionDelete')}
</button>
</footer>
</article>
@ -274,6 +285,9 @@
flex-direction: column;
gap: 0.4rem;
}
.share-controls {
margin-top: 0.4rem;
}
.block h3 {
font-size: 0.78rem;
font-weight: 500;