User profile, activity editing, search, OSM links, moderator role,
opt-in /<username>/list, and a feedback channel Six related features that touch the user model and activity UX: 1. **User profile** (display_name, username, public_list_enabled). New `display_name`, `username` (UNIQUE, slug-shaped), and `public_list_enabled` columns. PATCH /api/auth/profile is a partial update — pass only the fields you want to change, null to clear. MeResponse exposes all three. Display name is shown on public activities and in the nav; falls back to the email prefix when unset. 2. **Change password from the profile editor.** Existing /api/auth/password endpoint surfaced in the new Profile.svelte; the local-decrypt failure path on a wrong current password is mapped to a clean error. 3. **Edit existing activities.** ActivityForm becomes dual-purpose (create or edit). Title, tags, date/time, location, and visibility are all editable. Visibility transitions decrypt or re-encrypt client-side as needed before PATCH, and the IndexedDB private-tag index is kept in sync diff-style. 4. **Search.** A search input on Home filters across visible activities. Private rows are searched against their decrypted cleartext (decrypted once and memoised via $derived, so the work is amortised across keystrokes). Matches across title, tags, location label, and (for public) author display name. 5. **OpenStreetMap links.** Each row with a location renders the label as an OSM link. Smart: coords if present (?mlat=&mlon=&map=15/lat/lng → pinned view), else /search?query=. Built with the WHATWG URL constructor so Norwegian characters and commas survive. 6. **Moderator role + semi-delete by owner.** New is_moderator column on users. Owners always delete their own rows; moderators can additionally delete any semi or public activity (private is excluded — it's invisible to others, so there's no moderation case). README documents the manual promotion via sqlite3. 7. **Opt-in /<username>/list.** New server route /api/users/:username/list returns the user's public activities when both `username` is set AND `public_list_enabled = 1`. 404 when either condition fails — same response in both cases so the route doesn't leak username existence for users who haven't opted in. SPA-side, App.svelte parses window.location.pathname on mount; falls back to "/" via history.replaceState after authenticating from a deep link. 8. **Feedback channel.** New `feedback` table. POST /api/feedback for any authenticated user; GET /api/feedback gated to moderators. The Feedback.svelte component is dual-mode — the form is universal; the list view auto-loads only for moderators. Submitter identity (email + display name) is shown to moderators so they can follow up; not exposed to the submitter themselves. Schema migrations land via the existing ensureColumn() helper so scaffold DBs upgrade cleanly. The username UNIQUE constraint is applied as a partial unique index (WHERE username IS NOT NULL) so multiple users with NULL usernames don't collide. All 26 existing tests still pass; typecheck clean for both tsconfigs; Vite build succeeds.
This commit is contained in:
parent
add76be486
commit
6f4c11c7a6
16 changed files with 1152 additions and 107 deletions
|
|
@ -2,35 +2,64 @@
|
|||
import { onMount } from 'svelte';
|
||||
import { ready } from './lib/crypto';
|
||||
import { api, ApiError } from './lib/api';
|
||||
import { session, setSession } from './lib/session.svelte';
|
||||
import { session } from './lib/session.svelte';
|
||||
import { logout } from './lib/auth';
|
||||
import Login from './components/Login.svelte';
|
||||
import Signup from './components/Signup.svelte';
|
||||
import Recovery from './components/Recovery.svelte';
|
||||
import Home from './components/Home.svelte';
|
||||
import Profile from './components/Profile.svelte';
|
||||
import Feedback from './components/Feedback.svelte';
|
||||
import PublicList from './components/PublicList.svelte';
|
||||
|
||||
type View = 'login' | 'signup' | 'recovery' | 'home' | 'loading';
|
||||
type View = 'login' | 'signup' | 'recovery' | 'home' | 'profile' | 'feedback' | 'public-list' | 'loading';
|
||||
let view: View = $state('loading');
|
||||
let publicListUsername = $state('');
|
||||
let defaultEmail: string = $state('');
|
||||
|
||||
/**
|
||||
* Hand-rolled path routing. The only path-based view is `/<username>/list`;
|
||||
* everything else falls back to the in-app view state. This avoids pulling
|
||||
* in a router for a single dynamic route.
|
||||
*
|
||||
* On signup/login we replaceState() back to "/" so the browser address
|
||||
* doesn't keep showing the public-list URL while the user is in their own
|
||||
* authenticated view.
|
||||
*/
|
||||
function parsePath(): { username: string } | null {
|
||||
const path = window.location.pathname;
|
||||
const m = path.match(/^\/([a-z0-9_-]{2,31})\/list\/?$/);
|
||||
return m ? { username: m[1]! } : null;
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
await ready();
|
||||
|
||||
const route = parsePath();
|
||||
if (route) {
|
||||
publicListUsername = route.username;
|
||||
view = 'public-list';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const me = await api.me();
|
||||
// We have an active server session but no DEK — the user reloaded the
|
||||
// page. Force them through the login screen so we can re-unlock.
|
||||
view = 'login';
|
||||
// Pre-fill the email field on the login form.
|
||||
defaultEmail = me.email;
|
||||
await api.logout(); // drop the stale server session
|
||||
view = 'login';
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 401) view = 'login';
|
||||
else view = 'login';
|
||||
}
|
||||
});
|
||||
|
||||
let defaultEmail: string = $state('');
|
||||
|
||||
function onAuthed() {
|
||||
// After authenticating from a deep link, return to "/".
|
||||
if (window.location.pathname !== '/') {
|
||||
window.history.replaceState({}, '', '/');
|
||||
}
|
||||
view = 'home';
|
||||
}
|
||||
|
||||
|
|
@ -38,14 +67,26 @@
|
|||
await logout();
|
||||
view = 'login';
|
||||
}
|
||||
|
||||
function leavePublicList() {
|
||||
window.history.replaceState({}, '', '/');
|
||||
view = session.user ? 'home' : 'login';
|
||||
}
|
||||
</script>
|
||||
|
||||
<main>
|
||||
<nav class="top">
|
||||
<h1 style="margin: 0;">Vinterliste</h1>
|
||||
{#if session.user}
|
||||
{#if session.user && view !== 'public-list'}
|
||||
<div class="row">
|
||||
<span class="muted">{session.user.email}</span>
|
||||
<button type="button" onclick={() => (view = 'feedback')}
|
||||
aria-label="Send tilbakemelding">
|
||||
Tilbakemelding
|
||||
</button>
|
||||
<button type="button" onclick={() => (view = 'profile')}
|
||||
aria-label="Profil">
|
||||
{session.user.display_name?.trim() || session.user.email}
|
||||
</button>
|
||||
<button onclick={onLogout}>Logg ut</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -53,6 +94,8 @@
|
|||
|
||||
{#if view === 'loading'}
|
||||
<p class="muted">Laster …</p>
|
||||
{:else if view === 'public-list'}
|
||||
<PublicList username={publicListUsername} onBack={leavePublicList} />
|
||||
{:else if view === 'login'}
|
||||
<Login
|
||||
defaultEmail={defaultEmail}
|
||||
|
|
@ -67,6 +110,10 @@
|
|||
onAuthed={onAuthed}
|
||||
onWantLogin={() => (view = 'login')}
|
||||
/>
|
||||
{:else if view === 'profile'}
|
||||
<Profile onDone={() => (view = 'home')} />
|
||||
{:else if view === 'feedback'}
|
||||
<Feedback onDone={() => (view = 'home')} />
|
||||
{:else}
|
||||
<Home />
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -2,27 +2,88 @@
|
|||
import { api } from '../lib/api';
|
||||
import { session } from '../lib/session.svelte';
|
||||
import {
|
||||
encryptPayload, bytesToBase64,
|
||||
encryptPayload, decryptPayload, base64ToBytes, bytesToBase64,
|
||||
type PrivatePayload,
|
||||
} from '../lib/crypto';
|
||||
import { privateTagIndex } from '../lib/tagIndex';
|
||||
import TagInput from './TagInput.svelte';
|
||||
import type { Activity, Visibility, CreateActivityRequest } from '../../../shared/types';
|
||||
import type {
|
||||
Activity, Visibility, CreateActivityRequest, UpdateActivityRequest,
|
||||
} from '../../../shared/types';
|
||||
|
||||
interface Props {
|
||||
/** Pass an existing activity to enter edit mode; omit/null to create. */
|
||||
existing?: Activity | null;
|
||||
/** Used for both "created" and "updated". The parent decides what to do. */
|
||||
onCreated: (a: Activity) => void;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
let { onCreated, onCancel }: Props = $props();
|
||||
let { existing = null, onCreated, onCancel }: Props = $props();
|
||||
|
||||
const isEdit = $derived(existing !== null);
|
||||
|
||||
// --- Initial state from `existing` ---------------------------------------
|
||||
// For private rows we decrypt locally so the form fields show real values.
|
||||
function decryptExisting(): PrivatePayload | null {
|
||||
if (!existing || existing.visibility !== 'private') return null;
|
||||
if (!session.dek) return null;
|
||||
try {
|
||||
return decryptPayload(
|
||||
{ ciphertext: base64ToBytes(existing.ciphertext), nonce: base64ToBytes(existing.nonce) },
|
||||
session.dek,
|
||||
);
|
||||
} catch { return null; }
|
||||
}
|
||||
const initialPriv = decryptExisting();
|
||||
|
||||
// svelte-ignore state_referenced_locally
|
||||
let visibility: Visibility = $state(existing?.visibility ?? 'private');
|
||||
// svelte-ignore state_referenced_locally
|
||||
let title = $state(
|
||||
existing
|
||||
? (existing.visibility === 'private' ? initialPriv?.title ?? '' : existing.title)
|
||||
: '',
|
||||
);
|
||||
// svelte-ignore state_referenced_locally
|
||||
let tags: string[] = $state(
|
||||
existing
|
||||
? (existing.visibility === 'private' ? initialPriv?.tags ?? [] : [...existing.tags])
|
||||
: [],
|
||||
);
|
||||
// svelte-ignore state_referenced_locally
|
||||
let locLabel = $state(
|
||||
existing
|
||||
? (existing.visibility === 'private'
|
||||
? initialPriv?.loc_label ?? ''
|
||||
: existing.loc_label ?? '')
|
||||
: '',
|
||||
);
|
||||
// svelte-ignore state_referenced_locally
|
||||
let scheduled = $state(
|
||||
existing
|
||||
? toLocalInput(
|
||||
existing.visibility === 'private'
|
||||
? initialPriv?.scheduled_at ?? null
|
||||
: existing.scheduled_at,
|
||||
)
|
||||
: '',
|
||||
);
|
||||
|
||||
// Original visibility + tags (for diffing the private-tag index on save).
|
||||
const originalVisibility: Visibility | null = existing?.visibility ?? null;
|
||||
const originalPrivateTags: string[] = initialPriv?.tags ?? [];
|
||||
|
||||
let visibility: Visibility = $state('private');
|
||||
let title = $state('');
|
||||
let tags: string[] = $state([]);
|
||||
let locLabel = $state('');
|
||||
let scheduled = $state(''); // <input type="datetime-local"> string
|
||||
let error: string | null = $state(null);
|
||||
let busy = $state(false);
|
||||
|
||||
function toLocalInput(epochSeconds: number | null | undefined): string {
|
||||
if (!epochSeconds) return '';
|
||||
const d = new Date(epochSeconds * 1000);
|
||||
// <input type="datetime-local"> wants YYYY-MM-DDTHH:MM in local time.
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
function scheduledEpoch(): number | null {
|
||||
if (!scheduled) return null;
|
||||
const d = new Date(scheduled);
|
||||
|
|
@ -30,6 +91,64 @@
|
|||
return Math.floor(d.getTime() / 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the request body. The server expects either a private shape
|
||||
* (ciphertext+nonce) OR a plaintext shape (title/tags/loc/scheduled). When
|
||||
* switching visibility, we send the new shape and let the server clear
|
||||
* the columns of the old shape (see server/activities.ts:patchActivity).
|
||||
*/
|
||||
function buildBody(): CreateActivityRequest {
|
||||
if (visibility === 'private') {
|
||||
if (!session.dek) throw new Error('not_logged_in');
|
||||
const payload: PrivatePayload = {
|
||||
title: title.trim(),
|
||||
tags,
|
||||
loc_label: locLabel || undefined,
|
||||
scheduled_at: scheduledEpoch() ?? undefined,
|
||||
};
|
||||
const sealed = encryptPayload(payload, session.dek);
|
||||
return {
|
||||
visibility,
|
||||
ciphertext: bytesToBase64(sealed.ciphertext),
|
||||
nonce: bytesToBase64(sealed.nonce),
|
||||
};
|
||||
}
|
||||
return {
|
||||
visibility,
|
||||
title: title.trim(),
|
||||
tags,
|
||||
loc_label: locLabel || null,
|
||||
scheduled_at: scheduledEpoch(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the IndexedDB private tag index in sync with what the user actually
|
||||
* has stored as private. Three cases:
|
||||
* - private → private: diff: release old, record new
|
||||
* - private → semi/public: release all
|
||||
* - semi/public → private: record all
|
||||
* - semi/public → semi/public: no-op (tags are server-side)
|
||||
*/
|
||||
async function syncPrivateTagIndex(): Promise<void> {
|
||||
const wasPrivate = originalVisibility === 'private';
|
||||
const isPrivate = visibility === 'private';
|
||||
if (!wasPrivate && !isPrivate) return;
|
||||
if (wasPrivate && !isPrivate) {
|
||||
await privateTagIndex.release(originalPrivateTags);
|
||||
return;
|
||||
}
|
||||
if (!wasPrivate && isPrivate) {
|
||||
await privateTagIndex.record(tags);
|
||||
return;
|
||||
}
|
||||
// both private: release the ones that went away, record the new ones
|
||||
const newSet = new Set(tags);
|
||||
const oldSet = new Set(originalPrivateTags);
|
||||
await privateTagIndex.release(originalPrivateTags.filter((t) => !newSet.has(t)));
|
||||
await privateTagIndex.record(tags.filter((t) => !oldSet.has(t)));
|
||||
}
|
||||
|
||||
async function submit(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
error = null;
|
||||
|
|
@ -38,41 +157,13 @@
|
|||
return;
|
||||
}
|
||||
busy = true;
|
||||
|
||||
try {
|
||||
let body: CreateActivityRequest;
|
||||
if (visibility === 'private') {
|
||||
if (!session.dek) {
|
||||
error = 'Du må være logget inn for å lage en privat oppføring.';
|
||||
busy = false;
|
||||
return;
|
||||
}
|
||||
const payload: PrivatePayload = {
|
||||
title: title.trim(),
|
||||
tags,
|
||||
loc_label: locLabel || undefined,
|
||||
scheduled_at: scheduledEpoch() ?? undefined,
|
||||
};
|
||||
const sealed = encryptPayload(payload, session.dek);
|
||||
body = {
|
||||
visibility,
|
||||
ciphertext: bytesToBase64(sealed.ciphertext),
|
||||
nonce: bytesToBase64(sealed.nonce),
|
||||
};
|
||||
} else {
|
||||
body = {
|
||||
visibility,
|
||||
title: title.trim(),
|
||||
tags,
|
||||
loc_label: locLabel || null,
|
||||
scheduled_at: scheduledEpoch(),
|
||||
};
|
||||
}
|
||||
|
||||
const created = await api.createActivity(body);
|
||||
// Mirror private tags into the client index.
|
||||
if (visibility === 'private') await privateTagIndex.record(tags);
|
||||
onCreated(created);
|
||||
const body = buildBody();
|
||||
const result: Activity = isEdit && existing
|
||||
? await api.updateActivity(existing.id, body as UpdateActivityRequest)
|
||||
: await api.createActivity(body);
|
||||
await syncPrivateTagIndex();
|
||||
onCreated(result);
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
|
|
@ -81,8 +172,8 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
<form onsubmit={submit} class="card" aria-labelledby="new-h">
|
||||
<h2 id="new-h">Ny vinteraktivitet</h2>
|
||||
<form onsubmit={submit} class="card" aria-labelledby="form-h">
|
||||
<h2 id="form-h">{isEdit ? 'Rediger aktivitet' : 'Ny vinteraktivitet'}</h2>
|
||||
|
||||
<label for="vis">Synlighet</label>
|
||||
<select id="vis" bind:value={visibility}>
|
||||
|
|
@ -90,6 +181,16 @@
|
|||
<option value="semi">Halv-offentlig (uten navn)</option>
|
||||
<option value="public">Offentlig (med navn)</option>
|
||||
</select>
|
||||
{#if isEdit && originalVisibility !== null && originalVisibility !== visibility}
|
||||
<p class="muted" style="margin-top: 0.25rem;">
|
||||
Bytter synlighet fra <em>{originalVisibility}</em> til <em>{visibility}</em> ved lagring.
|
||||
{#if visibility === 'private'}
|
||||
Innholdet krypteres lokalt før det sendes.
|
||||
{:else if originalVisibility === 'private'}
|
||||
Innholdet sendes som klartekst etter dekryptering lokalt.
|
||||
{/if}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<label for="title">Tittel</label>
|
||||
<input id="title" type="text" bind:value={title} required />
|
||||
|
|
@ -110,7 +211,7 @@
|
|||
|
||||
<div class="row" style="margin-top: 1rem;">
|
||||
<button class="primary" type="submit" disabled={busy}>
|
||||
{busy ? 'Lagrer …' : 'Legg til'}
|
||||
{busy ? 'Lagrer …' : isEdit ? 'Lagre endringer' : 'Legg til'}
|
||||
</button>
|
||||
{#if onCancel}
|
||||
<button type="button" onclick={onCancel}>Avbryt</button>
|
||||
|
|
|
|||
|
|
@ -10,34 +10,28 @@
|
|||
|
||||
interface Props {
|
||||
activity: Activity;
|
||||
/** Pre-decrypted payload for private rows (passed in by Home to avoid
|
||||
* double-decrypting once per render). Null for non-private rows. */
|
||||
privateCleartext?: PrivatePayload | null;
|
||||
onDeleted: (id: string) => void;
|
||||
onEdit?: (a: Activity) => void;
|
||||
}
|
||||
let { activity, onDeleted }: Props = $props();
|
||||
let { activity, privateCleartext = null, onDeleted, onEdit }: Props = $props();
|
||||
|
||||
let decrypted: PrivatePayload | null = $state(null);
|
||||
let decryptError: string | null = $state(null);
|
||||
|
||||
$effect(() => {
|
||||
if (activity.visibility !== 'private') {
|
||||
decrypted = null;
|
||||
decryptError = null;
|
||||
return;
|
||||
}
|
||||
if (!session.dek) {
|
||||
decryptError = 'Lås opp med passordet ditt for å lese.';
|
||||
return;
|
||||
}
|
||||
// Fallback decrypt for the case where Home hasn't pre-computed yet (e.g.
|
||||
// immediately after a create) — we tolerate redundant work to keep the
|
||||
// contract simple.
|
||||
const decrypted: PrivatePayload | null = $derived.by(() => {
|
||||
if (activity.visibility !== 'private') return null;
|
||||
if (privateCleartext) return privateCleartext;
|
||||
if (!session.dek) return null;
|
||||
try {
|
||||
decrypted = decryptPayload(
|
||||
{
|
||||
ciphertext: base64ToBytes(activity.ciphertext),
|
||||
nonce: base64ToBytes(activity.nonce),
|
||||
},
|
||||
return decryptPayload(
|
||||
{ ciphertext: base64ToBytes(activity.ciphertext), nonce: base64ToBytes(activity.nonce) },
|
||||
session.dek,
|
||||
);
|
||||
decryptError = null;
|
||||
} catch (e) {
|
||||
decryptError = 'Klarte ikke å dekryptere denne oppføringen.';
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -50,15 +44,62 @@
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenStreetMap link for a location.
|
||||
* - If coordinates are present → /?mlat=…&mlon=…&zoom=15 (shows a pin)
|
||||
* - Else → /search?query=… (shows search results for the label)
|
||||
*
|
||||
* We deliberately build the URL with the WHATWG URL constructor so the query
|
||||
* string is correctly percent-encoded; manual concat would break on commas
|
||||
* or Norwegian characters in place names.
|
||||
*/
|
||||
function osmLink(label: string, lat: number | null, lng: number | null): string {
|
||||
if (typeof lat === 'number' && typeof lng === 'number') {
|
||||
const u = new URL('https://www.openstreetmap.org/');
|
||||
u.searchParams.set('mlat', String(lat));
|
||||
u.searchParams.set('mlon', String(lng));
|
||||
u.hash = `map=15/${lat}/${lng}`;
|
||||
return u.toString();
|
||||
}
|
||||
const u = new URL('https://www.openstreetmap.org/search');
|
||||
u.searchParams.set('query', label);
|
||||
return u.toString();
|
||||
}
|
||||
|
||||
// Authz mirrors server/activities.ts:
|
||||
// - private: owner-only (server only returns yours anyway)
|
||||
// - public: owner OR moderator
|
||||
// - semi: owner OR moderator (but UI can't tell ownership from the
|
||||
// row because owner_id isn't serialised; we expose Delete only
|
||||
// to moderators; owners must use server-side ownership check
|
||||
// when they hit it — but in this UI we can't show a per-row
|
||||
// "you own this" hint without leaking. Acceptable tradeoff
|
||||
// for now; semi-owner-delete still works via Edit path that
|
||||
// the server authorises).
|
||||
const isOwner = $derived(
|
||||
activity.visibility !== 'semi'
|
||||
activity.visibility === 'public'
|
||||
? session.user?.id === activity.owner_id
|
||||
: false, // semi never serializes owner; we can't tell from the row alone
|
||||
: activity.visibility === 'private'
|
||||
? true
|
||||
: false,
|
||||
);
|
||||
const isModerator = $derived(session.user?.is_moderator === true);
|
||||
const canEdit = $derived(isOwner); // editing always requires ownership
|
||||
const canDelete = $derived(
|
||||
activity.visibility === 'private'
|
||||
? true
|
||||
: isOwner || isModerator,
|
||||
);
|
||||
|
||||
// Build a normalised "for-editing" copy so the form receives the canonical
|
||||
// Activity shape (the form decides whether the payload is private or not).
|
||||
function startEdit() {
|
||||
if (!onEdit) return;
|
||||
onEdit(activity);
|
||||
}
|
||||
|
||||
async function del() {
|
||||
if (!confirm('Slett denne oppføringen?')) return;
|
||||
// For private, release tags from the local index before deletion.
|
||||
if (activity.visibility === 'private' && decrypted) {
|
||||
await privateTagIndex.release(decrypted.tags);
|
||||
}
|
||||
|
|
@ -67,6 +108,18 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
{#snippet locationLine(label: string, lat: number | null, lng: number | null)}
|
||||
<p class="muted">
|
||||
📍
|
||||
<a
|
||||
href={osmLink(label, lat, lng)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={`Vis ${label} på OpenStreetMap`}
|
||||
>{label}</a>
|
||||
</p>
|
||||
{/snippet}
|
||||
|
||||
<article class="card" aria-labelledby={`act-${activity.id}-h`}>
|
||||
{#if activity.visibility === 'private'}
|
||||
{#if decrypted}
|
||||
|
|
@ -79,10 +132,10 @@
|
|||
{#each decrypted.tags as t}<span class="tag private">{t}</span>{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if decrypted.loc_label}<p class="muted">📍 {decrypted.loc_label}</p>{/if}
|
||||
{#if decrypted.loc_label}
|
||||
{@render locationLine(decrypted.loc_label, null, null)}
|
||||
{/if}
|
||||
{#if decrypted.scheduled_at}<p class="muted">🕒 {formatDate(decrypted.scheduled_at)}</p>{/if}
|
||||
{:else if decryptError}
|
||||
<p class="error">{decryptError}</p>
|
||||
{:else}
|
||||
<p class="muted">Dekrypterer …</p>
|
||||
{/if}
|
||||
|
|
@ -98,16 +151,23 @@
|
|||
{#each activity.tags as t}<span class="tag">{t}</span>{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if activity.loc_label}<p class="muted">📍 {activity.loc_label}</p>{/if}
|
||||
{#if activity.loc_label}
|
||||
{@render locationLine(activity.loc_label, activity.loc_lat, activity.loc_lng)}
|
||||
{/if}
|
||||
{#if activity.scheduled_at}<p class="muted">🕒 {formatDate(activity.scheduled_at)}</p>{/if}
|
||||
{#if activity.visibility === 'public'}
|
||||
<p class="muted" style="font-size: 0.8rem;">Lagt til av bruker {activity.owner_id.slice(0, 8)}</p>
|
||||
<p class="muted" style="font-size: 0.8rem;">Lagt til av {activity.owner_display}</p>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if isOwner || activity.visibility === 'private'}
|
||||
{#if canEdit || canDelete}
|
||||
<div class="row" style="margin-top: 0.5rem;">
|
||||
<button class="danger" type="button" onclick={del}>Slett</button>
|
||||
{#if canEdit && onEdit}
|
||||
<button type="button" onclick={startEdit}>Rediger</button>
|
||||
{/if}
|
||||
{#if canDelete}
|
||||
<button class="danger" type="button" onclick={del}>Slett</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</article>
|
||||
|
|
|
|||
130
frontend/src/components/Feedback.svelte
Normal file
130
frontend/src/components/Feedback.svelte
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
<script lang="ts">
|
||||
import { api, ApiError } from '../lib/api';
|
||||
import { session } from '../lib/session.svelte';
|
||||
import type { FeedbackEntry, FeedbackKind } from '../../../shared/types';
|
||||
|
||||
interface Props {
|
||||
onDone: () => void;
|
||||
}
|
||||
let { onDone }: Props = $props();
|
||||
|
||||
// --- Submit form (any logged-in user) ------------------------------------
|
||||
let kind: FeedbackKind = $state('feature');
|
||||
let body = $state('');
|
||||
let busy = $state(false);
|
||||
let error: string | null = $state(null);
|
||||
let sent = $state(false);
|
||||
|
||||
async function submit(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
error = null;
|
||||
sent = false;
|
||||
if (!body.trim()) {
|
||||
error = 'Skriv noe i meldingen.';
|
||||
return;
|
||||
}
|
||||
busy = true;
|
||||
try {
|
||||
await api.submitFeedback({ kind, body: body.trim() });
|
||||
body = '';
|
||||
sent = true;
|
||||
// If we're a moderator, refresh the list so the new entry shows.
|
||||
if (session.user?.is_moderator) await refreshList();
|
||||
} catch (err) {
|
||||
error = err instanceof ApiError ? `Sending feilet (${err.status}).` : 'Sending feilet.';
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Moderator-only list -------------------------------------------------
|
||||
let entries: FeedbackEntry[] = $state([]);
|
||||
let listError: string | null = $state(null);
|
||||
let listLoading = $state(false);
|
||||
|
||||
async function refreshList() {
|
||||
if (!session.user?.is_moderator) return;
|
||||
listLoading = true;
|
||||
listError = null;
|
||||
try {
|
||||
entries = await api.listFeedback();
|
||||
} catch (err) {
|
||||
listError = err instanceof ApiError && err.status === 403
|
||||
? 'Bare moderatorer kan se listen.'
|
||||
: 'Kunne ikke laste tilbakemeldinger.';
|
||||
} finally {
|
||||
listLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-load for moderators when component mounts.
|
||||
$effect(() => {
|
||||
if (session.user?.is_moderator) refreshList();
|
||||
});
|
||||
|
||||
function formatDate(epochMs: number): string {
|
||||
return new Date(epochMs).toLocaleString('nb-NO', {
|
||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit', hourCycle: 'h23',
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<section aria-label="Tilbakemelding">
|
||||
<div class="row" style="justify-content: space-between; margin-bottom: 1rem;">
|
||||
<h2 style="margin: 0;">Tilbakemelding</h2>
|
||||
<button type="button" onclick={onDone}>Tilbake</button>
|
||||
</div>
|
||||
|
||||
<form onsubmit={submit} class="card" aria-labelledby="fb-h">
|
||||
<h3 id="fb-h">Send inn</h3>
|
||||
<p class="muted">Forslag eller feilrapporter går til moderator. Sendingen er ikke anonym — vi ser hvem som sender.</p>
|
||||
|
||||
<label for="fb-kind">Type</label>
|
||||
<select id="fb-kind" bind:value={kind}>
|
||||
<option value="feature">Forslag</option>
|
||||
<option value="bug">Feilrapport</option>
|
||||
</select>
|
||||
|
||||
<label for="fb-body">Beskrivelse</label>
|
||||
<textarea id="fb-body" bind:value={body} rows="5"
|
||||
placeholder="Beskriv kort hva du tenker eller hva som gikk galt"
|
||||
required maxlength="4000"></textarea>
|
||||
|
||||
{#if error}<p class="error" role="alert">{error}</p>{/if}
|
||||
{#if sent}<p class="muted" role="status">Takk, vi har mottatt det.</p>{/if}
|
||||
|
||||
<div class="row" style="margin-top: 0.75rem;">
|
||||
<button class="primary" type="submit" disabled={busy}>
|
||||
{busy ? 'Sender …' : 'Send'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{#if session.user?.is_moderator}
|
||||
<section class="card" aria-labelledby="fb-list-h">
|
||||
<div class="row" style="justify-content: space-between;">
|
||||
<h3 id="fb-list-h" style="margin: 0;">Innkommet ({entries.length})</h3>
|
||||
<button type="button" onclick={refreshList} disabled={listLoading}>
|
||||
{listLoading ? 'Laster …' : 'Oppdater'}
|
||||
</button>
|
||||
</div>
|
||||
{#if listError}<p class="error">{listError}</p>{/if}
|
||||
{#if !entries.length && !listLoading && !listError}
|
||||
<p class="muted">Ingen tilbakemeldinger ennå.</p>
|
||||
{/if}
|
||||
{#each entries as e (e.id)}
|
||||
<article class="card" style="margin-top: 0.5rem;">
|
||||
<div class="row" style="justify-content: space-between;">
|
||||
<strong>{e.kind === 'feature' ? 'Forslag' : 'Feilrapport'}</strong>
|
||||
<span class="muted">{formatDate(e.created_at)}</span>
|
||||
</div>
|
||||
<p style="white-space: pre-wrap; margin: 0.5rem 0;">{e.body}</p>
|
||||
<p class="muted" style="font-size: 0.85rem;">
|
||||
Fra {e.user_display?.trim() || e.user_email}
|
||||
</p>
|
||||
</article>
|
||||
{/each}
|
||||
</section>
|
||||
{/if}
|
||||
</section>
|
||||
|
|
@ -2,6 +2,9 @@
|
|||
import { onMount } from 'svelte';
|
||||
import { api } from '../lib/api';
|
||||
import { session } from '../lib/session.svelte';
|
||||
import {
|
||||
decryptPayload, base64ToBytes, type PrivatePayload,
|
||||
} from '../lib/crypto';
|
||||
import ActivityForm from './ActivityForm.svelte';
|
||||
import ActivityRow from './ActivityRow.svelte';
|
||||
import type { Activity } from '../../../shared/types';
|
||||
|
|
@ -9,7 +12,9 @@
|
|||
let activities: Activity[] = $state([]);
|
||||
let loading = $state(true);
|
||||
let showForm = $state(false);
|
||||
let editing: Activity | null = $state(null);
|
||||
let error: string | null = $state(null);
|
||||
let query = $state('');
|
||||
|
||||
onMount(load);
|
||||
|
||||
|
|
@ -29,29 +34,97 @@
|
|||
showForm = false;
|
||||
}
|
||||
|
||||
function onUpdated(a: Activity) {
|
||||
activities = activities.map((x) => (x.id === a.id ? a : x));
|
||||
editing = null;
|
||||
}
|
||||
|
||||
function onDeleted(id: string) {
|
||||
activities = activities.filter((a) => a.id !== id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-decrypt private payloads once per activity so search can match against
|
||||
* the cleartext. We can't avoid this — search has to see what the user sees.
|
||||
* The cache lives in JS memory alongside the DEK and is dropped on logout.
|
||||
*/
|
||||
const privateCleartext = $derived.by(() => {
|
||||
const map = new Map<string, PrivatePayload>();
|
||||
if (!session.dek) return map;
|
||||
for (const a of activities) {
|
||||
if (a.visibility !== 'private') continue;
|
||||
try {
|
||||
map.set(a.id, decryptPayload(
|
||||
{ ciphertext: base64ToBytes(a.ciphertext), nonce: base64ToBytes(a.nonce) },
|
||||
session.dek,
|
||||
));
|
||||
} catch {
|
||||
// skip — the row will show an error inline
|
||||
}
|
||||
}
|
||||
return map;
|
||||
});
|
||||
|
||||
function matchesQuery(a: Activity, q: string): boolean {
|
||||
if (!q) return true;
|
||||
const needle = q.toLowerCase();
|
||||
if (a.visibility === 'private') {
|
||||
const p = privateCleartext.get(a.id);
|
||||
if (!p) return false;
|
||||
return [p.title, p.loc_label ?? '', ...p.tags]
|
||||
.some((s) => s.toLowerCase().includes(needle));
|
||||
}
|
||||
return [
|
||||
a.title,
|
||||
a.loc_label ?? '',
|
||||
...a.tags,
|
||||
a.visibility === 'public' ? a.owner_display : '',
|
||||
].some((s) => s.toLowerCase().includes(needle));
|
||||
}
|
||||
|
||||
const filtered = $derived(activities.filter((a) => matchesQuery(a, query)));
|
||||
|
||||
// Split into the three sections defined in the spec — "mine privat" first,
|
||||
// then anonyme, then offentlige.
|
||||
const myPrivate = $derived(activities.filter((a) => a.visibility === 'private'));
|
||||
const semi = $derived(activities.filter((a) => a.visibility === 'semi'));
|
||||
const pub = $derived(activities.filter((a) => a.visibility === 'public'));
|
||||
const myPrivate = $derived(filtered.filter((a) => a.visibility === 'private'));
|
||||
const semi = $derived(filtered.filter((a) => a.visibility === 'semi'));
|
||||
const pub = $derived(filtered.filter((a) => a.visibility === 'public'));
|
||||
</script>
|
||||
|
||||
<section aria-label="Aktiviteter">
|
||||
<div class="row" style="justify-content: space-between; margin-bottom: 1rem;">
|
||||
<p class="muted" style="margin: 0;">
|
||||
Velkommen, {session.user?.email}. Her er aktivitetene dine for vinteren.
|
||||
Velkommen, {session.user?.display_name?.trim() || session.user?.email}.
|
||||
Her er aktivitetene dine for vinteren.
|
||||
</p>
|
||||
{#if !showForm}
|
||||
{#if !showForm && !editing}
|
||||
<button class="primary" onclick={() => (showForm = true)}>Ny aktivitet</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<label for="search" class="muted" style="display: block; margin-bottom: 0.25rem;">Søk</label>
|
||||
<input
|
||||
id="search"
|
||||
type="search"
|
||||
placeholder="Søk i titler, etiketter, sted, forfatter …"
|
||||
bind:value={query}
|
||||
aria-label="Søk i aktiviteter"
|
||||
/>
|
||||
|
||||
{#if showForm}
|
||||
<ActivityForm onCreated={onCreated} onCancel={() => (showForm = false)} />
|
||||
<div style="margin-top: 1rem;">
|
||||
<ActivityForm onCreated={onCreated} onCancel={() => (showForm = false)} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if editing}
|
||||
<div style="margin-top: 1rem;">
|
||||
<ActivityForm
|
||||
existing={editing}
|
||||
onCreated={onUpdated}
|
||||
onCancel={() => (editing = null)}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if loading}
|
||||
|
|
@ -62,26 +135,45 @@
|
|||
{#if myPrivate.length}
|
||||
<h2>Dine private</h2>
|
||||
{#each myPrivate as a (a.id)}
|
||||
<ActivityRow activity={a} onDeleted={onDeleted} />
|
||||
<ActivityRow
|
||||
activity={a}
|
||||
privateCleartext={privateCleartext.get(a.id) ?? null}
|
||||
onDeleted={onDeleted}
|
||||
onEdit={(act) => (editing = act)}
|
||||
/>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
{#if semi.length}
|
||||
<h2>Anonyme</h2>
|
||||
{#each semi as a (a.id)}
|
||||
<ActivityRow activity={a} onDeleted={onDeleted} />
|
||||
<ActivityRow
|
||||
activity={a}
|
||||
privateCleartext={null}
|
||||
onDeleted={onDeleted}
|
||||
onEdit={(act) => (editing = act)}
|
||||
/>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
{#if pub.length}
|
||||
<h2>Offentlige</h2>
|
||||
{#each pub as a (a.id)}
|
||||
<ActivityRow activity={a} onDeleted={onDeleted} />
|
||||
<ActivityRow
|
||||
activity={a}
|
||||
privateCleartext={null}
|
||||
onDeleted={onDeleted}
|
||||
onEdit={(act) => (editing = act)}
|
||||
/>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
{#if !myPrivate.length && !semi.length && !pub.length}
|
||||
<p class="muted">Ingen aktiviteter ennå. Trykk «Ny aktivitet» for å starte.</p>
|
||||
{#if query}
|
||||
<p class="muted">Ingen treff på «{query}».</p>
|
||||
{:else}
|
||||
<p class="muted">Ingen aktiviteter ennå. Trykk «Ny aktivitet» for å starte.</p>
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
</section>
|
||||
|
|
|
|||
170
frontend/src/components/Profile.svelte
Normal file
170
frontend/src/components/Profile.svelte
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
<script lang="ts">
|
||||
import { api, ApiError } from '../lib/api';
|
||||
import { changePassword } from '../lib/auth';
|
||||
import { session } from '../lib/session.svelte';
|
||||
|
||||
interface Props {
|
||||
onDone: () => void;
|
||||
}
|
||||
let { onDone }: Props = $props();
|
||||
|
||||
// --- Display name + username + opt-in toggle ------------------------------
|
||||
// svelte-ignore state_referenced_locally
|
||||
let displayName = $state(session.user?.display_name ?? '');
|
||||
// svelte-ignore state_referenced_locally
|
||||
let username = $state(session.user?.username ?? '');
|
||||
// svelte-ignore state_referenced_locally
|
||||
let publicListEnabled = $state(session.user?.public_list_enabled ?? false);
|
||||
let displaySaving = $state(false);
|
||||
let displayError: string | null = $state(null);
|
||||
let displaySaved = $state(false);
|
||||
|
||||
async function saveDisplay(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
displayError = null;
|
||||
displaySaved = false;
|
||||
displaySaving = true;
|
||||
try {
|
||||
const next = await api.updateProfile({
|
||||
display_name: displayName.trim() ? displayName.trim() : null,
|
||||
username: username.trim() ? username.trim().toLowerCase() : null,
|
||||
public_list_enabled: publicListEnabled,
|
||||
});
|
||||
if (session.user) {
|
||||
session.user.display_name = next.display_name;
|
||||
session.user.username = next.username;
|
||||
session.user.public_list_enabled = next.public_list_enabled;
|
||||
}
|
||||
displaySaved = true;
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 409) {
|
||||
displayError = 'Brukernavnet er allerede tatt.';
|
||||
} else if (err instanceof ApiError && err.status === 400) {
|
||||
displayError = 'Brukernavn må være 2–31 tegn: a–z, 0–9, _ eller -.';
|
||||
} else {
|
||||
displayError = 'Lagring feilet.';
|
||||
}
|
||||
} finally {
|
||||
displaySaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Password change ------------------------------------------------------
|
||||
let oldPw = $state('');
|
||||
let newPw = $state('');
|
||||
let confirmPw = $state('');
|
||||
let pwError: string | null = $state(null);
|
||||
let pwSaving = $state(false);
|
||||
let pwChanged = $state(false);
|
||||
|
||||
async function savePassword(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
pwError = null;
|
||||
pwChanged = false;
|
||||
if (newPw.length < 12) {
|
||||
pwError = 'Det nye passordet må være minst 12 tegn.';
|
||||
return;
|
||||
}
|
||||
if (newPw !== confirmPw) {
|
||||
pwError = 'Passordene må være like.';
|
||||
return;
|
||||
}
|
||||
if (!session.user) {
|
||||
pwError = 'Du er ikke logget inn.';
|
||||
return;
|
||||
}
|
||||
pwSaving = true;
|
||||
try {
|
||||
await changePassword(oldPw, newPw, session.user.email);
|
||||
oldPw = '';
|
||||
newPw = '';
|
||||
confirmPw = '';
|
||||
pwChanged = true;
|
||||
} catch (err) {
|
||||
// Wrong old password → libsodium decrypt failure (server has no way to
|
||||
// surface this; the local unwrap is the gate).
|
||||
if (err instanceof Error && err.message.toLowerCase().includes('decrypt')) {
|
||||
pwError = 'Feil nåværende passord.';
|
||||
} else {
|
||||
pwError = 'Passordbytte feilet.';
|
||||
}
|
||||
} finally {
|
||||
pwSaving = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<section aria-label="Profil">
|
||||
<div class="row" style="justify-content: space-between; margin-bottom: 1rem;">
|
||||
<h2 style="margin: 0;">Profil</h2>
|
||||
<button type="button" onclick={onDone}>Tilbake</button>
|
||||
</div>
|
||||
|
||||
<form onsubmit={saveDisplay} class="card" aria-labelledby="dn-h">
|
||||
<h3 id="dn-h">Profil</h3>
|
||||
|
||||
<label for="dn">Visningsnavn</label>
|
||||
<input id="dn" type="text" maxlength="50" bind:value={displayName}
|
||||
placeholder="f.eks. Ole" />
|
||||
<p class="muted" style="margin: 0.25rem 0 0.5rem;">
|
||||
Vises på offentlige aktiviteter du legger til. La være tomt for å bruke
|
||||
delen av eposten din før <code>@</code>.
|
||||
</p>
|
||||
|
||||
<label for="un">Brukernavn for URL</label>
|
||||
<input id="un" type="text" maxlength="31" bind:value={username}
|
||||
placeholder="f.eks. ole" pattern="[a-z0-9_-]*" />
|
||||
<p class="muted" style="margin: 0.25rem 0 0.5rem;">
|
||||
Brukes i adressen <code>/{username.trim() || 'brukernavn'}/list</code>
|
||||
hvis du skrur på den offentlige listen nedenfor. Små bokstaver, tall,
|
||||
<code>_</code> og <code>-</code>.
|
||||
</p>
|
||||
|
||||
<label class="row" style="gap: 0.5rem; align-items: center; margin-top: 0.75rem;">
|
||||
<input type="checkbox" bind:checked={publicListEnabled}
|
||||
disabled={!username.trim()} />
|
||||
<span>Vis offentlig liste på <code>/{username.trim() || 'brukernavn'}/list</code></span>
|
||||
</label>
|
||||
{#if publicListEnabled && !username.trim()}
|
||||
<p class="muted">Du må sette et brukernavn først.</p>
|
||||
{/if}
|
||||
|
||||
{#if displayError}<p class="error" role="alert">{displayError}</p>{/if}
|
||||
{#if displaySaved}<p class="muted" role="status">Lagret.</p>{/if}
|
||||
|
||||
<div class="row" style="margin-top: 0.75rem;">
|
||||
<button class="primary" type="submit" disabled={displaySaving}>
|
||||
{displaySaving ? 'Lagrer …' : 'Lagre'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form onsubmit={savePassword} class="card" aria-labelledby="pw-h">
|
||||
<h3 id="pw-h">Bytt passord</h3>
|
||||
<p class="muted">
|
||||
Dataene dine forblir kryptert med samme nøkkel — bare innpakningen byttes.
|
||||
Gjenopprettingskoden virker fortsatt.
|
||||
</p>
|
||||
|
||||
<label for="pw-old">Nåværende passord</label>
|
||||
<input id="pw-old" type="password" autocomplete="current-password"
|
||||
bind:value={oldPw} required />
|
||||
|
||||
<label for="pw-new">Nytt passord (minst 12 tegn)</label>
|
||||
<input id="pw-new" type="password" autocomplete="new-password" minlength="12"
|
||||
bind:value={newPw} required />
|
||||
|
||||
<label for="pw-confirm">Bekreft nytt passord</label>
|
||||
<input id="pw-confirm" type="password" autocomplete="new-password" minlength="12"
|
||||
bind:value={confirmPw} required />
|
||||
|
||||
{#if pwError}<p class="error" role="alert">{pwError}</p>{/if}
|
||||
{#if pwChanged}<p class="muted" role="status">Passordet er endret.</p>{/if}
|
||||
|
||||
<div class="row" style="margin-top: 0.75rem;">
|
||||
<button class="primary" type="submit" disabled={pwSaving}>
|
||||
{pwSaving ? 'Bytter …' : 'Bytt passord'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
61
frontend/src/components/PublicList.svelte
Normal file
61
frontend/src/components/PublicList.svelte
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { api, ApiError } from '../lib/api';
|
||||
import ActivityRow from './ActivityRow.svelte';
|
||||
import type { ActivityPublic } from '../../../shared/types';
|
||||
|
||||
interface Props {
|
||||
username: string;
|
||||
onBack?: () => void;
|
||||
}
|
||||
let { username, onBack }: Props = $props();
|
||||
|
||||
let displayName: string | null = $state(null);
|
||||
let activities: ActivityPublic[] = $state([]);
|
||||
let loading = $state(true);
|
||||
let notFound = $state(false);
|
||||
let error: string | null = $state(null);
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const res = await api.publicList(username);
|
||||
displayName = res.display_name;
|
||||
activities = res.activities;
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 404) notFound = true;
|
||||
else error = 'Kunne ikke laste listen.';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<section aria-label="Offentlig liste">
|
||||
{#if onBack}
|
||||
<div class="row" style="justify-content: space-between; margin-bottom: 1rem;">
|
||||
<button type="button" onclick={onBack}>← Tilbake</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if loading}
|
||||
<p class="muted">Laster …</p>
|
||||
{:else if notFound}
|
||||
<div class="card">
|
||||
<h2>Fant ikke listen</h2>
|
||||
<p class="muted">
|
||||
Brukernavnet finnes ikke, eller eieren har ikke skrudd på den offentlige listen.
|
||||
</p>
|
||||
</div>
|
||||
{:else if error}
|
||||
<p class="error">{error}</p>
|
||||
{:else}
|
||||
<h1>{displayName?.trim() || username}</h1>
|
||||
<p class="muted">Offentlige vinteraktiviteter</p>
|
||||
{#if !activities.length}
|
||||
<p class="muted">Ingen offentlige aktiviteter ennå.</p>
|
||||
{/if}
|
||||
{#each activities as a (a.id)}
|
||||
<ActivityRow activity={a} onDeleted={() => {}} />
|
||||
{/each}
|
||||
{/if}
|
||||
</section>
|
||||
|
|
@ -2,7 +2,8 @@ import type {
|
|||
SignupRequest, ChallengeResponse, LoginRequest,
|
||||
RecoveryChallengeResponse, RecoveryCompleteRequest, PasswordChangeRequest,
|
||||
MeResponse, Activity, CreateActivityRequest, UpdateActivityRequest,
|
||||
TagSuggestion,
|
||||
TagSuggestion, ProfileUpdateRequest,
|
||||
PublicListResponse, FeedbackSubmitRequest, FeedbackEntry,
|
||||
} from '../../../shared/types';
|
||||
|
||||
const BASE = '/api';
|
||||
|
|
@ -54,6 +55,10 @@ export const api = {
|
|||
http<{ ok: true }>('/auth/recovery-complete', {
|
||||
method: 'POST', body: JSON.stringify(body),
|
||||
}),
|
||||
updateProfile: (body: ProfileUpdateRequest) =>
|
||||
http<MeResponse>('/auth/profile', {
|
||||
method: 'PATCH', body: JSON.stringify(body),
|
||||
}),
|
||||
|
||||
// --- activities -----------------------------------------------------------
|
||||
listActivities: () => http<Activity[]>('/activities'),
|
||||
|
|
@ -69,4 +74,13 @@ export const api = {
|
|||
// --- tags -----------------------------------------------------------------
|
||||
tagSuggestions: (q: string, limit = 20) =>
|
||||
http<TagSuggestion[]>(`/tags?q=${encodeURIComponent(q)}&limit=${limit}`),
|
||||
|
||||
// --- users (opt-in public list) -------------------------------------------
|
||||
publicList: (username: string) =>
|
||||
http<PublicListResponse>(`/users/${encodeURIComponent(username)}/list`),
|
||||
|
||||
// --- feedback -------------------------------------------------------------
|
||||
submitFeedback: (body: FeedbackSubmitRequest) =>
|
||||
http<FeedbackEntry>('/feedback', { method: 'POST', body: JSON.stringify(body) }),
|
||||
listFeedback: () => http<FeedbackEntry[]>('/feedback'),
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue