2 Commits

Author SHA1 Message Date
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
7 changed files with 264 additions and 14 deletions

View File

@@ -4,6 +4,7 @@ import { browser } from '$app/environment';
const TOKEN_KEY = 'eventsnap_jwt'; const TOKEN_KEY = 'eventsnap_jwt';
const PIN_KEY = 'eventsnap_pin'; const PIN_KEY = 'eventsnap_pin';
const USER_ID_KEY = 'eventsnap_user_id'; const USER_ID_KEY = 'eventsnap_user_id';
const DISPLAY_NAME_KEY = 'eventsnap_display_name';
export const isAuthenticated = writable(false); export const isAuthenticated = writable(false);
@@ -22,11 +23,28 @@ export function getUserId(): string | null {
return localStorage.getItem(USER_ID_KEY); 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; if (!browser) return;
localStorage.setItem(TOKEN_KEY, jwt); localStorage.setItem(TOKEN_KEY, jwt);
if (pin) localStorage.setItem(PIN_KEY, pin); if (pin) localStorage.setItem(PIN_KEY, pin);
localStorage.setItem(USER_ID_KEY, userId); localStorage.setItem(USER_ID_KEY, userId);
if (displayName) localStorage.setItem(DISPLAY_NAME_KEY, displayName);
isAuthenticated.set(true); 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; html: JobStatus;
} }
const HTML_GUIDE_KEY = 'eventsnap_html_guide_seen';
let status = $state<ExportStatus | null>(null); let status = $state<ExportStatus | null>(null);
let showHtmlGuide = $state(false); let showHtmlGuide = $state(false);
let loading = $state(true); let loading = $state(true);
@@ -75,10 +77,15 @@
} }
function downloadHtml() { function downloadHtml() {
showHtmlGuide = true; if (localStorage.getItem(HTML_GUIDE_KEY)) {
window.location.href = '/api/v1/export/html';
} else {
showHtmlGuide = true;
}
} }
function confirmHtmlDownload() { function confirmHtmlDownload() {
localStorage.setItem(HTML_GUIDE_KEY, '1');
showHtmlGuide = false; showHtmlGuide = false;
window.location.href = '/api/v1/export/html'; window.location.href = '/api/v1/export/html';
} }

View File

@@ -1,12 +1,13 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { getToken, clearAuth } from '$lib/auth'; import { getToken } from '$lib/auth';
import { api } from '$lib/api'; import { api } from '$lib/api';
import { connectSse, disconnectSse, onSseEvent } from '$lib/sse'; import { connectSse, disconnectSse, onSseEvent } from '$lib/sse';
import { onMount, onDestroy } from 'svelte'; import { onMount, onDestroy } from 'svelte';
import FeedGrid from '$lib/components/FeedGrid.svelte'; import FeedGrid from '$lib/components/FeedGrid.svelte';
import HashtagChips from '$lib/components/HashtagChips.svelte'; import HashtagChips from '$lib/components/HashtagChips.svelte';
import LightboxModal from '$lib/components/LightboxModal.svelte'; import LightboxModal from '$lib/components/LightboxModal.svelte';
import OnboardingGuide from '$lib/components/OnboardingGuide.svelte';
import type { FeedUpload, FeedResponse, HashtagCount } from '$lib/types'; import type { FeedUpload, FeedResponse, HashtagCount } from '$lib/types';
let uploads = $state<FeedUpload[]>([]); let uploads = $state<FeedUpload[]>([]);
@@ -152,11 +153,7 @@
if (u) selectedUpload = u; if (u) selectedUpload = u;
} }
async function handleLogout() {
try { await api.delete('/session'); } catch { /* ignore */ }
clearAuth();
goto('/join');
}
</script> </script>
<div class="min-h-screen bg-gray-50"> <div class="min-h-screen bg-gray-50">
@@ -171,12 +168,15 @@
> >
Hochladen Hochladen
</a> </a>
<button <a
onclick={handleLogout} href="/account"
class="text-sm text-gray-500 hover:text-gray-700" class="text-sm text-gray-500 hover:text-gray-700"
aria-label="Mein Konto"
> >
Abmelden <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">
</button> <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>
</div> </div>
@@ -224,3 +224,6 @@
onlike={handleLike} onlike={handleLike}
/> />
{/if} {/if}
<!-- First-visit onboarding guide -->
<OnboardingGuide />

View File

@@ -22,7 +22,7 @@
is_new: boolean; is_new: boolean;
}>('/join', { display_name: displayName.trim() }); }>('/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; pin = res.pin;
showPinModal = true; showPinModal = true;
} catch (e) { } catch (e) {

View File

@@ -25,7 +25,7 @@
user_id: string; user_id: string;
}>('/recover', { display_name: displayName.trim(), pin: pin.trim() }); }>('/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'); goto('/feed');
} catch (e) { } catch (e) {
if (e instanceof ApiError) { if (e instanceof ApiError) {