feat(activity): per-viewer archive and hide
Two new per-viewer flags on activities, mirroring the heart/done shape: - ARCHIVE: any viewer (incl. the owner) can archive a row. Out of sight by default but the permalink still resolves. For owners, archive also filters the row out of their /<bruker>/liste public page — "I'm done sharing this." - HIDE: only non-owners. "This doesn't appeal to me." Endpoint returns 400 cannot_hide_own when the owner tries it (they should delete or archive instead). Both default-filtered from GET /api/activities. Opt-in via query params with three modes each: exclude (default), include (1), only. ?archived=1&hidden=1 includes both; ?archived=only shows just the archive. The list endpoint composes the SQL filters per-viewer (anonymous viewers have no rows in either table → no-ops). Schema: two new tables user_archived_activities and user_hidden_activities, both composite-PK on (user_id, activity_id), cascade on both refs. Same shape as the existing engagement tables. Types: viewer_archived + viewer_hidden on every Activity variant. viewer_hidden on private is always false (the endpoint refuses); docstring on the type explains why. Bulk lookups extended so the list endpoint stays at constant queries. Single-row paths get per-row viewerArchived/viewerHidden helpers. fetchRowForViewer keeps preserving the viewer's custom sort_position on toggles. UI: two new buttons in ActivityRow's action row — "📦 Arkiver" / "📦 Arkivert" (everyone) and "🙈 Gjem" / "🙈 Skjult" (non-owners only). Two new checkboxes near the search field on /hjem: "📦 Vis arkivert" and "🙈 Vis skjult". Toggling either re-fetches from the server because the filtering is server-side. When an archive/hide flips the row out of the current view, Home.svelte triggers a refetch so the row disappears in real time. Public-list endpoint (/api/users/:bruker/list) also filters out the owner's own archived rows — consistent with "archive means filed away from active view." Tests: 3 new in engagement.test.ts — viewer archives + per-viewer isolation, owner's archive filters their public list, hide refuses on own row + ?hidden=only path. Suite goes 102 → 105.
This commit is contained in:
parent
ef02b3f585
commit
6e005fc2d7
8 changed files with 418 additions and 7 deletions
|
|
@ -179,6 +179,50 @@
|
|||
}
|
||||
}
|
||||
|
||||
// --- Archive / Hide -----------------------------------------------------
|
||||
// Two per-viewer flags. Archive applies to anyone (including the owner);
|
||||
// hide applies only to non-owners. Both opt the row out of the default
|
||||
// listing — the user has to toggle "Vis arkiv" / "Vis skjulte" to see
|
||||
// them again.
|
||||
let archiveBusy = $state(false);
|
||||
let hideBusy = $state(false);
|
||||
|
||||
async function toggleArchive() {
|
||||
if (!session.user || archiveBusy) return;
|
||||
archiveBusy = true;
|
||||
const wasArchived = view.viewer_archived;
|
||||
localOverride = { ...view, viewer_archived: !wasArchived };
|
||||
try {
|
||||
const updated = wasArchived
|
||||
? await api.unarchiveActivity(view.id)
|
||||
: await api.archiveActivity(view.id);
|
||||
localOverride = updated;
|
||||
onChanged?.(updated);
|
||||
} catch {
|
||||
localOverride = null;
|
||||
} finally {
|
||||
archiveBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleHide() {
|
||||
if (!session.user || hideBusy) return;
|
||||
hideBusy = true;
|
||||
const wasHidden = view.viewer_hidden;
|
||||
localOverride = { ...view, viewer_hidden: !wasHidden };
|
||||
try {
|
||||
const updated = wasHidden
|
||||
? await api.unhideActivity(view.id)
|
||||
: await api.hideActivity(view.id);
|
||||
localOverride = updated;
|
||||
onChanged?.(updated);
|
||||
} catch {
|
||||
localOverride = null;
|
||||
} finally {
|
||||
hideBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Bookmarks -----------------------------------------------------------
|
||||
let bookmarkBusy = $state(false);
|
||||
async function toggleBookmark() {
|
||||
|
|
@ -374,6 +418,29 @@
|
|||
{#if canEdit && onEdit}
|
||||
<button type="button" onclick={startEdit}>Rediger</button>
|
||||
{/if}
|
||||
{#if session.user}
|
||||
<button
|
||||
type="button"
|
||||
onclick={toggleArchive}
|
||||
disabled={archiveBusy}
|
||||
aria-pressed={view.viewer_archived}
|
||||
title={view.viewer_archived ? 'Hent ut av arkivet' : 'Arkiver — gjem fra hovedlisten, behold for historikk'}
|
||||
>
|
||||
{view.viewer_archived ? '📦 Arkivert' : '📦 Arkiver'}
|
||||
</button>
|
||||
{/if}
|
||||
{#if session.user && !isOwner}
|
||||
<!-- Hide is non-owner only — owners can delete or archive instead. -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={toggleHide}
|
||||
disabled={hideBusy}
|
||||
aria-pressed={view.viewer_hidden}
|
||||
title={view.viewer_hidden ? 'Vis igjen' : 'Gjem — denne appellerer ikke til meg'}
|
||||
>
|
||||
{view.viewer_hidden ? '🙈 Skjult' : '🙈 Gjem'}
|
||||
</button>
|
||||
{/if}
|
||||
{#if canDelete}
|
||||
<button class="danger" type="button" onclick={del}>Slett</button>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -22,12 +22,28 @@
|
|||
let error: string | null = $state(null);
|
||||
let query = $state('');
|
||||
|
||||
onMount(load);
|
||||
// Two toggles control what shape of list we ask the server for:
|
||||
// - default: active rows only (excludes archived AND hidden)
|
||||
// - showArchived: include archived rows mixed in with active
|
||||
// - showHidden: include hidden rows mixed in
|
||||
// The user can flip these independently. Anonymous (publicOnly) viewers
|
||||
// never see the toggles since they have no archive/hide state.
|
||||
let showArchived = $state(false);
|
||||
let showHidden = $state(false);
|
||||
|
||||
onMount(() => load());
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
try {
|
||||
activities = await api.listActivities();
|
||||
activities = await api.listActivities(
|
||||
publicOnly
|
||||
? undefined
|
||||
: {
|
||||
...(showArchived ? { archived: '1' as const } : {}),
|
||||
...(showHidden ? { hidden: '1' as const } : {}),
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
error = 'Kunne ikke laste oppføringer.';
|
||||
} finally {
|
||||
|
|
@ -35,6 +51,14 @@
|
|||
}
|
||||
}
|
||||
|
||||
// Re-fetch from the server when the toggles flip — the WHERE clause is
|
||||
// server-side so we can't filter the existing array client-side.
|
||||
$effect(() => {
|
||||
void showArchived;
|
||||
void showHidden;
|
||||
if (!loading) load();
|
||||
});
|
||||
|
||||
function onCreated(a: Activity) {
|
||||
activities = [a, ...activities];
|
||||
showForm = false;
|
||||
|
|
@ -46,7 +70,13 @@
|
|||
}
|
||||
|
||||
function onChanged(a: Activity) {
|
||||
// When a row's archived/hidden state flips, it may no longer belong in
|
||||
// the current view (toggles are off → archived/hidden rows disappear).
|
||||
// Reload to get the authoritative list from the server.
|
||||
const needsRefetch =
|
||||
(a.viewer_archived && !showArchived) || (a.viewer_hidden && !showHidden);
|
||||
activities = activities.map((x) => (x.id === a.id ? a : x));
|
||||
if (needsRefetch) load();
|
||||
}
|
||||
|
||||
function onDeleted(id: string) {
|
||||
|
|
@ -201,6 +231,19 @@
|
|||
aria-label="Søk i aktiviteter"
|
||||
/>
|
||||
|
||||
{#if !publicOnly && session.user}
|
||||
<div class="row" style="gap: 1rem; margin-top: 0.5rem; font-size: 0.9rem;">
|
||||
<label class="row" style="gap: 0.35rem;">
|
||||
<input type="checkbox" bind:checked={showArchived} />
|
||||
<span class="muted">📦 Vis arkivert</span>
|
||||
</label>
|
||||
<label class="row" style="gap: 0.35rem;">
|
||||
<input type="checkbox" bind:checked={showHidden} />
|
||||
<span class="muted">🙈 Vis skjult</span>
|
||||
</label>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showForm}
|
||||
<div style="margin-top: 1rem;">
|
||||
<ActivityForm onCreated={onCreated} onCancel={() => (showForm = false)} />
|
||||
|
|
|
|||
|
|
@ -64,7 +64,13 @@ export const api = {
|
|||
}),
|
||||
|
||||
// --- activities -----------------------------------------------------------
|
||||
listActivities: () => http<Activity[]>('/activities'),
|
||||
listActivities: (opts?: { archived?: '1' | 'only'; hidden?: '1' | 'only' }) => {
|
||||
const qs = new URLSearchParams();
|
||||
if (opts?.archived) qs.set('archived', opts.archived);
|
||||
if (opts?.hidden) qs.set('hidden', opts.hidden);
|
||||
const q = qs.toString();
|
||||
return http<Activity[]>(`/activities${q ? `?${q}` : ''}`);
|
||||
},
|
||||
getActivity: (id: string) =>
|
||||
http<Activity>(`/activities/${encodeURIComponent(id)}`),
|
||||
createActivity: (body: CreateActivityRequest) =>
|
||||
|
|
@ -83,6 +89,14 @@ export const api = {
|
|||
http<Activity>(`/activities/${encodeURIComponent(id)}/done`, { method: 'POST' }),
|
||||
undoneActivity: (id: string) =>
|
||||
http<Activity>(`/activities/${encodeURIComponent(id)}/done`, { method: 'DELETE' }),
|
||||
archiveActivity: (id: string) =>
|
||||
http<Activity>(`/activities/${encodeURIComponent(id)}/archive`, { method: 'POST' }),
|
||||
unarchiveActivity: (id: string) =>
|
||||
http<Activity>(`/activities/${encodeURIComponent(id)}/archive`, { method: 'DELETE' }),
|
||||
hideActivity: (id: string) =>
|
||||
http<Activity>(`/activities/${encodeURIComponent(id)}/hide`, { method: 'POST' }),
|
||||
unhideActivity: (id: string) =>
|
||||
http<Activity>(`/activities/${encodeURIComponent(id)}/hide`, { method: 'DELETE' }),
|
||||
bookmarkActivity: (id: string) =>
|
||||
http<Activity>(`/activities/${encodeURIComponent(id)}/bookmark`, { method: 'POST' }),
|
||||
unbookmarkActivity: (id: string) =>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue