3 Commits

Author SHA1 Message Date
MechaCat02
a33b2c4d41 fix: serialize UserRole as lowercase in JWT
Add #[serde(rename_all = "lowercase")] to UserRole enum so JWT payloads
contain lowercase role strings ("guest", "host", "admin") matching what
the frontend route guards and getRole() comparisons expect.

Without this, JWTs contained PascalCase ("Guest", "Host", "Admin")
causing hosts and admins to be redirected to /join instead of their
dashboards.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 14:11:44 +02:00
MechaCat02
87b5aff478 feat: implement onboarding guide and HTML export guide
Add 4-step dismissible onboarding overlay shown on first feed visit
(welcome, upload, hashtags, PIN importance). Dismissed state persisted
in localStorage under eventsnap_guide_seen. Step indicator dots and
skip/continue buttons included.

Update HTML export guide modal to persist the eventsnap_html_guide_seen
flag: first download shows the instructions modal; subsequent clicks go
straight to download without interruption.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 21:13:14 +02:00
MechaCat02
75d186fad3 feat: implement My Account page
Add /account route showing display name (from localStorage), role badge,
session expiry decoded from JWT, and recovery PIN display with copy button.
Join and recover flows now persist display_name to localStorage via setAuth().
Feed header logout button replaced with person-icon link to /account;
logout is available from the account page.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 21:11:11 +02:00
8 changed files with 265 additions and 14 deletions

View File

@@ -4,6 +4,7 @@ use sqlx::PgPool;
use uuid::Uuid;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)]
#[serde(rename_all = "lowercase")]
#[sqlx(type_name = "user_role", rename_all = "lowercase")]
pub enum UserRole {
Guest,

View File

@@ -4,6 +4,7 @@ import { browser } from '$app/environment';
const TOKEN_KEY = 'eventsnap_jwt';
const PIN_KEY = 'eventsnap_pin';
const USER_ID_KEY = 'eventsnap_user_id';
const DISPLAY_NAME_KEY = 'eventsnap_display_name';
export const isAuthenticated = writable(false);
@@ -22,11 +23,28 @@ export function getUserId(): string | null {
return localStorage.getItem(USER_ID_KEY);
}
export function setAuth(jwt: string, pin: string | null, userId: string): void {
export function getDisplayName(): string | null {
if (!browser) return null;
return localStorage.getItem(DISPLAY_NAME_KEY);
}
export function getExpiry(): Date | null {
const token = getToken();
if (!token) return null;
try {
const payload = JSON.parse(atob(token.split('.')[1]));
return payload.exp ? new Date(payload.exp * 1000) : null;
} catch {
return null;
}
}
export function setAuth(jwt: string, pin: string | null, userId: string, displayName?: string): void {
if (!browser) return;
localStorage.setItem(TOKEN_KEY, jwt);
if (pin) localStorage.setItem(PIN_KEY, pin);
localStorage.setItem(USER_ID_KEY, userId);
if (displayName) localStorage.setItem(DISPLAY_NAME_KEY, displayName);
isAuthenticated.set(true);
}

View File

@@ -0,0 +1,85 @@
<script lang="ts">
import { browser } from '$app/environment';
const GUIDE_SEEN_KEY = 'eventsnap_guide_seen';
let visible = $state(false);
let step = $state(0);
const steps = [
{
icon: '📸',
title: 'Willkommen bei EventSnap!',
body: 'Hier kannst du Fotos und Videos mit allen Gästen teilen — in Echtzeit, ganz ohne App-Store.'
},
{
icon: '⬆️',
title: 'Fotos & Videos hochladen',
body: 'Tippe oben auf „Hochladen", um Fotos aus deiner Galerie oder direkt mit der Kamera aufzunehmen. Mehrere Dateien auf einmal sind kein Problem!'
},
{
icon: '#️⃣',
title: 'Hashtags nutzen',
body: 'Füge in deiner Bildunterschrift #hashtags ein, um Fotos zu gruppieren — z.B. #tanz, #buffet oder #reden. Du kannst danach filtern.'
},
{
icon: '🔑',
title: 'Deinen PIN merken!',
body: 'Du hast beim Registrieren einen 4-stelligen PIN erhalten. Speichere ihn — du brauchst ihn, um dein Konto auf einem anderen Gerät wiederherzustellen. Er ist immer unter „Mein Konto" zu finden.'
}
];
if (browser && !localStorage.getItem(GUIDE_SEEN_KEY)) {
visible = true;
}
function next() {
if (step < steps.length - 1) {
step++;
} else {
dismiss();
}
}
function dismiss() {
if (browser) localStorage.setItem(GUIDE_SEEN_KEY, '1');
visible = false;
}
</script>
{#if visible}
<!-- Backdrop -->
<div class="fixed inset-0 z-50 flex items-end justify-center bg-black/60 sm:items-center">
<div class="w-full max-w-sm rounded-t-3xl bg-white p-6 shadow-2xl sm:rounded-2xl">
<!-- Step indicator -->
<div class="mb-5 flex justify-center gap-1.5">
{#each steps as _, i}
<div class="h-1.5 rounded-full transition-all {i === step ? 'w-6 bg-blue-600' : 'w-1.5 bg-gray-200'}"></div>
{/each}
</div>
<!-- Content -->
<div class="mb-6 text-center">
<div class="mb-3 text-5xl">{steps[step].icon}</div>
<h2 class="mb-2 text-xl font-bold text-gray-900">{steps[step].title}</h2>
<p class="text-sm leading-relaxed text-gray-600">{steps[step].body}</p>
</div>
<!-- Buttons -->
<div class="flex gap-2">
<button
onclick={dismiss}
class="flex-1 rounded-xl border border-gray-200 py-3 text-sm text-gray-500 hover:bg-gray-50"
>
Überspringen
</button>
<button
onclick={next}
class="flex-1 rounded-xl bg-blue-600 py-3 text-sm font-semibold text-white hover:bg-blue-700"
>
{step < steps.length - 1 ? 'Weiter' : 'Los geht\'s!'}
</button>
</div>
</div>
</div>
{/if}

View File

@@ -0,0 +1,137 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { getToken, getPin, getDisplayName, getExpiry, getRole, clearAuth } from '$lib/auth';
import { api } from '$lib/api';
import { browser } from '$app/environment';
import { onMount } from 'svelte';
let pin = $state<string | null>(null);
let displayName = $state<string | null>(null);
let role = $state<'guest' | 'host' | 'admin' | null>(null);
let expiry = $state<Date | null>(null);
let copied = $state(false);
let pinCopied = $state(false);
onMount(() => {
if (!getToken()) {
goto('/join');
return;
}
pin = getPin();
displayName = getDisplayName();
role = getRole();
expiry = getExpiry();
});
function copyPin() {
if (!pin) return;
navigator.clipboard.writeText(pin);
pinCopied = true;
setTimeout(() => (pinCopied = false), 2000);
}
async function handleLogout() {
try { await api.delete('/session'); } catch { /* ignore */ }
clearAuth();
goto('/join');
}
function formatDate(d: Date): string {
return d.toLocaleDateString('de-DE', { day: '2-digit', month: 'long', year: 'numeric' });
}
function roleLabel(r: string | null): string {
switch (r) {
case 'admin': return 'Admin';
case 'host': return 'Gastgeber';
default: return 'Gast';
}
}
function roleColor(r: string | null): string {
switch (r) {
case 'admin': return 'bg-red-100 text-red-700';
case 'host': return 'bg-purple-100 text-purple-700';
default: return 'bg-blue-100 text-blue-700';
}
}
</script>
<div class="min-h-screen bg-gray-50">
<!-- Header -->
<div class="border-b border-gray-200 bg-white">
<div class="mx-auto flex max-w-lg items-center justify-between px-4 py-4">
<h1 class="text-xl font-bold text-gray-900">Mein Konto</h1>
<a href="/feed" class="text-sm text-blue-600 hover:underline">Zur Galerie</a>
</div>
</div>
<div class="mx-auto max-w-lg space-y-4 p-4">
<!-- Profile card -->
<div class="rounded-xl border border-gray-200 bg-white p-5">
<div class="flex items-center gap-3">
<div class="flex h-12 w-12 items-center justify-center rounded-full bg-blue-100 text-xl font-bold text-blue-600">
{#if displayName}
{displayName[0].toUpperCase()}
{:else}
?
{/if}
</div>
<div>
<p class="font-semibold text-gray-900">{displayName ?? 'Unbekannt'}</p>
<span class="inline-block rounded-full px-2 py-0.5 text-xs font-medium {roleColor(role)}">
{roleLabel(role)}
</span>
</div>
</div>
{#if expiry}
<p class="mt-3 text-xs text-gray-400">Sitzung gültig bis {formatDate(expiry)}</p>
{/if}
</div>
<!-- PIN card -->
<div class="rounded-xl border border-amber-200 bg-amber-50 p-5">
<h2 class="mb-1 font-semibold text-amber-900">Wiederherstellungs-PIN</h2>
<p class="mb-3 text-sm text-amber-700">
Du brauchst diesen PIN, um dein Konto auf einem anderen Gerät wiederherzustellen. Schreib ihn auf!
</p>
{#if pin}
<div class="flex items-center justify-between rounded-lg bg-white px-4 py-3 shadow-sm">
<span class="font-mono text-4xl font-bold tracking-widest text-gray-900">{pin}</span>
<button
onclick={copyPin}
class="rounded-md bg-amber-100 px-3 py-1.5 text-sm font-medium text-amber-800 transition hover:bg-amber-200"
>
{pinCopied ? 'Kopiert!' : 'Kopieren'}
</button>
</div>
{:else}
<div class="rounded-lg bg-white px-4 py-3 text-sm text-gray-400 shadow-sm">
PIN nicht gespeichert. Nutze die Wiederherstellungs-Seite, um dich mit deinem PIN anzumelden.
</div>
{/if}
</div>
<!-- Recovery hint -->
<div class="rounded-xl border border-gray-200 bg-white p-5">
<h2 class="mb-1 font-semibold text-gray-900">Gerät wechseln?</h2>
<p class="text-sm text-gray-600">
Auf einem anderen Gerät kannst du dein Konto mit deinem Namen und PIN wiederherstellen.
</p>
<a
href="/recover"
class="mt-3 inline-block text-sm font-medium text-blue-600 hover:underline"
>
Zur Wiederherstellungs-Seite →
</a>
</div>
<!-- Logout -->
<button
onclick={handleLogout}
class="w-full rounded-xl border border-red-200 bg-white py-3 text-sm font-medium text-red-600 transition hover:bg-red-50"
>
Abmelden
</button>
</div>
</div>

View File

@@ -16,6 +16,8 @@
html: JobStatus;
}
const HTML_GUIDE_KEY = 'eventsnap_html_guide_seen';
let status = $state<ExportStatus | null>(null);
let showHtmlGuide = $state(false);
let loading = $state(true);
@@ -75,10 +77,15 @@
}
function downloadHtml() {
showHtmlGuide = true;
if (localStorage.getItem(HTML_GUIDE_KEY)) {
window.location.href = '/api/v1/export/html';
} else {
showHtmlGuide = true;
}
}
function confirmHtmlDownload() {
localStorage.setItem(HTML_GUIDE_KEY, '1');
showHtmlGuide = false;
window.location.href = '/api/v1/export/html';
}

View File

@@ -1,12 +1,13 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { getToken, clearAuth } from '$lib/auth';
import { getToken } from '$lib/auth';
import { api } from '$lib/api';
import { connectSse, disconnectSse, onSseEvent } from '$lib/sse';
import { onMount, onDestroy } from 'svelte';
import FeedGrid from '$lib/components/FeedGrid.svelte';
import HashtagChips from '$lib/components/HashtagChips.svelte';
import LightboxModal from '$lib/components/LightboxModal.svelte';
import OnboardingGuide from '$lib/components/OnboardingGuide.svelte';
import type { FeedUpload, FeedResponse, HashtagCount } from '$lib/types';
let uploads = $state<FeedUpload[]>([]);
@@ -152,11 +153,7 @@
if (u) selectedUpload = u;
}
async function handleLogout() {
try { await api.delete('/session'); } catch { /* ignore */ }
clearAuth();
goto('/join');
}
</script>
<div class="min-h-screen bg-gray-50">
@@ -171,12 +168,15 @@
>
Hochladen
</a>
<button
onclick={handleLogout}
<a
href="/account"
class="text-sm text-gray-500 hover:text-gray-700"
aria-label="Mein Konto"
>
Abmelden
</button>
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z" />
</svg>
</a>
</div>
</div>
@@ -224,3 +224,6 @@
onlike={handleLike}
/>
{/if}
<!-- First-visit onboarding guide -->
<OnboardingGuide />

View File

@@ -22,7 +22,7 @@
is_new: boolean;
}>('/join', { display_name: displayName.trim() });
setAuth(res.jwt, res.pin, res.user_id);
setAuth(res.jwt, res.pin, res.user_id, displayName.trim());
pin = res.pin;
showPinModal = true;
} catch (e) {

View File

@@ -25,7 +25,7 @@
user_id: string;
}>('/recover', { display_name: displayName.trim(), pin: pin.trim() });
setAuth(res.jwt, pin.trim(), res.user_id);
setAuth(res.jwt, pin.trim(), res.user_id, displayName.trim());
goto('/feed');
} catch (e) {
if (e instanceof ApiError) {