diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..3154583 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,9 @@ +{ + "permissions": { + "allow": [ + "Bash(cargo check *)", + "Bash(cargo clippy *)", + "Bash(git --no-pager diff *)" + ] + } +} diff --git a/.gitignore b/.gitignore index a34803d..2f359bf 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,6 @@ e2e/.env.test # OS .DS_Store Thumbs.db + +# Claude Code personal (per-user) settings — shared settings.json IS committed +.claude/settings.local.json diff --git a/e2e/shots.mjs b/e2e/shots.mjs new file mode 100644 index 0000000..dcf2b6d --- /dev/null +++ b/e2e/shots.mjs @@ -0,0 +1,175 @@ +// One-off: seed a realistic feed, then capture mobile screenshots (light + dark). +// Run from the e2e dir so @playwright/test and pg resolve. +import { chromium, devices } from '@playwright/test'; +import { Client } from 'pg'; +import { readFileSync, mkdirSync } from 'node:fs'; + + +const BASE = 'http://localhost:3101'; +const PHOTOS = '/tmp/eventsnap-shots/photos'; +const OUT = '/tmp/eventsnap-shots'; +mkdirSync(OUT, { recursive: true }); + +const api = (path, opts = {}) => + fetch(`${BASE}/api/v1${path}`, opts).then(async (r) => ({ status: r.status, body: await r.text().then((t) => { try { return JSON.parse(t); } catch { return t; } }) })); + +const authHeaders = (jwt, json = true) => ({ Authorization: `Bearer ${jwt}`, ...(json ? { 'Content-Type': 'application/json' } : {}) }); + +async function adminLogin() { + const r = await api('/admin/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password: 'admin-test-pw' }) }); + return r.body.jwt; +} +async function joinGuest(name) { + const r = await api('/join', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ display_name: name }) }); + if (r.status !== 201) throw new Error('join failed ' + name + ' ' + r.status + ' ' + JSON.stringify(r.body)); + return r.body; // {jwt,pin,user_id} +} +async function upload(jwt, file, caption, hashtags) { + const form = new FormData(); + form.append('file', new Blob([readFileSync(file)], { type: 'image/jpeg' }), 'photo.jpg'); + if (caption) form.append('caption', caption); + if (hashtags) form.append('hashtags', hashtags); + const r = await fetch(`${BASE}/api/v1/upload`, { method: 'POST', headers: authHeaders(jwt, false), body: form }); + if (r.status !== 201) throw new Error('upload failed ' + r.status + ' ' + (await r.text())); + return (await r.json()).id; +} +async function like(jwt, id) { + await fetch(`${BASE}/api/v1/upload/${id}/like`, { method: 'POST', headers: authHeaders(jwt) }); +} +async function comment(jwt, id, body) { + await fetch(`${BASE}/api/v1/upload/${id}/comments`, { method: 'POST', headers: authHeaders(jwt), body: JSON.stringify({ body }) }); +} + +const pg = () => new Client({ host: 'localhost', port: 55432, user: 'eventsnap_test', password: 'eventsnap_test', database: 'eventsnap_test' }); + +async function truncate(adminJwt) { + await fetch(`${BASE}/api/v1/admin/__truncate`, { method: 'POST', headers: authHeaders(adminJwt) }); +} +async function patchConfig(adminJwt, patch) { + await fetch(`${BASE}/api/v1/admin/config`, { method: 'PATCH', headers: authHeaders(adminJwt), body: JSON.stringify(patch) }); +} + +// ---- SEED ---- +console.log('[seed] admin login + reset'); +let admin = await adminLogin(); +await truncate(admin); +admin = await adminLogin(); +await patchConfig(admin, { rate_limits_enabled: 'false', upload_rate_enabled: 'false', feed_rate_enabled: 'false', export_rate_enabled: 'false', join_rate_enabled: 'false', quota_enabled: 'false', storage_quota_enabled: 'false', upload_count_quota_enabled: 'false' }); + +const guests = ['Anna Bauer', 'Lukas Weber', 'Mia Schulz', 'Jonas Fischer', 'Emma Wagner', 'Ben Hoffmann', 'Sophie Klein', 'Paul Richter']; +const accounts = {}; +for (const g of guests) accounts[g] = await joinGuest(g); +console.log('[seed] joined', guests.length, 'guests'); + +const posts = [ + ['Anna Bauer', 'photo_00.jpg', 'Was für ein magischer Tag 💍✨', 'hochzeit,liebe'], + ['Lukas Weber', 'photo_01.jpg', 'Der erste Tanz 🕺💃', 'party,tanzen'], + ['Mia Schulz', 'photo_02.jpg', 'Sonnenuntergang am See 🌅', 'natur,abend'], + ['Jonas Fischer', 'photo_03.jpg', 'Prost auf das Brautpaar! 🥂', 'feier,freunde'], + ['Emma Wagner', 'photo_04.jpg', 'Die Torte war ein Traum 🍰', 'dessert,liebe'], + ['Ben Hoffmann', 'photo_05.jpg', 'Feuerwerk zum Abschluss 🎆', 'party,abend'], + ['Sophie Klein', 'photo_06.jpg', 'Beste Freunde für immer 💕', 'freunde,feier'], + ['Paul Richter', 'photo_07.jpg', 'Was für eine Stimmung! 🎉', 'party'], + ['Anna Bauer', 'photo_08.jpg', 'Details, die zählen 🌸', 'hochzeit'], + ['Mia Schulz', 'photo_09.jpg', 'Tanzfläche brennt 🔥', 'tanzen,party'], +]; +const ids = []; +for (const [who, f, cap, tags] of posts) { + const id = await upload(accounts[who].jwt, `${PHOTOS}/${f}`, cap, tags); + ids.push(id); +} +console.log('[seed] uploaded', ids.length, 'photos'); + +// Likes: distribute varied counts +const likeMatrix = [7, 3, 12, 5, 9, 4, 6, 2, 8, 5]; +for (let i = 0; i < ids.length; i++) { + const n = Math.min(likeMatrix[i], guests.length); + for (let j = 0; j < n; j++) await like(accounts[guests[j]].jwt, ids[i]); +} +// Comments on a few +await comment(accounts['Lukas Weber'].jwt, ids[0], 'Wunderschön! 😍'); +await comment(accounts['Mia Schulz'].jwt, ids[0], 'Der Moment war perfekt.'); +await comment(accounts['Anna Bauer'].jwt, ids[2], 'Traumhaft 🌅'); +await comment(accounts['Ben Hoffmann'].jwt, ids[1], 'Was ein Abend!'); +console.log('[seed] likes + comments done'); + +// Make one guest a host so /host renders populated +await fetch(`${BASE}/api/v1/host/users/${accounts['Anna Bauer'].user_id}/role`, { method: 'PATCH', headers: authHeaders(admin), body: JSON.stringify({ role: 'host' }) }); + +// Wait for compression to finish so previews render +const c = pg(); await c.connect(); +for (let t = 0; t < 40; t++) { + const r = await c.query(`SELECT COUNT(*)::int AS n FROM upload WHERE compression_status <> 'done' AND deleted_at IS NULL`); + if (r.rows[0].n === 0) { console.log('[seed] compression done'); break; } + await new Promise((res) => setTimeout(res, 500)); +} +await c.end(); + +// ---- SCREENSHOTS ---- +const device = devices['Pixel 7']; +const anna = accounts['Anna Bauer']; // host +const shot = async (label, theme, who, route, prep) => { + const ctx = await browser.newContext({ ...device }); + const page = await ctx.newPage(); + // Seed localStorage on the origin + await page.goto(`${BASE}/join`, { waitUntil: 'domcontentloaded' }); + await page.evaluate(({ jwt, pin, uid, name, theme, mode }) => { + localStorage.setItem('eventsnap_theme', theme); + localStorage.setItem('eventsnap_data_mode', mode); + if (jwt) { + localStorage.setItem('eventsnap_jwt', jwt); + localStorage.setItem('eventsnap_pin', pin); + localStorage.setItem('eventsnap_user_id', uid); + localStorage.setItem('eventsnap_display_name', name); + } + localStorage.setItem('eventsnap_guide_seen', '1'); + }, { jwt: who?.jwt, pin: who?.pin, uid: who?.user_id, name: who?.name, theme, mode: 'saver' }); + await page.goto(`${BASE}${route}`, { waitUntil: 'domcontentloaded' }); + await page.waitForTimeout(1200); + if (prep) await prep(page); + await page.waitForTimeout(600); + await page.screenshot({ path: `${OUT}/${label}-${theme}.png`, fullPage: false }); + await ctx.close(); + console.log('[shot]', label, theme); +}; + +const browser = await chromium.launch(); +const hostWho = { ...anna, name: 'Anna Bauer' }; +const adminWho = { jwt: admin, pin: '', user_id: '', name: 'Admin' }; + +for (const theme of ['light', 'dark']) { + await shot('01-join', theme, null, '/join'); + await shot('02-feed-list', theme, hostWho, '/feed'); + await shot('03-feed-grid', theme, hostWho, '/feed', async (p) => { + await p.getByLabel('Rasteransicht').click().catch(() => {}); + }); + await shot('04-lightbox', theme, hostWho, '/feed', async (p) => { + await p.locator('img').first().click().catch(() => {}); + await p.waitForTimeout(500); + }); + await shot('05-account', theme, hostWho, '/account'); + await shot('06-host', theme, hostWho, '/host'); + await shot('07-admin', theme, adminWho, '/admin'); + await shot('08-upload', theme, hostWho, '/upload'); + await shot('09-diashow', theme, hostWho, '/diashow'); +} +// Onboarding: fresh guest, guide not seen +{ + const ctx = await browser.newContext({ ...device }); + const page = await ctx.newPage(); + await page.goto(`${BASE}/join`, { waitUntil: 'domcontentloaded' }); + await page.evaluate((w) => { + localStorage.setItem('eventsnap_theme', 'light'); + localStorage.setItem('eventsnap_jwt', w.jwt); + localStorage.setItem('eventsnap_pin', w.pin); + localStorage.setItem('eventsnap_user_id', w.user_id); + localStorage.setItem('eventsnap_display_name', 'Emma Wagner'); + }, accounts['Emma Wagner']); + await page.goto(`${BASE}/feed`, { waitUntil: 'domcontentloaded' }); + await page.waitForTimeout(1500); + await page.screenshot({ path: `${OUT}/10-onboarding-light.png` }); + await ctx.close(); + console.log('[shot] onboarding'); +} +await browser.close(); +console.log('DONE');