mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 18:41:08 +02:00
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:
parent
547f643a6f
commit
b385839204
2 changed files with 132 additions and 44 deletions
|
|
@ -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);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue