Merge branch 'chore/lint-and-format-2026-07-15'
This commit is contained in:
18
.github/workflows/checks.yml
vendored
18
.github/workflows/checks.yml
vendored
@@ -94,8 +94,16 @@ jobs:
|
|||||||
working-directory: ./frontend
|
working-directory: ./frontend
|
||||||
run: npx svelte-check --threshold error
|
run: npx svelte-check --threshold error
|
||||||
|
|
||||||
|
- name: ESLint
|
||||||
|
working-directory: ./frontend
|
||||||
|
run: npm run lint
|
||||||
|
|
||||||
|
- name: Prettier
|
||||||
|
working-directory: ./frontend
|
||||||
|
run: npm run format:check
|
||||||
|
|
||||||
e2e-typecheck:
|
e2e-typecheck:
|
||||||
name: E2E — typecheck
|
name: E2E — typecheck + lint
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
@@ -111,3 +119,11 @@ jobs:
|
|||||||
- name: tsc --noEmit
|
- name: tsc --noEmit
|
||||||
working-directory: ./e2e
|
working-directory: ./e2e
|
||||||
run: npx tsc --noEmit
|
run: npx tsc --noEmit
|
||||||
|
|
||||||
|
- name: ESLint
|
||||||
|
working-directory: ./e2e
|
||||||
|
run: npm run lint
|
||||||
|
|
||||||
|
- name: Prettier
|
||||||
|
working-directory: ./e2e
|
||||||
|
run: npm run format:check
|
||||||
|
|||||||
4
e2e/.prettierignore
Normal file
4
e2e/.prettierignore
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
playwright-report/
|
||||||
|
test-results/
|
||||||
|
package-lock.json
|
||||||
7
e2e/.prettierrc
Normal file
7
e2e/.prettierrc
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"useTabs": false,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "es5",
|
||||||
|
"printWidth": 100
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ backend) and `:55432` (Postgres), and exercises the SvelteKit frontend
|
|||||||
against a real Rust backend with rate limits and quotas disabled.
|
against a real Rust backend with rate limits and quotas disabled.
|
||||||
|
|
||||||
**Phases 1, 2, and 3-mobile-gestures are landed**:
|
**Phases 1, 2, and 3-mobile-gestures are landed**:
|
||||||
|
|
||||||
- **Phase 1** — happy-path coverage of every documented user journey, plus a
|
- **Phase 1** — happy-path coverage of every documented user journey, plus a
|
||||||
smoke matrix across nine browser/UA profiles to catch engine-level
|
smoke matrix across nine browser/UA profiles to catch engine-level
|
||||||
divergences.
|
divergences.
|
||||||
@@ -47,25 +48,25 @@ The CI workflow at `.github/workflows/e2e.yml` runs both jobs on every PR.
|
|||||||
Every spec covers a journey from [`docs/USER_JOURNEYS.md`](../docs/USER_JOURNEYS.md)
|
Every spec covers a journey from [`docs/USER_JOURNEYS.md`](../docs/USER_JOURNEYS.md)
|
||||||
or a security/chaos scenario. One folder per area:
|
or a security/chaos scenario. One folder per area:
|
||||||
|
|
||||||
| Folder | Phase | Journeys / Topic | Tests | Notes |
|
| Folder | Phase | Journeys / Topic | Tests | Notes |
|
||||||
|---|---|---|---|---|
|
| ------------------------- | ----- | ----------------------------------------------------------------------------------------------- | --------- | ------------------------------------------------------------- |
|
||||||
| `specs/01-auth/` | 1 | §1, §2, §3, §11, §15 | 13 | Join, recover, PIN lockout, admin login, leave event. |
|
| `specs/01-auth/` | 1 | §1, §2, §3, §11, §15 | 13 | Join, recover, PIN lockout, admin login, leave event. |
|
||||||
| `specs/02-upload/` | 1 | §5, §6, §18 | 5 | Gallery picker, multi-file, rate-limit, admin toggle. |
|
| `specs/02-upload/` | 1 | §5, §6, §18 | 5 | Gallery picker, multi-file, rate-limit, admin toggle. |
|
||||||
| `specs/03-feed/` | 1 | §7, §8, §17 | 5 | Like/comment SSE, filter chips, SSE reconnect. |
|
| `specs/03-feed/` | 1 | §7, §8, §17 | 5 | Like/comment SSE, filter chips, SSE reconnect. |
|
||||||
| `specs/04-host/` | 1 | §9 | 5 | Event lock, ban/unban, role change. |
|
| `specs/04-host/` | 1 | §9 | 5 | Event lock, ban/unban, role change. |
|
||||||
| `specs/05-admin/` | 1 | §11, §16 | 11 | Config validation, foundational auth guards, stats. |
|
| `specs/05-admin/` | 1 | §11, §16 | 11 | Config validation, foundational auth guards, stats. |
|
||||||
| `specs/06-export/` | 1 | §12 | 3 | Status, release, download stub. |
|
| `specs/06-export/` | 1 | §12 | 3 | Status, release, download stub. |
|
||||||
| `specs/__smoke/` | 1 | (matrix) | 1 × 9 UAs | `@smoke`-tagged happy-path on every UA project. |
|
| `specs/__smoke/` | 1 | (matrix) | 1 × 9 UAs | `@smoke`-tagged happy-path on every UA project. |
|
||||||
| `specs/07-adversarial/` | **2** | Input attacks, file upload boundaries, JWT forgery, brute-force, deep authorization, small DDoS | ~40 | See breakdown below. |
|
| `specs/07-adversarial/` | **2** | Input attacks, file upload boundaries, JWT forgery, brute-force, deep authorization, small DDoS | ~40 | See breakdown below. |
|
||||||
| `specs/08-browser-chaos/` | **2** | Storage purge, IndexedDB, offline/slow-3G, multi-tab, no-JS, clock skew, quota | ~20 | See breakdown below. |
|
| `specs/08-browser-chaos/` | **2** | Storage purge, IndexedDB, offline/slow-3G, multi-tab, no-JS, clock skew, quota | ~20 | See breakdown below. |
|
||||||
| `specs/09-mobile/` | **3** | Touch-target audit, safe-area, long-press, double-tap, viewport reflow, fixme stubs | 23 | Runs only on `chromium-mobile` (Pixel 7 viewport). See below. |
|
| `specs/09-mobile/` | **3** | Touch-target audit, safe-area, long-press, double-tap, viewport reflow, fixme stubs | 23 | Runs only on `chromium-mobile` (Pixel 7 viewport). See below. |
|
||||||
|
|
||||||
### Phase 2 — adversarial (`specs/07-adversarial/`)
|
### Phase 2 — adversarial (`specs/07-adversarial/`)
|
||||||
|
|
||||||
- **`xss-injection.spec.ts`** — 13 tests. Six XSS payloads × display-name path
|
- **`xss-injection.spec.ts`** — 13 tests. Six XSS payloads × display-name path
|
||||||
+ four SQLi patterns + length/encoding edge cases (NUL byte, RTL override,
|
- four SQLi patterns + length/encoding edge cases (NUL byte, RTL override,
|
||||||
caption overflow). Asserts `window.__xssFired` never gets set and no
|
caption overflow). Asserts `window.__xssFired` never gets set and no
|
||||||
`dialog` event fires.
|
`dialog` event fires.
|
||||||
- **`ui-rendering.spec.ts`** — 2 tests. Belt-and-braces: even when a script-
|
- **`ui-rendering.spec.ts`** — 2 tests. Belt-and-braces: even when a script-
|
||||||
payload sits in localStorage as the user's display name, rendering through
|
payload sits in localStorage as the user's display name, rendering through
|
||||||
`/account` keeps it as text.
|
`/account` keeps it as text.
|
||||||
@@ -104,17 +105,17 @@ are marked `test.fixme` and will activate when that helper lands.
|
|||||||
|
|
||||||
## Browser & UA matrix
|
## Browser & UA matrix
|
||||||
|
|
||||||
| Project | Engine | UA / Device | Why |
|
| Project | Engine | UA / Device | Why |
|
||||||
|---|---|---|---|
|
| --------------------- | -------- | ----------------------------------- | --------------------------------------------- |
|
||||||
| `chromium-desktop` | Chromium | Desktop Chrome | Baseline. Full suite runs here. |
|
| `chromium-desktop` | Chromium | Desktop Chrome | Baseline. Full suite runs here. |
|
||||||
| `chromium-pixel7` | Chromium | Pixel 7 device descriptor | Chrome Android. |
|
| `chromium-pixel7` | Chromium | Pixel 7 device descriptor | Chrome Android. |
|
||||||
| `chromium-galaxy-s22` | Chromium | Galaxy viewport + Samsung phone UA | Chrome on Samsung hardware. |
|
| `chromium-galaxy-s22` | Chromium | Galaxy viewport + Samsung phone UA | Chrome on Samsung hardware. |
|
||||||
| `samsung-internet` | Chromium | Galaxy viewport + SamsungBrowser UA | **Tier-A Samsung Internet baseline.** |
|
| `samsung-internet` | Chromium | Galaxy viewport + SamsungBrowser UA | **Tier-A Samsung Internet baseline.** |
|
||||||
| `edge-android` | Chromium | Pixel viewport + EdgA UA | Edge Mobile (Blink-based). |
|
| `edge-android` | Chromium | Pixel viewport + EdgA UA | Edge Mobile (Blink-based). |
|
||||||
| `chrome-ios` | Chromium | iPhone viewport + CriOS UA | Chrome iOS (actually WebKit, but UA differs). |
|
| `chrome-ios` | Chromium | iPhone viewport + CriOS UA | Chrome iOS (actually WebKit, but UA differs). |
|
||||||
| `webkit-iphone` | WebKit | iPhone 14 Pro | Real iOS Safari engine. |
|
| `webkit-iphone` | WebKit | iPhone 14 Pro | Real iOS Safari engine. |
|
||||||
| `firefox-android` | Firefox | Pixel viewport + Firefox Android UA | Gecko engine. |
|
| `firefox-android` | Firefox | Pixel viewport + Firefox Android UA | Gecko engine. |
|
||||||
| `firefox-desktop` | Firefox | Desktop Firefox | FF-specific quirks. |
|
| `firefox-desktop` | Firefox | Desktop Firefox | FF-specific quirks. |
|
||||||
|
|
||||||
Only the `@smoke` happy-path runs across all projects (controlled by
|
Only the `@smoke` happy-path runs across all projects (controlled by
|
||||||
`grep` in `playwright.config.ts`). The full Phase 1 suite is
|
`grep` in `playwright.config.ts`). The full Phase 1 suite is
|
||||||
@@ -127,14 +128,14 @@ It's **Blink-based**, so Tier-A catches ~90% of regressions. Real Samsung
|
|||||||
divergences (Smart Switch save-data mode, dark-mode injection, custom
|
divergences (Smart Switch save-data mode, dark-mode injection, custom
|
||||||
autoplay, in-browser ad blocking) are only reproducible at Tier B+:
|
autoplay, in-browser ad blocking) are only reproducible at Tier B+:
|
||||||
|
|
||||||
- **Tier A** *(this repo, free, in CI)*: Playwright Chromium with the
|
- **Tier A** _(this repo, free, in CI)_: Playwright Chromium with the
|
||||||
Samsung Internet user-agent + Galaxy viewport. See the `samsung-internet`
|
Samsung Internet user-agent + Galaxy viewport. See the `samsung-internet`
|
||||||
project in `playwright.config.ts`.
|
project in `playwright.config.ts`.
|
||||||
- **Tier B** *(free, manual, future)*: Android Studio emulator on Linux →
|
- **Tier B** _(free, manual, future)_: Android Studio emulator on Linux →
|
||||||
install Samsung Internet APK → enable `--remote-debugging-port=9222` →
|
install Samsung Internet APK → enable `--remote-debugging-port=9222` →
|
||||||
`chromium.connectOverCDP('http://localhost:9222')`. Setup docs live in
|
`chromium.connectOverCDP('http://localhost:9222')`. Setup docs live in
|
||||||
`docs/samsung-emulator.md` (to be written).
|
`docs/samsung-emulator.md` (to be written).
|
||||||
- **Tier C** *(paid, optional)*: BrowserStack or LambdaTest cloud devices.
|
- **Tier C** _(paid, optional)_: BrowserStack or LambdaTest cloud devices.
|
||||||
Real Galaxy S22/S23 hardware via Playwright's cloud integration.
|
Real Galaxy S22/S23 hardware via Playwright's cloud integration.
|
||||||
|
|
||||||
## Test isolation
|
## Test isolation
|
||||||
@@ -223,7 +224,7 @@ Known findings surfaced (documented in tests, not silent failures):
|
|||||||
clears it).
|
clears it).
|
||||||
3. SVG uploads currently pass the magic-byte check (depends on `infer`'s
|
3. SVG uploads currently pass the magic-byte check (depends on `infer`'s
|
||||||
detection coverage) — consider adding `X-Content-Type-Options: nosniff`
|
detection coverage) — consider adding `X-Content-Type-Options: nosniff`
|
||||||
+ CSP on `/media/*` if SVGs are ever expected as user content.
|
- CSP on `/media/*` if SVGs are ever expected as user content.
|
||||||
|
|
||||||
### Phase 3 — Mobile gestures (`specs/09-mobile/`) ✅ landed
|
### Phase 3 — Mobile gestures (`specs/09-mobile/`) ✅ landed
|
||||||
|
|
||||||
@@ -273,6 +274,7 @@ ignores this folder via `testIgnore` in [playwright.config.ts](playwright.config
|
|||||||
values.
|
values.
|
||||||
|
|
||||||
### Phase 3 — Real-device compat & visual / a11y (not landed)
|
### Phase 3 — Real-device compat & visual / a11y (not landed)
|
||||||
|
|
||||||
- Long-press own/other post, swipe lightbox L/R, swipe-down dismiss, pull-to-refresh, double-tap like.
|
- Long-press own/other post, swipe lightbox L/R, swipe-down dismiss, pull-to-refresh, double-tap like.
|
||||||
- Safe-area inset visual diff on iPhone notch.
|
- Safe-area inset visual diff on iPhone notch.
|
||||||
- Touch-target ≥ 44 px audit.
|
- Touch-target ≥ 44 px audit.
|
||||||
@@ -282,6 +284,7 @@ ignores this folder via `testIgnore` in [playwright.config.ts](playwright.config
|
|||||||
- Visual regression with screenshot diffs.
|
- Visual regression with screenshot diffs.
|
||||||
|
|
||||||
### Out of scope (handed to other tools)
|
### Out of scope (handed to other tools)
|
||||||
|
|
||||||
- Load testing → k6 / Vegeta.
|
- Load testing → k6 / Vegeta.
|
||||||
- API contract testing → backend `cargo test` integration tests.
|
- API contract testing → backend `cargo test` integration tests.
|
||||||
- Static asset auditing → Lighthouse CI.
|
- Static asset auditing → Lighthouse CI.
|
||||||
|
|||||||
@@ -17,12 +17,12 @@ services:
|
|||||||
POSTGRES_PASSWORD: eventsnap_test
|
POSTGRES_PASSWORD: eventsnap_test
|
||||||
POSTGRES_DB: eventsnap_test
|
POSTGRES_DB: eventsnap_test
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U eventsnap_test -d eventsnap_test"]
|
test: ['CMD-SHELL', 'pg_isready -U eventsnap_test -d eventsnap_test']
|
||||||
interval: 3s
|
interval: 3s
|
||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 30
|
retries: 30
|
||||||
ports:
|
ports:
|
||||||
- "55432:5432" # exposed so the e2e harness can connect via pg for fixture setup
|
- '55432:5432' # exposed so the e2e harness can connect via pg for fixture setup
|
||||||
|
|
||||||
app:
|
app:
|
||||||
build:
|
build:
|
||||||
@@ -40,15 +40,15 @@ services:
|
|||||||
ADMIN_PASSWORD_HASH: $$2b$$04$$XKJJkNX6BOi6y3S42DA5JOWwk4oxc8DHPL6.MrPfJI2vpnccZjP32
|
ADMIN_PASSWORD_HASH: $$2b$$04$$XKJJkNX6BOi6y3S42DA5JOWwk4oxc8DHPL6.MrPfJI2vpnccZjP32
|
||||||
EVENT_SLUG: e2e-test-event
|
EVENT_SLUG: e2e-test-event
|
||||||
EVENT_NAME: E2E Test Event
|
EVENT_NAME: E2E Test Event
|
||||||
APP_PORT: "3000"
|
APP_PORT: '3000'
|
||||||
MEDIA_PATH: /media
|
MEDIA_PATH: /media
|
||||||
SESSION_EXPIRY_DAYS: "30"
|
SESSION_EXPIRY_DAYS: '30'
|
||||||
EVENTSNAP_TEST_MODE: "1" # ENABLES /admin/__truncate — never set in prod
|
EVENTSNAP_TEST_MODE: '1' # ENABLES /admin/__truncate — never set in prod
|
||||||
RUST_LOG: eventsnap_backend=info,tower_http=warn
|
RUST_LOG: eventsnap_backend=info,tower_http=warn
|
||||||
volumes:
|
volumes:
|
||||||
- media_data:/media
|
- media_data:/media
|
||||||
expose:
|
expose:
|
||||||
- "3000"
|
- '3000'
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
build:
|
build:
|
||||||
@@ -57,11 +57,11 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
- app
|
- app
|
||||||
environment:
|
environment:
|
||||||
PORT: "3001"
|
PORT: '3001'
|
||||||
HOST: "0.0.0.0"
|
HOST: '0.0.0.0'
|
||||||
ORIGIN: "http://localhost:3101"
|
ORIGIN: 'http://localhost:3101'
|
||||||
expose:
|
expose:
|
||||||
- "3001"
|
- '3001'
|
||||||
|
|
||||||
caddy:
|
caddy:
|
||||||
image: caddy:2-alpine
|
image: caddy:2-alpine
|
||||||
@@ -71,7 +71,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./Caddyfile.test:/etc/caddy/Caddyfile:ro
|
- ./Caddyfile.test:/etc/caddy/Caddyfile:ro
|
||||||
ports:
|
ports:
|
||||||
- "3101:3101"
|
- '3101:3101'
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
media_data:
|
media_data:
|
||||||
|
|||||||
47
e2e/eslint.config.js
Normal file
47
e2e/eslint.config.js
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import js from '@eslint/js';
|
||||||
|
import ts from 'typescript-eslint';
|
||||||
|
import prettier from 'eslint-config-prettier';
|
||||||
|
import globals from 'globals';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flat-config ESLint for the Playwright TypeScript suite. Prettier owns formatting (its config is
|
||||||
|
* last so it disables every stylistic rule). The rules kept here catch real test bugs — unused
|
||||||
|
* setup, floating promises that make a test race, `any` that hides a wrong assertion shape.
|
||||||
|
*/
|
||||||
|
export default ts.config(
|
||||||
|
js.configs.recommended,
|
||||||
|
...ts.configs.recommended,
|
||||||
|
prettier,
|
||||||
|
{
|
||||||
|
languageOptions: {
|
||||||
|
globals: { ...globals.node },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rules: {
|
||||||
|
'@typescript-eslint/no-unused-vars': [
|
||||||
|
'error',
|
||||||
|
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_' },
|
||||||
|
],
|
||||||
|
// A floating promise in a test is a real hazard: an un-awaited request or assertion can let
|
||||||
|
// the test end before it runs, passing vacuously. Keep it on.
|
||||||
|
'@typescript-eslint/no-floating-promises': 'error',
|
||||||
|
// Off for the suite: this is all test code, where `any` is the honest type for an untyped
|
||||||
|
// `res.json()` body or a `page.evaluate()` return. Threading DTO types through every
|
||||||
|
// assertion is churn that buys nothing — the assertion values are what's checked, not the
|
||||||
|
// static shape. (no-floating-promises and no-unused-vars, which catch real test bugs, stay on.)
|
||||||
|
'@typescript-eslint/no-explicit-any': 'off',
|
||||||
|
// Off: Playwright fixtures with no dependencies are declared `async ({}, use) => {}` — the
|
||||||
|
// empty destructure is required by the fixtures API, not an accident.
|
||||||
|
'no-empty-pattern': 'off',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: { projectService: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ignores: ['node_modules/', 'playwright-report/', 'test-results/', '*.config.js'],
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -47,7 +47,9 @@ export class ApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Auth ───────────────────────────────────────────────────────────────
|
// ── Auth ───────────────────────────────────────────────────────────────
|
||||||
async join(displayName: string): Promise<{ jwt: string; pin: string; user_id: string; is_new: boolean }> {
|
async join(
|
||||||
|
displayName: string
|
||||||
|
): Promise<{ jwt: string; pin: string; user_id: string; is_new: boolean }> {
|
||||||
const { body } = await this.request<any>('POST', '/join', {
|
const { body } = await this.request<any>('POST', '/join', {
|
||||||
body: { display_name: displayName },
|
body: { display_name: displayName },
|
||||||
expectedStatus: [201],
|
expectedStatus: [201],
|
||||||
@@ -55,7 +57,11 @@ export class ApiClient {
|
|||||||
return body;
|
return body;
|
||||||
}
|
}
|
||||||
|
|
||||||
async recover(displayName: string, pin: string, opts: { expectedStatus?: number | number[] } = {}) {
|
async recover(
|
||||||
|
displayName: string,
|
||||||
|
pin: string,
|
||||||
|
opts: { expectedStatus?: number | number[] } = {}
|
||||||
|
) {
|
||||||
return this.request<any>('POST', '/recover', {
|
return this.request<any>('POST', '/recover', {
|
||||||
body: { display_name: displayName, pin },
|
body: { display_name: displayName, pin },
|
||||||
expectedStatus: opts.expectedStatus ?? [200],
|
expectedStatus: opts.expectedStatus ?? [200],
|
||||||
@@ -91,7 +97,9 @@ export class ApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getConfig(adminToken: string): Promise<Record<string, string>> {
|
async getConfig(adminToken: string): Promise<Record<string, string>> {
|
||||||
const { body } = await this.request<Record<string, string>>('GET', '/admin/config', { token: adminToken });
|
const { body } = await this.request<Record<string, string>>('GET', '/admin/config', {
|
||||||
|
token: adminToken,
|
||||||
|
});
|
||||||
return body;
|
return body;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,7 +126,11 @@ export class ApiClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async unbanUser(token: string, userId: string, opts: { expectedStatus?: number | number[] } = {}) {
|
async unbanUser(
|
||||||
|
token: string,
|
||||||
|
userId: string,
|
||||||
|
opts: { expectedStatus?: number | number[] } = {}
|
||||||
|
) {
|
||||||
return this.request<void>('POST', `/host/users/${userId}/unban`, {
|
return this.request<void>('POST', `/host/users/${userId}/unban`, {
|
||||||
token,
|
token,
|
||||||
expectedStatus: opts.expectedStatus ?? [200, 204],
|
expectedStatus: opts.expectedStatus ?? [200, 204],
|
||||||
|
|||||||
@@ -39,11 +39,16 @@ export const db = {
|
|||||||
|
|
||||||
async expireSession(userId: string) {
|
async expireSession(userId: string) {
|
||||||
await withClient((c) =>
|
await withClient((c) =>
|
||||||
c.query(`UPDATE session SET expires_at = NOW() - interval '1 hour' WHERE user_id = $1`, [userId])
|
c.query(`UPDATE session SET expires_at = NOW() - interval '1 hour' WHERE user_id = $1`, [
|
||||||
|
userId,
|
||||||
|
])
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
async setUploadCompressionStatus(uploadId: string, status: 'pending' | 'processing' | 'done' | 'failed') {
|
async setUploadCompressionStatus(
|
||||||
|
uploadId: string,
|
||||||
|
status: 'pending' | 'processing' | 'done' | 'failed'
|
||||||
|
) {
|
||||||
await withClient((c) =>
|
await withClient((c) =>
|
||||||
c.query(`UPDATE upload SET compression_status = $2 WHERE id = $1`, [uploadId, status])
|
c.query(`UPDATE upload SET compression_status = $2 WHERE id = $1`, [uploadId, status])
|
||||||
);
|
);
|
||||||
@@ -113,7 +118,11 @@ export const db = {
|
|||||||
* Insert a pre-baked export job row to skip the (slow) real compression path. Stamped with the
|
* Insert a pre-baked export job row to skip the (slow) real compression path. Stamped with the
|
||||||
* event's CURRENT epoch so it counts as the live generation.
|
* event's CURRENT epoch so it counts as the live generation.
|
||||||
*/
|
*/
|
||||||
async fakeExportJob(eventSlug: string, type: 'zip' | 'html', status: 'pending' | 'running' | 'done') {
|
async fakeExportJob(
|
||||||
|
eventSlug: string,
|
||||||
|
type: 'zip' | 'html',
|
||||||
|
status: 'pending' | 'running' | 'done'
|
||||||
|
) {
|
||||||
await withClient(async (c) => {
|
await withClient(async (c) => {
|
||||||
const ev = await c.query<{ id: string; export_epoch: string }>(
|
const ev = await c.query<{ id: string; export_epoch: string }>(
|
||||||
`SELECT id, export_epoch FROM event WHERE slug = $1`,
|
`SELECT id, export_epoch FROM event WHERE slug = $1`,
|
||||||
|
|||||||
@@ -84,16 +84,13 @@ export const test = base.extend<Fixtures>({
|
|||||||
const fn = async (page: Page, handle: GuestHandle) => {
|
const fn = async (page: Page, handle: GuestHandle) => {
|
||||||
// Visit any in-app URL first so localStorage is scoped to the right origin.
|
// Visit any in-app URL first so localStorage is scoped to the right origin.
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await page.evaluate(
|
await page.evaluate(({ jwt, pin, userId, displayName }) => {
|
||||||
({ jwt, pin, userId, displayName }) => {
|
localStorage.setItem('eventsnap_jwt', jwt);
|
||||||
localStorage.setItem('eventsnap_jwt', jwt);
|
localStorage.setItem('eventsnap_pin', pin);
|
||||||
localStorage.setItem('eventsnap_pin', pin);
|
localStorage.setItem('eventsnap_user_id', userId);
|
||||||
localStorage.setItem('eventsnap_user_id', userId);
|
localStorage.setItem('eventsnap_display_name', displayName);
|
||||||
localStorage.setItem('eventsnap_display_name', displayName);
|
localStorage.setItem('eventsnap_guide_seen', 'true');
|
||||||
localStorage.setItem('eventsnap_guide_seen', 'true');
|
}, handle);
|
||||||
},
|
|
||||||
handle
|
|
||||||
);
|
|
||||||
await page.goto('/feed');
|
await page.goto('/feed');
|
||||||
};
|
};
|
||||||
await use(fn);
|
await use(fn);
|
||||||
|
|||||||
5
e2e/helpers/env.ts
Normal file
5
e2e/helpers/env.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
/**
|
||||||
|
* Shared test environment constants. The frontend base URL was redeclared verbatim in ~23 specs;
|
||||||
|
* one source of truth means a port/scheme change is a single edit, not a sweep.
|
||||||
|
*/
|
||||||
|
export const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
|
||||||
@@ -7,8 +7,7 @@ import { readFileSync } from 'node:fs';
|
|||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { uploadRaw } from './upload-client';
|
import { uploadRaw } from './upload-client';
|
||||||
import { db } from '../fixtures/db';
|
import { db } from '../fixtures/db';
|
||||||
|
import { BASE } from './env';
|
||||||
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
|
|
||||||
|
|
||||||
// A real, decodable JPEG. Magic-bytes-only fakes now get auto-cleaned by the
|
// A real, decodable JPEG. Magic-bytes-only fakes now get auto-cleaned by the
|
||||||
// backend when compression fails (a failed transcode refunds quota + deletes the
|
// backend when compression fails (a failed transcode refunds quota + deletes the
|
||||||
|
|||||||
@@ -30,7 +30,8 @@ export class SseListener {
|
|||||||
const reader = res.body.getReader();
|
const reader = res.body.getReader();
|
||||||
const decoder = new TextDecoder();
|
const decoder = new TextDecoder();
|
||||||
let buffer = '';
|
let buffer = '';
|
||||||
(async () => {
|
// Fire-and-forget reader loop; the caller drives lifecycle via stop()/the AbortController.
|
||||||
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
while (!this.closed) {
|
while (!this.closed) {
|
||||||
const { done, value } = await reader.read();
|
const { done, value } = await reader.read();
|
||||||
@@ -61,7 +62,9 @@ export class SseListener {
|
|||||||
if (hit) return hit;
|
if (hit) return hit;
|
||||||
await new Promise((r) => setTimeout(r, 50));
|
await new Promise((r) => setTimeout(r, 50));
|
||||||
}
|
}
|
||||||
throw new Error(`SSE event ${type} did not arrive within ${timeoutMs}ms. Got: ${this.events.map((e) => e.type).join(', ')}`);
|
throw new Error(
|
||||||
|
`SSE event ${type} did not arrive within ${timeoutMs}ms. Got: ${this.events.map((e) => e.type).join(', ')}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
allEvents(): SseEvent[] {
|
allEvents(): SseEvent[] {
|
||||||
|
|||||||
@@ -4,8 +4,7 @@
|
|||||||
* it lives in one place instead of being re-inlined per spec.
|
* it lives in one place instead of being re-inlined per spec.
|
||||||
*/
|
*/
|
||||||
import type { Page } from '@playwright/test';
|
import type { Page } from '@playwright/test';
|
||||||
|
import { BASE } from './env';
|
||||||
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
|
|
||||||
|
|
||||||
/** Exchange a JWT for a single-use SSE ticket via POST /api/v1/stream/ticket. */
|
/** Exchange a JWT for a single-use SSE ticket via POST /api/v1/stream/ticket. */
|
||||||
export async function mintSseTicket(jwt: string): Promise<string> {
|
export async function mintSseTicket(jwt: string): Promise<string> {
|
||||||
@@ -13,7 +12,8 @@ export async function mintSseTicket(jwt: string): Promise<string> {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${jwt}` },
|
headers: { Authorization: `Bearer ${jwt}` },
|
||||||
});
|
});
|
||||||
if (res.status !== 200) throw new Error(`mintSseTicket failed: ${res.status} ${await res.text()}`);
|
if (res.status !== 200)
|
||||||
|
throw new Error(`mintSseTicket failed: ${res.status} ${await res.text()}`);
|
||||||
return (await res.json()).ticket;
|
return (await res.json()).ticket;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { BASE } from './env';
|
||||||
/**
|
/**
|
||||||
* Node-side multipart upload helper. Lets adversarial specs post arbitrary
|
* Node-side multipart upload helper. Lets adversarial specs post arbitrary
|
||||||
* bytes with arbitrary `Content-Type` claims to /api/v1/upload without
|
* bytes with arbitrary `Content-Type` claims to /api/v1/upload without
|
||||||
@@ -11,8 +12,6 @@
|
|||||||
*/
|
*/
|
||||||
// Node 22+ ships FormData and Blob as globals — no import needed.
|
// Node 22+ ships FormData and Blob as globals — no import needed.
|
||||||
|
|
||||||
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
|
|
||||||
|
|
||||||
export type UploadOptions = {
|
export type UploadOptions = {
|
||||||
filename?: string;
|
filename?: string;
|
||||||
contentType?: string;
|
contentType?: string;
|
||||||
@@ -20,7 +19,11 @@ export type UploadOptions = {
|
|||||||
hashtags?: string;
|
hashtags?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function uploadRaw(token: string, body: Uint8Array | Buffer, opts: UploadOptions = {}) {
|
export async function uploadRaw(
|
||||||
|
token: string,
|
||||||
|
body: Uint8Array | Buffer,
|
||||||
|
opts: UploadOptions = {}
|
||||||
|
) {
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
const blob = new Blob([body as any], { type: opts.contentType ?? 'application/octet-stream' });
|
const blob = new Blob([body as any], { type: opts.contentType ?? 'application/octet-stream' });
|
||||||
form.append('file', blob as any, opts.filename ?? 'upload.bin');
|
form.append('file', blob as any, opts.filename ?? 'upload.bin');
|
||||||
@@ -41,7 +44,9 @@ export async function uploadFile(token: string, path: string, opts: UploadOption
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Tiny valid JPEG header — magic bytes only, useful for "claim image but is N MB of zeros" tests. */
|
/** Tiny valid JPEG header — magic bytes only, useful for "claim image but is N MB of zeros" tests. */
|
||||||
export const JPEG_MAGIC = new Uint8Array([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46]);
|
export const JPEG_MAGIC = new Uint8Array([
|
||||||
|
0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46,
|
||||||
|
]);
|
||||||
/** Tiny valid PNG magic bytes. */
|
/** Tiny valid PNG magic bytes. */
|
||||||
export const PNG_MAGIC = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
export const PNG_MAGIC = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||||
/** ELF header — the magic bytes of a Linux executable. `infer` reports `application/x-executable`. */
|
/** ELF header — the magic bytes of a Linux executable. `infer` reports `application/x-executable`. */
|
||||||
|
|||||||
1514
e2e/package-lock.json
generated
1514
e2e/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -14,13 +14,23 @@
|
|||||||
"stack:up": "docker compose -f docker-compose.test.yml up -d --build",
|
"stack:up": "docker compose -f docker-compose.test.yml up -d --build",
|
||||||
"stack:down": "docker compose -f docker-compose.test.yml down -v",
|
"stack:down": "docker compose -f docker-compose.test.yml down -v",
|
||||||
"stack:logs": "docker compose -f docker-compose.test.yml logs -f",
|
"stack:logs": "docker compose -f docker-compose.test.yml logs -f",
|
||||||
"install:browsers": "playwright install --with-deps chromium firefox webkit"
|
"install:browsers": "playwright install --with-deps chromium firefox webkit",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"lint:fix": "eslint . --fix",
|
||||||
|
"format": "prettier --write .",
|
||||||
|
"format:check": "prettier --check ."
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.39.5",
|
||||||
"@playwright/test": "^1.49.0",
|
"@playwright/test": "^1.49.0",
|
||||||
"@types/node": "^22.10.0",
|
"@types/node": "^22.10.0",
|
||||||
"pg": "^8.13.1",
|
|
||||||
"@types/pg": "^8.11.10",
|
"@types/pg": "^8.11.10",
|
||||||
"typescript": "^5.7.0"
|
"eslint": "^9.39.5",
|
||||||
|
"eslint-config-prettier": "^9.1.2",
|
||||||
|
"globals": "^15.15.0",
|
||||||
|
"pg": "^8.13.1",
|
||||||
|
"prettier": "^3.9.5",
|
||||||
|
"typescript": "^5.7.0",
|
||||||
|
"typescript-eslint": "^8.64.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,8 +11,12 @@ export class HostDashboard {
|
|||||||
constructor(page: Page) {
|
constructor(page: Page) {
|
||||||
this.page = page;
|
this.page = page;
|
||||||
this.statsSection = page.locator('[data-testid="host-stats"]');
|
this.statsSection = page.locator('[data-testid="host-stats"]');
|
||||||
this.lockEventButton = page.getByRole('button', { name: /uploads sperren|event sperren|sperren/i }).first();
|
this.lockEventButton = page
|
||||||
this.unlockEventButton = page.getByRole('button', { name: /uploads freigeben|entsperren/i }).first();
|
.getByRole('button', { name: /uploads sperren|event sperren|sperren/i })
|
||||||
|
.first();
|
||||||
|
this.unlockEventButton = page
|
||||||
|
.getByRole('button', { name: /uploads freigeben|entsperren/i })
|
||||||
|
.first();
|
||||||
this.releaseGalleryButton = page.getByRole('button', { name: /galerie freigeben/i });
|
this.releaseGalleryButton = page.getByRole('button', { name: /galerie freigeben/i });
|
||||||
this.userSearchInput = page.getByPlaceholder(/suche|search/i).first();
|
this.userSearchInput = page.getByPlaceholder(/suche|search/i).first();
|
||||||
}
|
}
|
||||||
@@ -22,14 +26,23 @@ export class HostDashboard {
|
|||||||
}
|
}
|
||||||
|
|
||||||
userRow(displayName: string): Locator {
|
userRow(displayName: string): Locator {
|
||||||
return this.page.locator('tr,li,div', { hasText: displayName }).filter({ has: this.page.getByRole('button') }).first();
|
return this.page
|
||||||
|
.locator('tr,li,div', { hasText: displayName })
|
||||||
|
.filter({ has: this.page.getByRole('button') })
|
||||||
|
.first();
|
||||||
}
|
}
|
||||||
|
|
||||||
async banUser(displayName: string) {
|
async banUser(displayName: string) {
|
||||||
// A ban always hides — the confirm dialog is a plain confirm, no "hide uploads" checkbox
|
// A ban always hides — the confirm dialog is a plain confirm, no "hide uploads" checkbox
|
||||||
// (removed from the UI; the endpoint takes no body).
|
// (removed from the UI; the endpoint takes no body).
|
||||||
const row = this.userRow(displayName);
|
const row = this.userRow(displayName);
|
||||||
await row.getByRole('button', { name: /sperren/i }).first().click();
|
await row
|
||||||
await this.page.getByRole('button', { name: /bestätigen|sperren/i }).last().click();
|
.getByRole('button', { name: /sperren/i })
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
|
await this.page
|
||||||
|
.getByRole('button', { name: /bestätigen|sperren/i })
|
||||||
|
.last()
|
||||||
|
.click();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ const CHROMIUM_PERMISSIONS = ['camera', 'microphone', 'clipboard-read', 'clipboa
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: './specs',
|
testDir: './specs',
|
||||||
outputDir: './test-results',
|
outputDir: './test-results',
|
||||||
fullyParallel: false, // Single shared backend → tests TRUNCATE between, so don't run in parallel.
|
fullyParallel: false, // Single shared backend → tests TRUNCATE between, so don't run in parallel.
|
||||||
forbidOnly: !!process.env.CI,
|
forbidOnly: !!process.env.CI,
|
||||||
// NO RETRIES — not even in CI. This is deliberate and it is the opposite of the usual advice.
|
// NO RETRIES — not even in CI. This is deliberate and it is the opposite of the usual advice.
|
||||||
//
|
//
|
||||||
@@ -42,11 +42,8 @@ export default defineConfig({
|
|||||||
//
|
//
|
||||||
// A flake here is a bug report. Treat it as one.
|
// A flake here is a bug report. Treat it as one.
|
||||||
retries: 0,
|
retries: 0,
|
||||||
workers: 1, // One worker. Multi-worker needs per-worker isolated DBs (Phase 2+).
|
workers: 1, // One worker. Multi-worker needs per-worker isolated DBs (Phase 2+).
|
||||||
reporter: [
|
reporter: [['list'], ['html', { open: 'never', outputFolder: './playwright-report' }]],
|
||||||
['list'],
|
|
||||||
['html', { open: 'never', outputFolder: './playwright-report' }],
|
|
||||||
],
|
|
||||||
globalSetup: './global-setup.ts',
|
globalSetup: './global-setup.ts',
|
||||||
globalTeardown: './global-teardown.ts',
|
globalTeardown: './global-teardown.ts',
|
||||||
timeout: 60_000,
|
timeout: 60_000,
|
||||||
@@ -104,7 +101,7 @@ export default defineConfig({
|
|||||||
{
|
{
|
||||||
name: 'chromium-galaxy-s22',
|
name: 'chromium-galaxy-s22',
|
||||||
use: {
|
use: {
|
||||||
...devices['Galaxy S9+'], // Playwright doesn't ship S22 yet — S9+ is the closest Samsung descriptor.
|
...devices['Galaxy S9+'], // Playwright doesn't ship S22 yet — S9+ is the closest Samsung descriptor.
|
||||||
viewport: { width: 360, height: 780 },
|
viewport: { width: 360, height: 780 },
|
||||||
userAgent:
|
userAgent:
|
||||||
'Mozilla/5.0 (Linux; Android 14; SM-S911B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36',
|
'Mozilla/5.0 (Linux; Android 14; SM-S911B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36',
|
||||||
@@ -160,8 +157,7 @@ export default defineConfig({
|
|||||||
// Firefox rejects `isMobile` (Chromium-only). Keep the phone viewport +
|
// Firefox rejects `isMobile` (Chromium-only). Keep the phone viewport +
|
||||||
// Android UA for coverage, but drop the unsupported flag.
|
// Android UA for coverage, but drop the unsupported flag.
|
||||||
isMobile: false,
|
isMobile: false,
|
||||||
userAgent:
|
userAgent: 'Mozilla/5.0 (Android 14; Mobile; rv:124.0) Gecko/124.0 Firefox/124.0',
|
||||||
'Mozilla/5.0 (Android 14; Mobile; rv:124.0) Gecko/124.0 Firefox/124.0',
|
|
||||||
},
|
},
|
||||||
grep: /@smoke/,
|
grep: /@smoke/,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ test.describe('Auth — admin login', () => {
|
|||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
sessionRole: decodeRole(sessionStorage.getItem('eventsnap_jwt')),
|
sessionRole: decodeRole(sessionStorage.getItem('eventsnap_jwt')),
|
||||||
localToken: localStorage.getItem('eventsnap_jwt')
|
localToken: localStorage.getItem('eventsnap_jwt'),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
expect(stores.sessionRole).toBe('admin');
|
expect(stores.sessionRole).toBe('admin');
|
||||||
|
|||||||
@@ -6,7 +6,9 @@
|
|||||||
import { test, expect } from '../../fixtures/test';
|
import { test, expect } from '../../fixtures/test';
|
||||||
|
|
||||||
test.describe('Navigation — back chevrons', () => {
|
test.describe('Navigation — back chevrons', () => {
|
||||||
test('/recover back chevron navigates to /feed (which redirects to /join when unauth)', async ({ page }) => {
|
test('/recover back chevron navigates to /feed (which redirects to /join when unauth)', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
await page.goto('/recover');
|
await page.goto('/recover');
|
||||||
const back = page.getByTestId('recover-back');
|
const back = page.getByTestId('recover-back');
|
||||||
await expect(back).toBeVisible();
|
await expect(back).toBeVisible();
|
||||||
@@ -15,7 +17,11 @@ test.describe('Navigation — back chevrons', () => {
|
|||||||
await page.waitForURL(/\/(join|feed)$/);
|
await page.waitForURL(/\/(join|feed)$/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('/export back chevron returns the authenticated guest to /feed', async ({ page, guest, signIn }) => {
|
test('/export back chevron returns the authenticated guest to /feed', async ({
|
||||||
|
page,
|
||||||
|
guest,
|
||||||
|
signIn,
|
||||||
|
}) => {
|
||||||
const g = await guest('ExportBack');
|
const g = await guest('ExportBack');
|
||||||
await signIn(page, g);
|
await signIn(page, g);
|
||||||
await page.goto('/export');
|
await page.goto('/export');
|
||||||
|
|||||||
@@ -41,7 +41,10 @@ test.describe('Auth — join flow', () => {
|
|||||||
await page.waitForURL('**/feed', { timeout: 5_000 });
|
await page.waitForURL('**/feed', { timeout: 5_000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('returning guest, new device: same name shows the inline recovery form', async ({ page, guest }) => {
|
test('returning guest, new device: same name shows the inline recovery form', async ({
|
||||||
|
page,
|
||||||
|
guest,
|
||||||
|
}) => {
|
||||||
const original = await guest('Charlie');
|
const original = await guest('Charlie');
|
||||||
|
|
||||||
// Brand-new browser context (cleared storage) — landing on /join with same name
|
// Brand-new browser context (cleared storage) — landing on /join with same name
|
||||||
|
|||||||
@@ -7,7 +7,11 @@ import { AccountPage } from '../../page-objects';
|
|||||||
import { readStorage } from '../../helpers/storage-helpers';
|
import { readStorage } from '../../helpers/storage-helpers';
|
||||||
|
|
||||||
test.describe('Auth — leave event', () => {
|
test.describe('Auth — leave event', () => {
|
||||||
test('leave event clears localStorage and redirects to /join', async ({ page, guest, signIn }) => {
|
test('leave event clears localStorage and redirects to /join', async ({
|
||||||
|
page,
|
||||||
|
guest,
|
||||||
|
signIn,
|
||||||
|
}) => {
|
||||||
const h = await guest('Jens');
|
const h = await guest('Jens');
|
||||||
await signIn(page, h);
|
await signIn(page, h);
|
||||||
|
|
||||||
|
|||||||
@@ -5,14 +5,13 @@
|
|||||||
* revoked token must stop authenticating.
|
* revoked token must stop authenticating.
|
||||||
*/
|
*/
|
||||||
import { test, expect } from '../../fixtures/test';
|
import { test, expect } from '../../fixtures/test';
|
||||||
|
import { BASE } from '../../helpers/env';
|
||||||
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
|
|
||||||
|
|
||||||
const ctx = (jwt: string) =>
|
const ctx = (jwt: string) =>
|
||||||
fetch(`${BASE}/api/v1/me/context`, { headers: { Authorization: `Bearer ${jwt}` } });
|
fetch(`${BASE}/api/v1/me/context`, { headers: { Authorization: `Bearer ${jwt}` } });
|
||||||
|
|
||||||
test.describe('Auth — sign out everywhere', () => {
|
test.describe('Auth — sign out everywhere', () => {
|
||||||
test('DELETE /sessions revokes ALL of the caller\'s sessions, not just the current one', async ({
|
test("DELETE /sessions revokes ALL of the caller's sessions, not just the current one", async ({
|
||||||
api,
|
api,
|
||||||
guest,
|
guest,
|
||||||
db,
|
db,
|
||||||
@@ -41,7 +40,7 @@ test.describe('Auth — sign out everywhere', () => {
|
|||||||
expect(await db.countSessionsForUser(g.userId)).toBe(0);
|
expect(await db.countSessionsForUser(g.userId)).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('one user signing out everywhere does not touch another user\'s sessions', async ({
|
test("one user signing out everywhere does not touch another user's sessions", async ({
|
||||||
guest,
|
guest,
|
||||||
}) => {
|
}) => {
|
||||||
const a = await guest('SignsOut');
|
const a = await guest('SignsOut');
|
||||||
|
|||||||
@@ -13,7 +13,10 @@ import { JoinPage, RecoverPage } from '../../page-objects';
|
|||||||
import { clearAllStorage } from '../../helpers/storage-helpers';
|
import { clearAllStorage } from '../../helpers/storage-helpers';
|
||||||
|
|
||||||
test.describe('Auth — PIN auto-submit', () => {
|
test.describe('Auth — PIN auto-submit', () => {
|
||||||
test('inline recovery: 4th digit auto-submits and navigates to /feed', async ({ page, guest }) => {
|
test('inline recovery: 4th digit auto-submits and navigates to /feed', async ({
|
||||||
|
page,
|
||||||
|
guest,
|
||||||
|
}) => {
|
||||||
const original = await guest('AutoInline');
|
const original = await guest('AutoInline');
|
||||||
await clearAllStorage(page);
|
await clearAllStorage(page);
|
||||||
|
|
||||||
@@ -30,7 +33,10 @@ test.describe('Auth — PIN auto-submit', () => {
|
|||||||
await page.waitForURL('**/feed', { timeout: 5_000 });
|
await page.waitForURL('**/feed', { timeout: 5_000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('/recover: 4th digit auto-submits when the name is already filled in', async ({ page, guest }) => {
|
test('/recover: 4th digit auto-submits when the name is already filled in', async ({
|
||||||
|
page,
|
||||||
|
guest,
|
||||||
|
}) => {
|
||||||
const original = await guest('AutoRecover');
|
const original = await guest('AutoRecover');
|
||||||
await clearAllStorage(page);
|
await clearAllStorage(page);
|
||||||
|
|
||||||
|
|||||||
@@ -9,8 +9,7 @@
|
|||||||
* - a host reset REVOKES the target's sessions (the forgotten/compromised device is logged out)
|
* - a host reset REVOKES the target's sessions (the forgotten/compromised device is logged out)
|
||||||
*/
|
*/
|
||||||
import { test, expect } from '../../fixtures/test';
|
import { test, expect } from '../../fixtures/test';
|
||||||
|
import { BASE } from '../../helpers/env';
|
||||||
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
|
|
||||||
|
|
||||||
const requestReset = (displayName: string) =>
|
const requestReset = (displayName: string) =>
|
||||||
fetch(`${BASE}/api/v1/recover/request`, {
|
fetch(`${BASE}/api/v1/recover/request`, {
|
||||||
@@ -59,9 +58,8 @@ test.describe('PIN reset — in-app request lifecycle', () => {
|
|||||||
api,
|
api,
|
||||||
host,
|
host,
|
||||||
db,
|
db,
|
||||||
adminToken,
|
|
||||||
}) => {
|
}) => {
|
||||||
// The admin user exists (adminToken logged them in). Their display name is "Admin" by
|
// The admin user exists (the host fixture logs an admin in to create it). Its display name is "Admin" by
|
||||||
// convention; request a reset for it and confirm the queue stays empty — the INSERT filters
|
// convention; request a reset for it and confirm the queue stays empty — the INSERT filters
|
||||||
// `role <> 'admin'`, so queuing a reset for an admin would be a privilege-relevant leak.
|
// `role <> 'admin'`, so queuing a reset for an admin would be a privilege-relevant leak.
|
||||||
const admin = (await api.listUsers(host.jwt)).find((u: any) => u.role === 'admin');
|
const admin = (await api.listUsers(host.jwt)).find((u: any) => u.role === 'admin');
|
||||||
|
|||||||
@@ -31,14 +31,25 @@ test.describe('Upload — gallery path', () => {
|
|||||||
const h = await guest('Uploader2');
|
const h = await guest('Uploader2');
|
||||||
const { uploadRaw } = await import('../../helpers/upload-client');
|
const { uploadRaw } = await import('../../helpers/upload-client');
|
||||||
const { readFileSync } = await import('node:fs');
|
const { readFileSync } = await import('node:fs');
|
||||||
const r1 = await uploadRaw(h.jwt, readFileSync(SAMPLE_JPG), { filename: 'a.jpg', contentType: 'image/jpeg' });
|
const r1 = await uploadRaw(h.jwt, readFileSync(SAMPLE_JPG), {
|
||||||
const r2 = await uploadRaw(h.jwt, readFileSync(SAMPLE_JPG_2), { filename: 'b.jpg', contentType: 'image/jpeg' });
|
filename: 'a.jpg',
|
||||||
|
contentType: 'image/jpeg',
|
||||||
|
});
|
||||||
|
const r2 = await uploadRaw(h.jwt, readFileSync(SAMPLE_JPG_2), {
|
||||||
|
filename: 'b.jpg',
|
||||||
|
contentType: 'image/jpeg',
|
||||||
|
});
|
||||||
expect(r1.status).toBe(201);
|
expect(r1.status).toBe(201);
|
||||||
expect(r2.status).toBe(201);
|
expect(r2.status).toBe(201);
|
||||||
await expect.poll(() => db.countUploadsForUser(h.userId), { timeout: 10_000 }).toBe(2);
|
await expect.poll(() => db.countUploadsForUser(h.userId), { timeout: 10_000 }).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('UI flow: FAB → UploadSheet → /upload → submit drives a real XHR upload', async ({ page, guest, signIn, db }) => {
|
test('UI flow: FAB → UploadSheet → /upload → submit drives a real XHR upload', async ({
|
||||||
|
page,
|
||||||
|
guest,
|
||||||
|
signIn,
|
||||||
|
db,
|
||||||
|
}) => {
|
||||||
// Previously fixme'd: the UI queue never fired a POST. Root cause was NOT a
|
// Previously fixme'd: the UI queue never fired a POST. Root cause was NOT a
|
||||||
// navigation/blob timing quirk but an IndexedDB upgrade bug — the v1→v2
|
// navigation/blob timing quirk but an IndexedDB upgrade bug — the v1→v2
|
||||||
// `upgrade` callback opened a *new* transaction, which throws during a
|
// `upgrade` callback opened a *new* transaction, which throws during a
|
||||||
@@ -74,13 +85,20 @@ test.describe('Upload — gallery path', () => {
|
|||||||
const { id } = await res.json();
|
const { id } = await res.json();
|
||||||
|
|
||||||
// Wait up to 10s for the compression worker's SSE.
|
// Wait up to 10s for the compression worker's SSE.
|
||||||
const evt = await sse.waitForEvent('upload-processed', (e) => {
|
const evt = await sse
|
||||||
const data = typeof e.data === 'string' ? safeJson(e.data) : e.data;
|
.waitForEvent(
|
||||||
return data?.upload_id === id || data?.id === id;
|
'upload-processed',
|
||||||
}, 10_000).catch(() => null);
|
(e) => {
|
||||||
|
const data = typeof e.data === 'string' ? safeJson(e.data) : e.data;
|
||||||
|
return data?.upload_id === id || data?.id === id;
|
||||||
|
},
|
||||||
|
10_000
|
||||||
|
)
|
||||||
|
.catch(() => null);
|
||||||
|
|
||||||
// If the SSE didn't arrive in 10s (slow CI, debug mode), at least we know the upload was accepted.
|
// If the SSE didn't arrive in 10s (slow CI, debug mode), at least we know the upload was accepted.
|
||||||
if (!evt) console.warn('[finding] upload-processed SSE did not arrive within 10s for upload', id);
|
if (!evt)
|
||||||
|
console.warn('[finding] upload-processed SSE did not arrive within 10s for upload', id);
|
||||||
} finally {
|
} finally {
|
||||||
sse.stop();
|
sse.stop();
|
||||||
}
|
}
|
||||||
@@ -88,5 +106,9 @@ test.describe('Upload — gallery path', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
function safeJson(s: string) {
|
function safeJson(s: string) {
|
||||||
try { return JSON.parse(s); } catch { return s; }
|
try {
|
||||||
|
return JSON.parse(s);
|
||||||
|
} catch {
|
||||||
|
return s;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,8 +26,8 @@ import { test, expect } from '../../fixtures/test';
|
|||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
import { uploadPausedMidStream } from '../../helpers/upload-client';
|
import { uploadPausedMidStream } from '../../helpers/upload-client';
|
||||||
|
import { BASE } from '../../helpers/env';
|
||||||
|
|
||||||
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
|
|
||||||
const SAMPLE_BYTES = readFileSync(join(process.cwd(), 'fixtures', 'media', 'sample.jpg'));
|
const SAMPLE_BYTES = readFileSync(join(process.cwd(), 'fixtures', 'media', 'sample.jpg'));
|
||||||
const SIZE = SAMPLE_BYTES.byteLength;
|
const SIZE = SAMPLE_BYTES.byteLength;
|
||||||
|
|
||||||
@@ -64,7 +64,10 @@ async function setLimitTo(
|
|||||||
targetBytes: number
|
targetBytes: number
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
const q = await quotaOf(jwt);
|
const q = await quotaOf(jwt);
|
||||||
expect(q.free_disk_bytes, 'the disk must be readable, else quota fails OPEN and proves nothing').toBeTruthy();
|
expect(
|
||||||
|
q.free_disk_bytes,
|
||||||
|
'the disk must be readable, else quota fails OPEN and proves nothing'
|
||||||
|
).toBeTruthy();
|
||||||
const active = Math.max(q.active_uploaders, 1);
|
const active = Math.max(q.active_uploaders, 1);
|
||||||
const tolerance = (targetBytes * active) / (q.free_disk_bytes as number);
|
const tolerance = (targetBytes * active) / (q.free_disk_bytes as number);
|
||||||
await api.patchConfig(adminToken, { quota_tolerance: tolerance.toExponential(12) });
|
await api.patchConfig(adminToken, { quota_tolerance: tolerance.toExponential(12) });
|
||||||
@@ -98,7 +101,9 @@ test.describe('Upload — storage quota enforcement', () => {
|
|||||||
|
|
||||||
// Nothing was accounted, and nothing reached the feed.
|
// Nothing was accounted, and nothing reached the feed.
|
||||||
expect((await quotaOf(g.jwt)).used_bytes).toBe(0);
|
expect((await quotaOf(g.jwt)).used_bytes).toBe(0);
|
||||||
const feed = await (await fetch(BASE + '/api/v1/feed', { headers: { Authorization: `Bearer ${g.jwt}` } })).json();
|
const feed = await (
|
||||||
|
await fetch(BASE + '/api/v1/feed', { headers: { Authorization: `Bearer ${g.jwt}` } })
|
||||||
|
).json();
|
||||||
expect(feed.uploads).toHaveLength(0);
|
expect(feed.uploads).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,11 @@ const SAMPLE_JPG = join(process.cwd(), 'fixtures', 'media', 'sample.jpg');
|
|||||||
const SAMPLE_BYTES = readFileSync(SAMPLE_JPG);
|
const SAMPLE_BYTES = readFileSync(SAMPLE_JPG);
|
||||||
|
|
||||||
test.describe('Upload — rate limit', () => {
|
test.describe('Upload — rate limit', () => {
|
||||||
test('4th upload in one hour returns 429 with Retry-After', async ({ api, adminToken, guest }) => {
|
test('4th upload in one hour returns 429 with Retry-After', async ({
|
||||||
|
api,
|
||||||
|
adminToken,
|
||||||
|
guest,
|
||||||
|
}) => {
|
||||||
// Enable inside the test body, AFTER all auto-fixtures (truncate) have run,
|
// Enable inside the test body, AFTER all auto-fixtures (truncate) have run,
|
||||||
// so the config can't be reset out from under us by the ordering of hooks
|
// so the config can't be reset out from under us by the ordering of hooks
|
||||||
// vs fixtures.
|
// vs fixtures.
|
||||||
@@ -53,7 +57,11 @@ test.describe('Upload — rate limit', () => {
|
|||||||
expect(limited.headers.get('retry-after')).toBeTruthy();
|
expect(limited.headers.get('retry-after')).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('flipping upload_rate_enabled off bypasses the limit', async ({ api, adminToken, guest }) => {
|
test('flipping upload_rate_enabled off bypasses the limit', async ({
|
||||||
|
api,
|
||||||
|
adminToken,
|
||||||
|
guest,
|
||||||
|
}) => {
|
||||||
await api.patchConfig(adminToken, { upload_rate_enabled: 'false' });
|
await api.patchConfig(adminToken, { upload_rate_enabled: 'false' });
|
||||||
|
|
||||||
const h = await guest('NoQuota');
|
const h = await guest('NoQuota');
|
||||||
|
|||||||
@@ -7,8 +7,7 @@
|
|||||||
*/
|
*/
|
||||||
import { test, expect } from '../../fixtures/test';
|
import { test, expect } from '../../fixtures/test';
|
||||||
import { seedUpload } from '../../helpers/seed';
|
import { seedUpload } from '../../helpers/seed';
|
||||||
|
import { BASE } from '../../helpers/env';
|
||||||
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
|
|
||||||
|
|
||||||
test.describe('Comments — UI round-trip (CR1)', () => {
|
test.describe('Comments — UI round-trip (CR1)', () => {
|
||||||
test('a comment typed in the lightbox persists to the backend', async ({
|
test('a comment typed in the lightbox persists to the backend', async ({
|
||||||
|
|||||||
@@ -8,8 +8,7 @@
|
|||||||
import { test, expect } from '../../fixtures/test';
|
import { test, expect } from '../../fixtures/test';
|
||||||
import { SseListener } from '../../helpers/sse-listener';
|
import { SseListener } from '../../helpers/sse-listener';
|
||||||
import { seedUpload, seedComment, findFeedRow } from '../../helpers/seed';
|
import { seedUpload, seedComment, findFeedRow } from '../../helpers/seed';
|
||||||
|
import { BASE } from '../../helpers/env';
|
||||||
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
|
|
||||||
|
|
||||||
async function like(jwt: string, uploadId: string): Promise<number> {
|
async function like(jwt: string, uploadId: string): Promise<number> {
|
||||||
const res = await fetch(`${BASE}/api/v1/upload/${uploadId}/like`, {
|
const res = await fetch(`${BASE}/api/v1/upload/${uploadId}/like`, {
|
||||||
@@ -20,7 +19,10 @@ async function like(jwt: string, uploadId: string): Promise<number> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
test.describe('Feed — like + comment', () => {
|
test.describe('Feed — like + comment', () => {
|
||||||
test('a like counts once per user and toggles off on repeat (no double-count)', async ({ api, guest }) => {
|
test('a like counts once per user and toggles off on repeat (no double-count)', async ({
|
||||||
|
api,
|
||||||
|
guest,
|
||||||
|
}) => {
|
||||||
const author = await guest('Author');
|
const author = await guest('Author');
|
||||||
const liker = await guest('Liker');
|
const liker = await guest('Liker');
|
||||||
const uploadId = await seedUpload(author.jwt);
|
const uploadId = await seedUpload(author.jwt);
|
||||||
|
|||||||
@@ -11,7 +11,11 @@ import { test, expect } from '../../fixtures/test';
|
|||||||
import { trackStreamOpens } from '../../helpers/sse';
|
import { trackStreamOpens } from '../../helpers/sse';
|
||||||
|
|
||||||
test.describe('Feed — SSE behavior', () => {
|
test.describe('Feed — SSE behavior', () => {
|
||||||
test('backgrounding then foregrounding the tab opens a fresh SSE connection', async ({ page, guest, signIn }) => {
|
test('backgrounding then foregrounding the tab opens a fresh SSE connection', async ({
|
||||||
|
page,
|
||||||
|
guest,
|
||||||
|
signIn,
|
||||||
|
}) => {
|
||||||
const g = await guest('SseReconnect');
|
const g = await guest('SseReconnect');
|
||||||
const streamOpens = trackStreamOpens(page);
|
const streamOpens = trackStreamOpens(page);
|
||||||
|
|
||||||
@@ -25,7 +29,10 @@ test.describe('Feed — SSE behavior', () => {
|
|||||||
// new stream opens while hidden.
|
// new stream opens while hidden.
|
||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
Object.defineProperty(document, 'hidden', { configurable: true, get: () => true });
|
Object.defineProperty(document, 'hidden', { configurable: true, get: () => true });
|
||||||
Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => 'hidden' });
|
Object.defineProperty(document, 'visibilityState', {
|
||||||
|
configurable: true,
|
||||||
|
get: () => 'hidden',
|
||||||
|
});
|
||||||
document.dispatchEvent(new Event('visibilitychange'));
|
document.dispatchEvent(new Event('visibilitychange'));
|
||||||
});
|
});
|
||||||
// Snapshot the count AFTER backgrounding — this baselines out the initial open (and
|
// Snapshot the count AFTER backgrounding — this baselines out the initial open (and
|
||||||
@@ -36,7 +43,10 @@ test.describe('Feed — SSE behavior', () => {
|
|||||||
// Foreground again → connectSse() mints a new ticket and opens a new EventSource.
|
// Foreground again → connectSse() mints a new ticket and opens a new EventSource.
|
||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
Object.defineProperty(document, 'hidden', { configurable: true, get: () => false });
|
Object.defineProperty(document, 'hidden', { configurable: true, get: () => false });
|
||||||
Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => 'visible' });
|
Object.defineProperty(document, 'visibilityState', {
|
||||||
|
configurable: true,
|
||||||
|
get: () => 'visible',
|
||||||
|
});
|
||||||
document.dispatchEvent(new Event('visibilitychange'));
|
document.dispatchEvent(new Event('visibilitychange'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,10 @@ test.describe('Feed — error toast on user action failures', () => {
|
|||||||
route.fulfill({
|
route.fulfill({
|
||||||
status: 429,
|
status: 429,
|
||||||
contentType: 'application/json',
|
contentType: 'application/json',
|
||||||
body: JSON.stringify({ error: 'rate_limited', message: 'Zu viele Anfragen — bitte kurz warten.' }),
|
body: JSON.stringify({
|
||||||
|
error: 'rate_limited',
|
||||||
|
message: 'Zu viele Anfragen — bitte kurz warten.',
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -5,13 +5,16 @@
|
|||||||
* for a guest who's already viewing the feed.
|
* for a guest who's already viewing the feed.
|
||||||
*/
|
*/
|
||||||
import { test, expect } from '../../fixtures/test';
|
import { test, expect } from '../../fixtures/test';
|
||||||
|
import { BASE } from '../../helpers/env';
|
||||||
|
|
||||||
test.describe('Host — event lock', () => {
|
test.describe('Host — event lock', () => {
|
||||||
test('closing the event via API sets uploads_locked_at; opening clears it', async ({ host, api }) => {
|
test('closing the event via API sets uploads_locked_at; opening clears it', async ({
|
||||||
|
host,
|
||||||
|
api,
|
||||||
|
}) => {
|
||||||
// The frontend doesn't (yet) render a per-guest "uploads locked" banner on
|
// The frontend doesn't (yet) render a per-guest "uploads locked" banner on
|
||||||
// the feed — that's the journey §9 banner, currently a UX gap. We assert
|
// the feed — that's the journey §9 banner, currently a UX gap. We assert
|
||||||
// the API + DB contract here and leave the banner check for once it ships.
|
// the API + DB contract here and leave the banner check for once it ships.
|
||||||
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
|
|
||||||
|
|
||||||
await api.closeEvent(host.jwt);
|
await api.closeEvent(host.jwt);
|
||||||
const evRes = await fetch(`${BASE}/api/v1/host/event`, {
|
const evRes = await fetch(`${BASE}/api/v1/host/event`, {
|
||||||
@@ -38,8 +41,11 @@ test.describe('Host — event lock', () => {
|
|||||||
// event (USER_JOURNEYS §9.3, FEATURES capability matrix). Only new uploads are
|
// event (USER_JOURNEYS §9.3, FEATURES capability matrix). Only new uploads are
|
||||||
// rejected. (An earlier revision froze social interaction too; that contradicted
|
// rejected. (An earlier revision froze social interaction too; that contradicted
|
||||||
// the documented behavior and was reverted.)
|
// the documented behavior and was reverted.)
|
||||||
test('a closed event still allows likes and comments, but blocks new uploads', async ({ api, host, guest }) => {
|
test('a closed event still allows likes and comments, but blocks new uploads', async ({
|
||||||
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
|
api,
|
||||||
|
host,
|
||||||
|
guest,
|
||||||
|
}) => {
|
||||||
const g = await guest('SocialLocked');
|
const g = await guest('SocialLocked');
|
||||||
|
|
||||||
// Upload while still open so there's a target to interact with.
|
// Upload while still open so there's a target to interact with.
|
||||||
|
|||||||
@@ -7,7 +7,11 @@ import { test, expect } from '../../fixtures/test';
|
|||||||
import { seedUpload, seedComment } from '../../helpers/seed';
|
import { seedUpload, seedComment } from '../../helpers/seed';
|
||||||
|
|
||||||
test.describe('Host — moderation API', () => {
|
test.describe('Host — moderation API', () => {
|
||||||
test('ban always hides — a banned user is is_banned AND uploads_hidden (USER_JOURNEYS §9.5)', async ({ api, host, guest }) => {
|
test('ban always hides — a banned user is is_banned AND uploads_hidden (USER_JOURNEYS §9.5)', async ({
|
||||||
|
api,
|
||||||
|
host,
|
||||||
|
guest,
|
||||||
|
}) => {
|
||||||
// Ban is unconditionally a hide: a banned user's content is "gone" everywhere. The endpoint
|
// Ban is unconditionally a hide: a banned user's content is "gone" everywhere. The endpoint
|
||||||
// takes no body and there is no per-request opt-out (the legacy hide_uploads flag is gone).
|
// takes no body and there is no per-request opt-out (the legacy hide_uploads flag is gone).
|
||||||
const target = await guest('Banned2');
|
const target = await guest('Banned2');
|
||||||
@@ -23,11 +27,14 @@ test.describe('Host — moderation API', () => {
|
|||||||
await api.banUser(host.jwt, target.userId);
|
await api.banUser(host.jwt, target.userId);
|
||||||
|
|
||||||
// Direct fetch — multipart body shape is just a marker; the auth middleware should reject before parsing.
|
// Direct fetch — multipart body shape is just a marker; the auth middleware should reject before parsing.
|
||||||
const res = await fetch((process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101') + '/api/v1/upload', {
|
const res = await fetch(
|
||||||
method: 'POST',
|
(process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101') + '/api/v1/upload',
|
||||||
headers: { Authorization: `Bearer ${target.jwt}` },
|
{
|
||||||
body: new FormData(),
|
method: 'POST',
|
||||||
});
|
headers: { Authorization: `Bearer ${target.jwt}` },
|
||||||
|
body: new FormData(),
|
||||||
|
}
|
||||||
|
);
|
||||||
expect(res.status).toBe(403);
|
expect(res.status).toBe(403);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -87,15 +94,20 @@ test.describe('Host — live role/ban revocation (H1)', () => {
|
|||||||
|
|
||||||
test('a banned host cannot unban themselves', async ({ api, adminToken, host }) => {
|
test('a banned host cannot unban themselves', async ({ api, adminToken, host }) => {
|
||||||
await api.banUser(adminToken, host.userId);
|
await api.banUser(adminToken, host.userId);
|
||||||
await expect(
|
await expect(api.unbanUser(host.jwt, host.userId, { expectedStatus: [204] })).rejects.toThrow(
|
||||||
api.unbanUser(host.jwt, host.userId, { expectedStatus: [204] })
|
/→ 403/
|
||||||
).rejects.toThrow(/→ 403/);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// H1 / USER_JOURNEYS §10: a banned user keeps *read* access but every write is
|
// H1 / USER_JOURNEYS §10: a banned user keeps *read* access but every write is
|
||||||
// rejected with 403. The ban is enforced on write handlers + Require{Host,Admin},
|
// rejected with 403. The ban is enforced on write handlers + Require{Host,Admin},
|
||||||
// NOT in the base extractor (which would wrongly block reads too).
|
// NOT in the base extractor (which would wrongly block reads too).
|
||||||
test('a banned user keeps read access but is blocked from writes', async ({ api, host, guest, db }) => {
|
test('a banned user keeps read access but is blocked from writes', async ({
|
||||||
|
api,
|
||||||
|
host,
|
||||||
|
guest,
|
||||||
|
db,
|
||||||
|
}) => {
|
||||||
const base = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
|
const base = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
|
||||||
const target = await guest('BannedRW');
|
const target = await guest('BannedRW');
|
||||||
const auth = (jwt: string) => ({ Authorization: `Bearer ${jwt}` });
|
const auth = (jwt: string) => ({ Authorization: `Bearer ${jwt}` });
|
||||||
|
|||||||
@@ -7,8 +7,7 @@
|
|||||||
import { test, expect } from '../../fixtures/test';
|
import { test, expect } from '../../fixtures/test';
|
||||||
import { seedUpload } from '../../helpers/seed';
|
import { seedUpload } from '../../helpers/seed';
|
||||||
import { SseListener } from '../../helpers/sse-listener';
|
import { SseListener } from '../../helpers/sse-listener';
|
||||||
|
import { BASE } from '../../helpers/env';
|
||||||
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
|
|
||||||
|
|
||||||
test.describe('Host — live SSE eviction (H3)', () => {
|
test.describe('Host — live SSE eviction (H3)', () => {
|
||||||
test('self-deleting an upload broadcasts upload-deleted', async ({ guest }) => {
|
test('self-deleting an upload broadcasts upload-deleted', async ({ guest }) => {
|
||||||
@@ -24,17 +23,10 @@ test.describe('Host — live SSE eviction (H3)', () => {
|
|||||||
});
|
});
|
||||||
expect(res.status).toBe(204);
|
expect(res.status).toBe(204);
|
||||||
|
|
||||||
await sse.waitForEvent(
|
await sse.waitForEvent('upload-deleted', (e) => e.data.upload_id === uploadId);
|
||||||
'upload-deleted',
|
|
||||||
(e) => e.data.upload_id === uploadId
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('banning a user broadcasts user-hidden', async ({
|
test('banning a user broadcasts user-hidden', async ({ api, host, guest }) => {
|
||||||
api,
|
|
||||||
host,
|
|
||||||
guest,
|
|
||||||
}) => {
|
|
||||||
const target = await guest('HideTarget');
|
const target = await guest('HideTarget');
|
||||||
await seedUpload(target.jwt, { caption: 'should vanish' });
|
await seedUpload(target.jwt, { caption: 'should vanish' });
|
||||||
|
|
||||||
@@ -43,10 +35,7 @@ test.describe('Host — live SSE eviction (H3)', () => {
|
|||||||
|
|
||||||
await api.banUser(host.jwt, target.userId);
|
await api.banUser(host.jwt, target.userId);
|
||||||
|
|
||||||
await sse.waitForEvent(
|
await sse.waitForEvent('user-hidden', (e) => e.data.user_id === target.userId);
|
||||||
'user-hidden',
|
|
||||||
(e) => e.data.user_id === target.userId
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Frontend regression: the broadcasts above are inert if the client never
|
// Frontend regression: the broadcasts above are inert if the client never
|
||||||
|
|||||||
@@ -4,8 +4,7 @@
|
|||||||
* guest can't hit host/admin; host can't hit admin.
|
* guest can't hit host/admin; host can't hit admin.
|
||||||
*/
|
*/
|
||||||
import { test, expect } from '../../fixtures/test';
|
import { test, expect } from '../../fixtures/test';
|
||||||
|
import { BASE } from '../../helpers/env';
|
||||||
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
|
|
||||||
|
|
||||||
test.describe('Authorization escalation guards', () => {
|
test.describe('Authorization escalation guards', () => {
|
||||||
test('guest → POST /host/event/close returns 403', async ({ guest }) => {
|
test('guest → POST /host/event/close returns 403', async ({ guest }) => {
|
||||||
|
|||||||
@@ -19,8 +19,7 @@
|
|||||||
* still download the keepsake) is BY DESIGN, and is asserted in 07-adversarial/authorization-deep.
|
* still download the keepsake) is BY DESIGN, and is asserted in 07-adversarial/authorization-deep.
|
||||||
*/
|
*/
|
||||||
import { test, expect } from '../../fixtures/test';
|
import { test, expect } from '../../fixtures/test';
|
||||||
|
import { BASE } from '../../helpers/env';
|
||||||
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
|
|
||||||
|
|
||||||
type Method = 'GET' | 'POST' | 'PATCH' | 'DELETE';
|
type Method = 'GET' | 'POST' | 'PATCH' | 'DELETE';
|
||||||
interface Route {
|
interface Route {
|
||||||
@@ -37,23 +36,66 @@ const ROUTES: Route[] = [
|
|||||||
{ method: 'GET', path: () => '/api/v1/host/event', name: 'GET /host/event' },
|
{ method: 'GET', path: () => '/api/v1/host/event', name: 'GET /host/event' },
|
||||||
{ method: 'POST', path: () => '/api/v1/host/event/close', name: 'POST /host/event/close' },
|
{ method: 'POST', path: () => '/api/v1/host/event/close', name: 'POST /host/event/close' },
|
||||||
{ method: 'POST', path: () => '/api/v1/host/event/open', name: 'POST /host/event/open' },
|
{ method: 'POST', path: () => '/api/v1/host/event/open', name: 'POST /host/event/open' },
|
||||||
{ method: 'POST', path: () => '/api/v1/host/gallery/release', name: 'POST /host/gallery/release' },
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: () => '/api/v1/host/gallery/release',
|
||||||
|
name: 'POST /host/gallery/release',
|
||||||
|
},
|
||||||
{ method: 'POST', path: () => '/api/v1/host/export/rebuild', name: 'POST /host/export/rebuild' },
|
{ method: 'POST', path: () => '/api/v1/host/export/rebuild', name: 'POST /host/export/rebuild' },
|
||||||
{ method: 'GET', path: () => '/api/v1/host/users', name: 'GET /host/users' },
|
{ method: 'GET', path: () => '/api/v1/host/users', name: 'GET /host/users' },
|
||||||
{ method: 'POST', path: (v) => `/api/v1/host/users/${v}/ban`, name: 'POST /host/users/{id}/ban', body: {} },
|
{
|
||||||
{ method: 'POST', path: (v) => `/api/v1/host/users/${v}/unban`, name: 'POST /host/users/{id}/unban', body: {} },
|
method: 'POST',
|
||||||
{ method: 'PATCH', path: (v) => `/api/v1/host/users/${v}/role`, name: 'PATCH /host/users/{id}/role', body: { role: 'host' } },
|
path: (v) => `/api/v1/host/users/${v}/ban`,
|
||||||
{ method: 'POST', path: (v) => `/api/v1/host/users/${v}/pin-reset`, name: 'POST /host/users/{id}/pin-reset', body: {} },
|
name: 'POST /host/users/{id}/ban',
|
||||||
{ method: 'GET', path: () => '/api/v1/host/pin-reset-requests', name: 'GET /host/pin-reset-requests' },
|
body: {},
|
||||||
{ method: 'DELETE', path: (v) => `/api/v1/host/pin-reset-requests/${v}`, name: 'DELETE /host/pin-reset-requests/{id}' },
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: (v) => `/api/v1/host/users/${v}/unban`,
|
||||||
|
name: 'POST /host/users/{id}/unban',
|
||||||
|
body: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'PATCH',
|
||||||
|
path: (v) => `/api/v1/host/users/${v}/role`,
|
||||||
|
name: 'PATCH /host/users/{id}/role',
|
||||||
|
body: { role: 'host' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: (v) => `/api/v1/host/users/${v}/pin-reset`,
|
||||||
|
name: 'POST /host/users/{id}/pin-reset',
|
||||||
|
body: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
path: () => '/api/v1/host/pin-reset-requests',
|
||||||
|
name: 'GET /host/pin-reset-requests',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'DELETE',
|
||||||
|
path: (v) => `/api/v1/host/pin-reset-requests/${v}`,
|
||||||
|
name: 'DELETE /host/pin-reset-requests/{id}',
|
||||||
|
},
|
||||||
{ method: 'DELETE', path: (v) => `/api/v1/host/upload/${v}`, name: 'DELETE /host/upload/{id}' },
|
{ method: 'DELETE', path: (v) => `/api/v1/host/upload/${v}`, name: 'DELETE /host/upload/{id}' },
|
||||||
{ method: 'DELETE', path: (v) => `/api/v1/host/comment/${v}`, name: 'DELETE /host/comment/{id}' },
|
{ method: 'DELETE', path: (v) => `/api/v1/host/comment/${v}`, name: 'DELETE /host/comment/{id}' },
|
||||||
|
|
||||||
// ── Admin surface ───────────────────────────────────────────────────────
|
// ── Admin surface ───────────────────────────────────────────────────────
|
||||||
{ method: 'GET', path: () => '/api/v1/admin/stats', name: 'GET /admin/stats', adminOnly: true },
|
{ method: 'GET', path: () => '/api/v1/admin/stats', name: 'GET /admin/stats', adminOnly: true },
|
||||||
{ method: 'GET', path: () => '/api/v1/admin/config', name: 'GET /admin/config', adminOnly: true },
|
{ method: 'GET', path: () => '/api/v1/admin/config', name: 'GET /admin/config', adminOnly: true },
|
||||||
{ method: 'PATCH', path: () => '/api/v1/admin/config', name: 'PATCH /admin/config', adminOnly: true, body: { quota_enabled: 'false' } },
|
{
|
||||||
{ method: 'GET', path: () => '/api/v1/admin/export/jobs', name: 'GET /admin/export/jobs', adminOnly: true },
|
method: 'PATCH',
|
||||||
|
path: () => '/api/v1/admin/config',
|
||||||
|
name: 'PATCH /admin/config',
|
||||||
|
adminOnly: true,
|
||||||
|
body: { quota_enabled: 'false' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
path: () => '/api/v1/admin/export/jobs',
|
||||||
|
name: 'GET /admin/export/jobs',
|
||||||
|
adminOnly: true,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
async function call(route: Route, jwt: string, victimId: string): Promise<number> {
|
async function call(route: Route, jwt: string, victimId: string): Promise<number> {
|
||||||
@@ -89,7 +131,10 @@ test.describe('Authorization sweep — every privileged route', () => {
|
|||||||
|
|
||||||
const status = await call(route, host.jwt, victim.userId);
|
const status = await call(route, host.jwt, victim.userId);
|
||||||
|
|
||||||
expect(status, `${route.name} is admin-only and must reject a host with 403, got ${status}`).toBe(403);
|
expect(
|
||||||
|
status,
|
||||||
|
`${route.name} is admin-only and must reject a host with 403, got ${status}`
|
||||||
|
).toBe(403);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,9 +166,10 @@ test.describe('Authorization sweep — every privileged route', () => {
|
|||||||
headers: route.body !== undefined ? { 'Content-Type': 'application/json' } : {},
|
headers: route.body !== undefined ? { 'Content-Type': 'application/json' } : {},
|
||||||
...(route.body !== undefined ? { body: JSON.stringify(route.body) } : {}),
|
...(route.body !== undefined ? { body: JSON.stringify(route.body) } : {}),
|
||||||
});
|
});
|
||||||
expect([401, 403], `${route.name} must reject an anonymous caller, got ${res.status}`).toContain(
|
expect(
|
||||||
res.status
|
[401, 403],
|
||||||
);
|
`${route.name} must reject an anonymous caller, got ${res.status}`
|
||||||
|
).toContain(res.status);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,21 +13,27 @@ test.describe('Admin — config API', () => {
|
|||||||
await api.patchConfig(adminToken, { max_image_size_mb: '20' });
|
await api.patchConfig(adminToken, { max_image_size_mb: '20' });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('non-numeric value for a numeric key is rejected', async ({ api, adminToken }) => {
|
test('non-numeric value for a numeric key is rejected', async ({ adminToken }) => {
|
||||||
const res = await fetch((process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101') + '/api/v1/admin/config', {
|
const res = await fetch(
|
||||||
method: 'PATCH',
|
(process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101') + '/api/v1/admin/config',
|
||||||
headers: { Authorization: `Bearer ${adminToken}`, 'Content-Type': 'application/json' },
|
{
|
||||||
body: JSON.stringify({ max_image_size_mb: 'not-a-number' }),
|
method: 'PATCH',
|
||||||
});
|
headers: { Authorization: `Bearer ${adminToken}`, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ max_image_size_mb: 'not-a-number' }),
|
||||||
|
}
|
||||||
|
);
|
||||||
expect(res.status).toBe(400);
|
expect(res.status).toBe(400);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('unknown config key is rejected (whitelist enforced)', async ({ adminToken }) => {
|
test('unknown config key is rejected (whitelist enforced)', async ({ adminToken }) => {
|
||||||
const res = await fetch((process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101') + '/api/v1/admin/config', {
|
const res = await fetch(
|
||||||
method: 'PATCH',
|
(process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101') + '/api/v1/admin/config',
|
||||||
headers: { Authorization: `Bearer ${adminToken}`, 'Content-Type': 'application/json' },
|
{
|
||||||
body: JSON.stringify({ totally_fake_key: '1' }),
|
method: 'PATCH',
|
||||||
});
|
headers: { Authorization: `Bearer ${adminToken}`, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ totally_fake_key: '1' }),
|
||||||
|
}
|
||||||
|
);
|
||||||
expect(res.status).toBe(400);
|
expect(res.status).toBe(400);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -40,16 +46,23 @@ test.describe('Admin — config API', () => {
|
|||||||
cfg = await api.getConfig(adminToken);
|
cfg = await api.getConfig(adminToken);
|
||||||
expect(cfg.upload_rate_enabled).toBe('false');
|
expect(cfg.upload_rate_enabled).toBe('false');
|
||||||
|
|
||||||
const res = await fetch((process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101') + '/api/v1/admin/config', {
|
const res = await fetch(
|
||||||
method: 'PATCH',
|
(process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101') + '/api/v1/admin/config',
|
||||||
headers: { Authorization: `Bearer ${adminToken}`, 'Content-Type': 'application/json' },
|
{
|
||||||
body: JSON.stringify({ upload_rate_enabled: 'maybe' }),
|
method: 'PATCH',
|
||||||
});
|
headers: { Authorization: `Bearer ${adminToken}`, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ upload_rate_enabled: 'maybe' }),
|
||||||
|
}
|
||||||
|
);
|
||||||
expect(res.status).toBe(400);
|
expect(res.status).toBe(400);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('privacy_note round-trips verbatim, preserving whitespace + newlines', async ({ api, adminToken }) => {
|
test('privacy_note round-trips verbatim, preserving whitespace + newlines', async ({
|
||||||
const note = ' Datenschutz\n • Wir verwenden keine Cookies.\n • Alles bleibt im Browser.\n\n— Dein Host';
|
api,
|
||||||
|
adminToken,
|
||||||
|
}) => {
|
||||||
|
const note =
|
||||||
|
' Datenschutz\n • Wir verwenden keine Cookies.\n • Alles bleibt im Browser.\n\n— Dein Host';
|
||||||
await api.patchConfig(adminToken, { privacy_note: note });
|
await api.patchConfig(adminToken, { privacy_note: note });
|
||||||
const cfg = await api.getConfig(adminToken);
|
const cfg = await api.getConfig(adminToken);
|
||||||
expect(cfg.privacy_note).toBe(note);
|
expect(cfg.privacy_note).toBe(note);
|
||||||
@@ -58,7 +71,11 @@ test.describe('Admin — config API', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test.describe('Admin — stats', () => {
|
test.describe('Admin — stats', () => {
|
||||||
test('GET /admin/stats returns matching counts after seeding users', async ({ api, adminToken, guest }) => {
|
test('GET /admin/stats returns matching counts after seeding users', async ({
|
||||||
|
api,
|
||||||
|
adminToken,
|
||||||
|
guest,
|
||||||
|
}) => {
|
||||||
await guest('Stat1');
|
await guest('Stat1');
|
||||||
await guest('Stat2');
|
await guest('Stat2');
|
||||||
await guest('Stat3');
|
await guest('Stat3');
|
||||||
|
|||||||
@@ -12,8 +12,7 @@
|
|||||||
*/
|
*/
|
||||||
import { test, expect } from '../../fixtures/test';
|
import { test, expect } from '../../fixtures/test';
|
||||||
import { seedUpload } from '../../helpers/seed';
|
import { seedUpload } from '../../helpers/seed';
|
||||||
|
import { BASE } from '../../helpers/env';
|
||||||
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
|
|
||||||
|
|
||||||
test.describe('Export — no public leak (CR2)', () => {
|
test.describe('Export — no public leak (CR2)', () => {
|
||||||
test('a real export is downloadable only via the gated endpoint, never via /media', async ({
|
test('a real export is downloadable only via the gated endpoint, never via /media', async ({
|
||||||
@@ -26,7 +25,10 @@ test.describe('Export — no public leak (CR2)', () => {
|
|||||||
await seedUpload(host.jwt, { caption: 'in the export' });
|
await seedUpload(host.jwt, { caption: 'in the export' });
|
||||||
|
|
||||||
// Host releases the gallery → spawns the real zip/html export jobs.
|
// Host releases the gallery → spawns the real zip/html export jobs.
|
||||||
const rel = await fetch(`${BASE}/api/v1/host/gallery/release`, { method: 'POST', headers: bearer });
|
const rel = await fetch(`${BASE}/api/v1/host/gallery/release`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: bearer,
|
||||||
|
});
|
||||||
expect(rel.status).toBe(204);
|
expect(rel.status).toBe(204);
|
||||||
|
|
||||||
// Wait for the real zip job to finish writing the archive to disk.
|
// Wait for the real zip job to finish writing the archive to disk.
|
||||||
@@ -49,7 +51,10 @@ test.describe('Export — no public leak (CR2)', () => {
|
|||||||
|
|
||||||
// …but IS retrievable via the gated single-use ticket endpoint. This proves the
|
// …but IS retrievable via the gated single-use ticket endpoint. This proves the
|
||||||
// 404 above means "not public", not merely "no file was produced".
|
// 404 above means "not public", not merely "no file was produced".
|
||||||
const ticketRes = await fetch(`${BASE}/api/v1/export/ticket`, { method: 'POST', headers: bearer });
|
const ticketRes = await fetch(`${BASE}/api/v1/export/ticket`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: bearer,
|
||||||
|
});
|
||||||
const { ticket } = await ticketRes.json();
|
const { ticket } = await ticketRes.json();
|
||||||
const dl = await fetch(`${BASE}/api/v1/export/zip?ticket=${encodeURIComponent(ticket)}`);
|
const dl = await fetch(`${BASE}/api/v1/export/zip?ticket=${encodeURIComponent(ticket)}`);
|
||||||
expect(dl.status).toBe(200);
|
expect(dl.status).toBe(200);
|
||||||
|
|||||||
@@ -14,8 +14,7 @@ import { test, expect } from '../../fixtures/test';
|
|||||||
import { uploadRaw } from '../../helpers/upload-client';
|
import { uploadRaw } from '../../helpers/upload-client';
|
||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
|
import { BASE } from '../../helpers/env';
|
||||||
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
|
|
||||||
|
|
||||||
test.describe('Export — video streaming (P4)', () => {
|
test.describe('Export — video streaming (P4)', () => {
|
||||||
test('a real video upload is streamed into Memories.zip (not dropped)', async ({ host }) => {
|
test('a real video upload is streamed into Memories.zip (not dropped)', async ({ host }) => {
|
||||||
@@ -29,7 +28,10 @@ test.describe('Export — video streaming (P4)', () => {
|
|||||||
const { id } = await up.json();
|
const { id } = await up.json();
|
||||||
|
|
||||||
// Release the gallery → spawns the real zip + html export jobs.
|
// Release the gallery → spawns the real zip + html export jobs.
|
||||||
const rel = await fetch(`${BASE}/api/v1/host/gallery/release`, { method: 'POST', headers: bearer });
|
const rel = await fetch(`${BASE}/api/v1/host/gallery/release`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: bearer,
|
||||||
|
});
|
||||||
expect(rel.status).toBe(204);
|
expect(rel.status).toBe(204);
|
||||||
|
|
||||||
// Wait for the HTML (Memories.zip) job — that's the one whose media staging P4 changed.
|
// Wait for the HTML (Memories.zip) job — that's the one whose media staging P4 changed.
|
||||||
@@ -44,7 +46,10 @@ test.describe('Export — video streaming (P4)', () => {
|
|||||||
.toBe('done');
|
.toBe('done');
|
||||||
|
|
||||||
// Download Memories.zip via the gated single-use ticket.
|
// Download Memories.zip via the gated single-use ticket.
|
||||||
const ticketRes = await fetch(`${BASE}/api/v1/export/ticket`, { method: 'POST', headers: bearer });
|
const ticketRes = await fetch(`${BASE}/api/v1/export/ticket`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: bearer,
|
||||||
|
});
|
||||||
const { ticket } = await ticketRes.json();
|
const { ticket } = await ticketRes.json();
|
||||||
const dl = await fetch(`${BASE}/api/v1/export/html?ticket=${encodeURIComponent(ticket)}`);
|
const dl = await fetch(`${BASE}/api/v1/export/html?ticket=${encodeURIComponent(ticket)}`);
|
||||||
expect(dl.status).toBe(200);
|
expect(dl.status).toBe(200);
|
||||||
|
|||||||
@@ -11,7 +11,11 @@ import { ExportPage } from '../../page-objects';
|
|||||||
const SLUG = 'e2e-test-event';
|
const SLUG = 'e2e-test-event';
|
||||||
|
|
||||||
test.describe('Export — release and download', () => {
|
test.describe('Export — release and download', () => {
|
||||||
test('/export shows the "not yet available" state before release', async ({ page, guest, signIn }) => {
|
test('/export shows the "not yet available" state before release', async ({
|
||||||
|
page,
|
||||||
|
guest,
|
||||||
|
signIn,
|
||||||
|
}) => {
|
||||||
const g = await guest('PreRelease');
|
const g = await guest('PreRelease');
|
||||||
await signIn(page, g);
|
await signIn(page, g);
|
||||||
const exportPage = new ExportPage(page);
|
const exportPage = new ExportPage(page);
|
||||||
@@ -55,9 +59,12 @@ test.describe('Export — release and download', () => {
|
|||||||
test('export status API reflects released flag', async ({ guest, db }) => {
|
test('export status API reflects released flag', async ({ guest, db }) => {
|
||||||
const g = await guest('ReleaseQuery');
|
const g = await guest('ReleaseQuery');
|
||||||
|
|
||||||
let res = await fetch((process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101') + '/api/v1/export/status', {
|
let res = await fetch(
|
||||||
headers: { Authorization: `Bearer ${g.jwt}` },
|
(process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101') + '/api/v1/export/status',
|
||||||
});
|
{
|
||||||
|
headers: { Authorization: `Bearer ${g.jwt}` },
|
||||||
|
}
|
||||||
|
);
|
||||||
let body: any = await res.json();
|
let body: any = await res.json();
|
||||||
expect(body.released).toBe(false);
|
expect(body.released).toBe(false);
|
||||||
|
|
||||||
@@ -65,9 +72,12 @@ test.describe('Export — release and download', () => {
|
|||||||
await db.fakeExportJob(SLUG, 'zip', 'done');
|
await db.fakeExportJob(SLUG, 'zip', 'done');
|
||||||
await db.fakeExportJob(SLUG, 'html', 'done');
|
await db.fakeExportJob(SLUG, 'html', 'done');
|
||||||
|
|
||||||
res = await fetch((process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101') + '/api/v1/export/status', {
|
res = await fetch(
|
||||||
headers: { Authorization: `Bearer ${g.jwt}` },
|
(process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101') + '/api/v1/export/status',
|
||||||
});
|
{
|
||||||
|
headers: { Authorization: `Bearer ${g.jwt}` },
|
||||||
|
}
|
||||||
|
);
|
||||||
body = await res.json();
|
body = await res.json();
|
||||||
expect(body.released).toBe(true);
|
expect(body.released).toBe(true);
|
||||||
expect(body.zip.status).toBe('done');
|
expect(body.zip.status).toBe('done');
|
||||||
|
|||||||
@@ -8,23 +8,28 @@
|
|||||||
*/
|
*/
|
||||||
import { test, expect } from '../../fixtures/test';
|
import { test, expect } from '../../fixtures/test';
|
||||||
import { ADMIN_PASSWORD } from '../../fixtures/api-client';
|
import { ADMIN_PASSWORD } from '../../fixtures/api-client';
|
||||||
|
import { BASE } from '../../helpers/env';
|
||||||
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
|
|
||||||
|
|
||||||
/** RFC-4648 base64url with no padding. */
|
/** RFC-4648 base64url with no padding. */
|
||||||
function b64u(s: string) {
|
function b64u(s: string) {
|
||||||
return Buffer.from(s).toString('base64').replace(/=+$/, '').replace(/\+/g, '-').replace(/\//g, '_');
|
return Buffer.from(s)
|
||||||
|
.toString('base64')
|
||||||
|
.replace(/=+$/, '')
|
||||||
|
.replace(/\+/g, '-')
|
||||||
|
.replace(/\//g, '_');
|
||||||
}
|
}
|
||||||
|
|
||||||
test.describe('Adversarial — JWT', () => {
|
test.describe('Adversarial — JWT', () => {
|
||||||
test('alg:none token claiming admin role is rejected', async () => {
|
test('alg:none token claiming admin role is rejected', async () => {
|
||||||
const header = b64u(JSON.stringify({ alg: 'none', typ: 'JWT' }));
|
const header = b64u(JSON.stringify({ alg: 'none', typ: 'JWT' }));
|
||||||
const payload = b64u(JSON.stringify({
|
const payload = b64u(
|
||||||
sub: '00000000-0000-0000-0000-000000000000',
|
JSON.stringify({
|
||||||
role: 'admin',
|
sub: '00000000-0000-0000-0000-000000000000',
|
||||||
event_id: '00000000-0000-0000-0000-000000000000',
|
role: 'admin',
|
||||||
exp: Math.floor(Date.now() / 1000) + 3600,
|
event_id: '00000000-0000-0000-0000-000000000000',
|
||||||
}));
|
exp: Math.floor(Date.now() / 1000) + 3600,
|
||||||
|
})
|
||||||
|
);
|
||||||
const token = `${header}.${payload}.`;
|
const token = `${header}.${payload}.`;
|
||||||
const res = await fetch(`${BASE}/api/v1/admin/config`, {
|
const res = await fetch(`${BASE}/api/v1/admin/config`, {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
@@ -44,7 +49,9 @@ test.describe('Adversarial — JWT', () => {
|
|||||||
expect(res.status).toBe(401);
|
expect(res.status).toBe(401);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('JWT with payload-tampered role=admin (re-encoded payload, original signature) is rejected', async ({ guest }) => {
|
test('JWT with payload-tampered role=admin (re-encoded payload, original signature) is rejected', async ({
|
||||||
|
guest,
|
||||||
|
}) => {
|
||||||
const g = await guest('RolePromote');
|
const g = await guest('RolePromote');
|
||||||
const parts = g.jwt.split('.');
|
const parts = g.jwt.split('.');
|
||||||
const original = JSON.parse(Buffer.from(parts[1], 'base64url').toString());
|
const original = JSON.parse(Buffer.from(parts[1], 'base64url').toString());
|
||||||
@@ -111,7 +118,9 @@ test.describe('Adversarial — PIN brute-force', () => {
|
|||||||
expect(correct.status).toBe(429);
|
expect(correct.status).toBe(429);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('parallel wrong-PIN attempts still lock the account (counter is not lost to the race)', async ({ guest }) => {
|
test('parallel wrong-PIN attempts still lock the account (counter is not lost to the race)', async ({
|
||||||
|
guest,
|
||||||
|
}) => {
|
||||||
const g = await guest('BruteParallel');
|
const g = await guest('BruteParallel');
|
||||||
const wrong = g.pin === '0000' ? '1111' : '0000';
|
const wrong = g.pin === '0000' ? '1111' : '0000';
|
||||||
|
|
||||||
@@ -125,7 +134,10 @@ test.describe('Adversarial — PIN brute-force', () => {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
const statuses = attempts.map((r) => r.status);
|
const statuses = attempts.map((r) => r.status);
|
||||||
expect(statuses.filter((s) => s === 200), 'a wrong PIN must never authenticate').toHaveLength(0);
|
expect(
|
||||||
|
statuses.filter((s) => s === 200),
|
||||||
|
'a wrong PIN must never authenticate'
|
||||||
|
).toHaveLength(0);
|
||||||
|
|
||||||
// The in-flight requests all read `pin_locked_until` before any of them wrote it, so
|
// The in-flight requests all read `pin_locked_until` before any of them wrote it, so
|
||||||
// *which* of the 10 come back 429 is genuinely racy and can't be asserted. What is NOT
|
// *which* of the 10 come back 429 is genuinely racy and can't be asserted. What is NOT
|
||||||
@@ -140,7 +152,10 @@ test.describe('Adversarial — PIN brute-force', () => {
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ display_name: g.displayName, pin: g.pin }),
|
body: JSON.stringify({ display_name: g.displayName, pin: g.pin }),
|
||||||
});
|
});
|
||||||
expect(correct.status, 'after 10 wrong PINs the account must be locked, even for the right PIN').toBe(429);
|
expect(
|
||||||
|
correct.status,
|
||||||
|
'after 10 wrong PINs the account must be locked, even for the right PIN'
|
||||||
|
).toBe(429);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -188,7 +203,10 @@ test.describe('Adversarial — admin password brute-force', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The password path actually ran (budget existed) before the limiter engaged...
|
// The password path actually ran (budget existed) before the limiter engaged...
|
||||||
expect(statuses[0], 'first attempt should be a normal wrong-password 401, not a spurious 429').toBe(401);
|
expect(
|
||||||
|
statuses[0],
|
||||||
|
'first attempt should be a normal wrong-password 401, not a spurious 429'
|
||||||
|
).toBe(401);
|
||||||
// ...and the limiter DID engage within the window. Delete the throttle and this is never true.
|
// ...and the limiter DID engage within the window. Delete the throttle and this is never true.
|
||||||
expect(
|
expect(
|
||||||
statuses.some((s) => s === 429),
|
statuses.some((s) => s === 429),
|
||||||
@@ -218,7 +236,10 @@ test.describe('Adversarial — admin password brute-force', () => {
|
|||||||
|
|
||||||
// The right password, while throttled, must STILL be refused — the limiter is checked before
|
// The right password, while throttled, must STILL be refused — the limiter is checked before
|
||||||
// the bcrypt verify, so a valid credential does not buy a way around a brute-force lockout.
|
// the bcrypt verify, so a valid credential does not buy a way around a brute-force lockout.
|
||||||
expect((await tryLogin(ADMIN_PASSWORD)).status, 'a throttled IP is refused even with the correct password').toBe(429);
|
expect(
|
||||||
|
(await tryLogin(ADMIN_PASSWORD)).status,
|
||||||
|
'a throttled IP is refused even with the correct password'
|
||||||
|
).toBe(429);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('the throttle is gated: with the limiter disabled, a burst is NOT rate-limited', async ({
|
test('the throttle is gated: with the limiter disabled, a burst is NOT rate-limited', async ({
|
||||||
@@ -235,6 +256,9 @@ test.describe('Adversarial — admin password brute-force', () => {
|
|||||||
const statuses: number[] = [];
|
const statuses: number[] = [];
|
||||||
for (let i = 0; i < 8; i++) statuses.push((await tryLogin('wrong-' + i)).status);
|
for (let i = 0; i < 8; i++) statuses.push((await tryLogin('wrong-' + i)).status);
|
||||||
|
|
||||||
expect(statuses.every((s) => s === 401), 'with admin_login_rate_enabled=false no attempt should be 429').toBe(true);
|
expect(
|
||||||
|
statuses.every((s) => s === 401),
|
||||||
|
'with admin_login_rate_enabled=false no attempt should be 429'
|
||||||
|
).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,15 +6,16 @@
|
|||||||
*/
|
*/
|
||||||
import { test, expect } from '../../fixtures/test';
|
import { test, expect } from '../../fixtures/test';
|
||||||
import { seedUpload, seedComment, listComments, findFeedRow } from '../../helpers/seed';
|
import { seedUpload, seedComment, listComments, findFeedRow } from '../../helpers/seed';
|
||||||
|
import { BASE } from '../../helpers/env';
|
||||||
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
|
|
||||||
|
|
||||||
test.describe('Adversarial — deep authorization', () => {
|
test.describe('Adversarial — deep authorization', () => {
|
||||||
// IDOR: user B must not be able to delete user A's REAL comment. This exercises the
|
// IDOR: user B must not be able to delete user A's REAL comment. This exercises the
|
||||||
// ownership guard (`comment.user_id != auth.user_id` → 403) — the previous version fired
|
// ownership guard (`comment.user_id != auth.user_id` → 403) — the previous version fired
|
||||||
// at the all-zeros UUID, which 404s at the lookup BEFORE that guard runs, so it never
|
// at the all-zeros UUID, which 404s at the lookup BEFORE that guard runs, so it never
|
||||||
// tested authorization at all.
|
// tested authorization at all.
|
||||||
test('user B cannot delete user A\'s comment (real resource → 403, comment survives)', async ({ guest }) => {
|
test("user B cannot delete user A's comment (real resource → 403, comment survives)", async ({
|
||||||
|
guest,
|
||||||
|
}) => {
|
||||||
const a = await guest('CommentOwnerA');
|
const a = await guest('CommentOwnerA');
|
||||||
const b = await guest('AttackerB');
|
const b = await guest('AttackerB');
|
||||||
|
|
||||||
@@ -42,7 +43,7 @@ test.describe('Adversarial — deep authorization', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// IDOR: user B must not be able to delete user A's REAL upload.
|
// IDOR: user B must not be able to delete user A's REAL upload.
|
||||||
test('user B cannot delete user A\'s upload (403, upload survives)', async ({ guest, db }) => {
|
test("user B cannot delete user A's upload (403, upload survives)", async ({ guest, db }) => {
|
||||||
const a = await guest('UploadOwnerA');
|
const a = await guest('UploadOwnerA');
|
||||||
const b = await guest('AttackerB2');
|
const b = await guest('AttackerB2');
|
||||||
|
|
||||||
@@ -60,7 +61,7 @@ test.describe('Adversarial — deep authorization', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// IDOR: user B must not be able to edit (re-caption / re-tag) user A's upload.
|
// IDOR: user B must not be able to edit (re-caption / re-tag) user A's upload.
|
||||||
test('user B cannot edit user A\'s upload caption (403, caption unchanged)', async ({ guest }) => {
|
test("user B cannot edit user A's upload caption (403, caption unchanged)", async ({ guest }) => {
|
||||||
const a = await guest('UploadOwnerA2');
|
const a = await guest('UploadOwnerA2');
|
||||||
const b = await guest('AttackerB3');
|
const b = await guest('AttackerB3');
|
||||||
|
|
||||||
@@ -75,7 +76,9 @@ test.describe('Adversarial — deep authorization', () => {
|
|||||||
expect(res.status).toBe(403);
|
expect(res.status).toBe(403);
|
||||||
|
|
||||||
// No state change: the caption A set is intact.
|
// No state change: the caption A set is intact.
|
||||||
const feedRes = await fetch(`${BASE}/api/v1/feed`, { headers: { Authorization: `Bearer ${a.jwt}` } });
|
const feedRes = await fetch(`${BASE}/api/v1/feed`, {
|
||||||
|
headers: { Authorization: `Bearer ${a.jwt}` },
|
||||||
|
});
|
||||||
const row = findFeedRow(await feedRes.json(), uploadId);
|
const row = findFeedRow(await feedRes.json(), uploadId);
|
||||||
expect(row?.caption).toBe('original caption');
|
expect(row?.caption).toBe('original caption');
|
||||||
});
|
});
|
||||||
@@ -115,7 +118,11 @@ test.describe('Adversarial — deep authorization', () => {
|
|||||||
expect(await listComments(host.jwt, uploadId)).toHaveLength(0);
|
expect(await listComments(host.jwt, uploadId)).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('banned user can still read the feed (read-only access preserved)', async ({ api, host, guest }) => {
|
test('banned user can still read the feed (read-only access preserved)', async ({
|
||||||
|
api,
|
||||||
|
host,
|
||||||
|
guest,
|
||||||
|
}) => {
|
||||||
const target = await guest('BannedRead');
|
const target = await guest('BannedRead');
|
||||||
await api.banUser(host.jwt, target.userId);
|
await api.banUser(host.jwt, target.userId);
|
||||||
|
|
||||||
@@ -126,7 +133,11 @@ test.describe('Adversarial — deep authorization', () => {
|
|||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('host cannot delete another host\'s session via /api/v1/session', async ({ api, host, guest }) => {
|
test("host cannot delete another host's session via /api/v1/session", async ({
|
||||||
|
api,
|
||||||
|
host,
|
||||||
|
guest,
|
||||||
|
}) => {
|
||||||
const otherHost = await guest('OtherHost');
|
const otherHost = await guest('OtherHost');
|
||||||
// Promote so they have a host JWT to play with.
|
// Promote so they have a host JWT to play with.
|
||||||
await api.setRole(host.jwt, otherHost.userId, 'host');
|
await api.setRole(host.jwt, otherHost.userId, 'host');
|
||||||
|
|||||||
@@ -6,8 +6,7 @@
|
|||||||
import { test, expect } from '../../fixtures/test';
|
import { test, expect } from '../../fixtures/test';
|
||||||
import { mintSseTicket } from '../../helpers/sse';
|
import { mintSseTicket } from '../../helpers/sse';
|
||||||
import { seedUpload } from '../../helpers/seed';
|
import { seedUpload } from '../../helpers/seed';
|
||||||
|
import { BASE } from '../../helpers/env';
|
||||||
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
|
|
||||||
|
|
||||||
test.describe('Adversarial — small-scale abuse', () => {
|
test.describe('Adversarial — small-scale abuse', () => {
|
||||||
// Note: the truncate auto-fixture resets every rate-limit toggle back to false
|
// Note: the truncate auto-fixture resets every rate-limit toggle back to false
|
||||||
@@ -64,7 +63,9 @@ test.describe('Adversarial — small-scale abuse', () => {
|
|||||||
expect(res.status, '500 chars is the documented maximum and must be accepted').toBe(201);
|
expect(res.status, '500 chars is the documented maximum and must be accepted').toBe(201);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('10 MB comment body never reaches the handler (body-size limit rejects it)', async ({ guest }) => {
|
test('10 MB comment body never reaches the handler (body-size limit rejects it)', async ({
|
||||||
|
guest,
|
||||||
|
}) => {
|
||||||
const g = await guest('BigComment');
|
const g = await guest('BigComment');
|
||||||
const uploadId = await seedUpload(g.jwt);
|
const uploadId = await seedUpload(g.jwt);
|
||||||
const huge = 'A'.repeat(10 * 1024 * 1024);
|
const huge = 'A'.repeat(10 * 1024 * 1024);
|
||||||
@@ -87,7 +88,9 @@ test.describe('Adversarial — small-scale abuse', () => {
|
|||||||
|
|
||||||
const controllers = tickets.map(() => new AbortController());
|
const controllers = tickets.map(() => new AbortController());
|
||||||
const requests = tickets.map((ticket, i) =>
|
const requests = tickets.map((ticket, i) =>
|
||||||
fetch(`${BASE}/api/v1/stream?ticket=${encodeURIComponent(ticket)}`, { signal: controllers[i].signal })
|
fetch(`${BASE}/api/v1/stream?ticket=${encodeURIComponent(ticket)}`, {
|
||||||
|
signal: controllers[i].signal,
|
||||||
|
})
|
||||||
);
|
);
|
||||||
const responses = await Promise.all(requests);
|
const responses = await Promise.all(requests);
|
||||||
// All accepted (or some rate-limited — both fine).
|
// All accepted (or some rate-limited — both fine).
|
||||||
|
|||||||
Binary file not shown.
@@ -6,8 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
import { test, expect } from '../../fixtures/test';
|
import { test, expect } from '../../fixtures/test';
|
||||||
import { seedUpload } from '../../helpers/seed';
|
import { seedUpload } from '../../helpers/seed';
|
||||||
|
import { BASE } from '../../helpers/env';
|
||||||
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
|
|
||||||
|
|
||||||
test.describe('Media gating — moderation revokes preview access (F2)', () => {
|
test.describe('Media gating — moderation revokes preview access (F2)', () => {
|
||||||
test('preview served via gated alias, blocked directly, and 404 after delete', async ({
|
test('preview served via gated alias, blocked directly, and 404 after delete', async ({
|
||||||
|
|||||||
@@ -7,8 +7,7 @@
|
|||||||
* still work.
|
* still work.
|
||||||
*/
|
*/
|
||||||
import { test, expect } from '../../fixtures/test';
|
import { test, expect } from '../../fixtures/test';
|
||||||
|
import { BASE } from '../../helpers/env';
|
||||||
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
|
|
||||||
|
|
||||||
function patchRole(token: string, userId: string, role: string) {
|
function patchRole(token: string, userId: string, role: string) {
|
||||||
return fetch(`${BASE}/api/v1/host/users/${userId}/role`, {
|
return fetch(`${BASE}/api/v1/host/users/${userId}/role`, {
|
||||||
|
|||||||
@@ -8,8 +8,7 @@
|
|||||||
*/
|
*/
|
||||||
import { test, expect } from '../../fixtures/test';
|
import { test, expect } from '../../fixtures/test';
|
||||||
import { mintSseTicket, openStream } from '../../helpers/sse';
|
import { mintSseTicket, openStream } from '../../helpers/sse';
|
||||||
|
import { BASE } from '../../helpers/env';
|
||||||
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
|
|
||||||
|
|
||||||
test.describe('Adversarial — SSE ticket abuse', () => {
|
test.describe('Adversarial — SSE ticket abuse', () => {
|
||||||
test('minting a ticket requires authentication', async () => {
|
test('minting a ticket requires authentication', async () => {
|
||||||
|
|||||||
@@ -14,12 +14,15 @@ test.describe('Adversarial — UI render escape', () => {
|
|||||||
const r = await api.join(payload);
|
const r = await api.join(payload);
|
||||||
|
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await page.evaluate(({ j, u, n, p }) => {
|
await page.evaluate(
|
||||||
localStorage.setItem('eventsnap_jwt', j);
|
({ j, u, n, p }) => {
|
||||||
localStorage.setItem('eventsnap_user_id', u);
|
localStorage.setItem('eventsnap_jwt', j);
|
||||||
localStorage.setItem('eventsnap_display_name', n);
|
localStorage.setItem('eventsnap_user_id', u);
|
||||||
localStorage.setItem('eventsnap_pin', p);
|
localStorage.setItem('eventsnap_display_name', n);
|
||||||
}, { j: r.jwt, u: r.user_id, n: payload, p: r.pin });
|
localStorage.setItem('eventsnap_pin', p);
|
||||||
|
},
|
||||||
|
{ j: r.jwt, u: r.user_id, n: payload, p: r.pin }
|
||||||
|
);
|
||||||
|
|
||||||
page.on('dialog', (d) => {
|
page.on('dialog', (d) => {
|
||||||
throw new Error(`Dialog fired: ${d.message()}`);
|
throw new Error(`Dialog fired: ${d.message()}`);
|
||||||
@@ -31,7 +34,9 @@ test.describe('Adversarial — UI render escape', () => {
|
|||||||
// the *absence* of a <b> at that point passes on a page that never rendered the name
|
// the *absence* of a <b> at that point passes on a page that never rendered the name
|
||||||
// at all — a false green on an XSS test. Prove the payload actually reached the DOM
|
// at all — a false green on an XSS test. Prove the payload actually reached the DOM
|
||||||
// (as escaped text) before concluding anything about how it was rendered.
|
// (as escaped text) before concluding anything about how it was rendered.
|
||||||
await expect(page.getByText(payload, { exact: false }).first()).toBeVisible({ timeout: 10_000 });
|
await expect(page.getByText(payload, { exact: false }).first()).toBeVisible({
|
||||||
|
timeout: 10_000,
|
||||||
|
});
|
||||||
|
|
||||||
const fired = await page.evaluate(() => (window as any).__xssFired === true);
|
const fired = await page.evaluate(() => (window as any).__xssFired === true);
|
||||||
expect(fired).toBe(false);
|
expect(fired).toBe(false);
|
||||||
@@ -42,17 +47,23 @@ test.describe('Adversarial — UI render escape', () => {
|
|||||||
await expect(page.locator('script:has-text("__xssFired")')).toHaveCount(0);
|
await expect(page.locator('script:has-text("__xssFired")')).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('rendering of a known SQL-injection-shaped name does not break the page', async ({ page, api }) => {
|
test('rendering of a known SQL-injection-shaped name does not break the page', async ({
|
||||||
|
page,
|
||||||
|
api,
|
||||||
|
}) => {
|
||||||
const payload = `'); DROP TABLE users; --`;
|
const payload = `'); DROP TABLE users; --`;
|
||||||
const r = await api.join(payload);
|
const r = await api.join(payload);
|
||||||
|
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await page.evaluate(({ j, u, n, p }) => {
|
await page.evaluate(
|
||||||
localStorage.setItem('eventsnap_jwt', j);
|
({ j, u, n, p }) => {
|
||||||
localStorage.setItem('eventsnap_user_id', u);
|
localStorage.setItem('eventsnap_jwt', j);
|
||||||
localStorage.setItem('eventsnap_display_name', n);
|
localStorage.setItem('eventsnap_user_id', u);
|
||||||
localStorage.setItem('eventsnap_pin', p);
|
localStorage.setItem('eventsnap_display_name', n);
|
||||||
}, { j: r.jwt, u: r.user_id, n: payload, p: r.pin });
|
localStorage.setItem('eventsnap_pin', p);
|
||||||
|
},
|
||||||
|
{ j: r.jwt, u: r.user_id, n: payload, p: r.pin }
|
||||||
|
);
|
||||||
|
|
||||||
await page.goto('/account');
|
await page.goto('/account');
|
||||||
// Page renders.
|
// Page renders.
|
||||||
|
|||||||
@@ -13,8 +13,7 @@
|
|||||||
*/
|
*/
|
||||||
import { test, expect } from '../../fixtures/test';
|
import { test, expect } from '../../fixtures/test';
|
||||||
import { seedUpload, seedComment } from '../../helpers/seed';
|
import { seedUpload, seedComment } from '../../helpers/seed';
|
||||||
|
import { BASE } from '../../helpers/env';
|
||||||
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Every payload sets `window.__x = 1` if it executes. The marker is deliberately terse:
|
* Every payload sets `window.__x = 1` if it executes. The marker is deliberately terse:
|
||||||
@@ -36,7 +35,10 @@ const XSS_PAYLOADS = [
|
|||||||
// payload is edited past it, we want a loud failure here rather than six silent no-ops.
|
// payload is edited past it, we want a loud failure here rather than six silent no-ops.
|
||||||
const NAME_MAX = 50;
|
const NAME_MAX = 50;
|
||||||
for (const p of XSS_PAYLOADS) {
|
for (const p of XSS_PAYLOADS) {
|
||||||
if (p.length > NAME_MAX) throw new Error(`XSS payload exceeds the ${NAME_MAX}-char display-name cap and would never be stored: ${p}`);
|
if (p.length > NAME_MAX)
|
||||||
|
throw new Error(
|
||||||
|
`XSS payload exceeds the ${NAME_MAX}-char display-name cap and would never be stored: ${p}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const SQLI_PAYLOADS = [
|
const SQLI_PAYLOADS = [
|
||||||
@@ -48,7 +50,10 @@ const SQLI_PAYLOADS = [
|
|||||||
|
|
||||||
test.describe('Adversarial — input injection (display name)', () => {
|
test.describe('Adversarial — input injection (display name)', () => {
|
||||||
for (const payload of XSS_PAYLOADS) {
|
for (const payload of XSS_PAYLOADS) {
|
||||||
test(`name with XSS payload ${JSON.stringify(payload).slice(0, 40)} never executes`, async ({ api, page }) => {
|
test(`name with XSS payload ${JSON.stringify(payload).slice(0, 40)} never executes`, async ({
|
||||||
|
api,
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
// No try/catch escape hatch: every payload is short enough to be accepted, so a
|
// No try/catch escape hatch: every payload is short enough to be accepted, so a
|
||||||
// rejection here is a real failure (the payload would never be rendered, and the
|
// rejection here is a real failure (the payload would never be rendered, and the
|
||||||
// "nothing executed" assertions below would be vacuous).
|
// "nothing executed" assertions below would be vacuous).
|
||||||
@@ -77,7 +82,9 @@ test.describe('Adversarial — input injection (display name)', () => {
|
|||||||
|
|
||||||
// Render guard: confirm the payload actually reached the DOM as escaped text,
|
// Render guard: confirm the payload actually reached the DOM as escaped text,
|
||||||
// so a "nothing fired" pass can't be because the name was never rendered.
|
// so a "nothing fired" pass can't be because the name was never rendered.
|
||||||
await expect(page.getByText(payload, { exact: false }).first()).toBeVisible({ timeout: 10_000 });
|
await expect(page.getByText(payload, { exact: false }).first()).toBeVisible({
|
||||||
|
timeout: 10_000,
|
||||||
|
});
|
||||||
|
|
||||||
const fired = await page.evaluate(() => (window as any).__x === 1);
|
const fired = await page.evaluate(() => (window as any).__x === 1);
|
||||||
expect(fired, 'window.__x should never be set').toBe(false);
|
expect(fired, 'window.__x should never be set').toBe(false);
|
||||||
@@ -92,7 +99,11 @@ test.describe('Adversarial — input injection (display name)', () => {
|
|||||||
|
|
||||||
test.describe('Adversarial — stored XSS (caption)', () => {
|
test.describe('Adversarial — stored XSS (caption)', () => {
|
||||||
for (const payload of XSS_PAYLOADS) {
|
for (const payload of XSS_PAYLOADS) {
|
||||||
test(`caption with XSS payload ${JSON.stringify(payload).slice(0, 40)} renders inert`, async ({ guest, page, signIn }) => {
|
test(`caption with XSS payload ${JSON.stringify(payload).slice(0, 40)} renders inert`, async ({
|
||||||
|
guest,
|
||||||
|
page,
|
||||||
|
signIn,
|
||||||
|
}) => {
|
||||||
const g = await guest('CapXss');
|
const g = await guest('CapXss');
|
||||||
// A trailing marker lets us wait until the caption has actually rendered before
|
// A trailing marker lets us wait until the caption has actually rendered before
|
||||||
// asserting nothing fired — otherwise a caption that never rendered would pass vacuously.
|
// asserting nothing fired — otherwise a caption that never rendered would pass vacuously.
|
||||||
@@ -100,17 +111,30 @@ test.describe('Adversarial — stored XSS (caption)', () => {
|
|||||||
expect(id).toMatch(/^[0-9a-f-]{36}$/);
|
expect(id).toMatch(/^[0-9a-f-]{36}$/);
|
||||||
|
|
||||||
const dialogs: string[] = [];
|
const dialogs: string[] = [];
|
||||||
page.on('dialog', (d) => { dialogs.push(d.message()); d.dismiss().catch(() => {}); });
|
page.on('dialog', (d) => {
|
||||||
|
dialogs.push(d.message());
|
||||||
|
d.dismiss().catch(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
await signIn(page, g);
|
await signIn(page, g);
|
||||||
// Wait for the caption text to land in the DOM (escaped, as literal text).
|
// Wait for the caption text to land in the DOM (escaped, as literal text).
|
||||||
await expect(page.getByText('CAPMARK', { exact: false }).first()).toBeVisible({ timeout: 10_000 });
|
await expect(page.getByText('CAPMARK', { exact: false }).first()).toBeVisible({
|
||||||
|
timeout: 10_000,
|
||||||
|
});
|
||||||
|
|
||||||
expect(await page.evaluate(() => (window as any).__x === 1), 'caption XSS must not fire').toBe(false);
|
expect(
|
||||||
|
await page.evaluate(() => (window as any).__x === 1),
|
||||||
|
'caption XSS must not fire'
|
||||||
|
).toBe(false);
|
||||||
expect(dialogs, 'no dialogs from a caption').toHaveLength(0);
|
expect(dialogs, 'no dialogs from a caption').toHaveLength(0);
|
||||||
// The payload must be inert text, not a live element / script.
|
// The payload must be inert text, not a live element / script.
|
||||||
expect(await page.locator('img[onerror]').count(), 'no live onerror img from caption').toBe(0);
|
expect(await page.locator('img[onerror]').count(), 'no live onerror img from caption').toBe(
|
||||||
expect(await page.locator('script:has-text("window.__x")').count(), 'no executable script from caption').toBe(0);
|
0
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
await page.locator('script:has-text("window.__x")').count(),
|
||||||
|
'no executable script from caption'
|
||||||
|
).toBe(0);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -118,23 +142,30 @@ test.describe('Adversarial — stored XSS (caption)', () => {
|
|||||||
test.describe('Adversarial — stored XSS (comment)', () => {
|
test.describe('Adversarial — stored XSS (comment)', () => {
|
||||||
// The two payloads that actually execute on render (script injection via innerHTML
|
// The two payloads that actually execute on render (script injection via innerHTML
|
||||||
// does not) — enough to prove the comment body is escaped without a slow 6× lightbox loop.
|
// does not) — enough to prove the comment body is escaped without a slow 6× lightbox loop.
|
||||||
const COMMENT_PAYLOADS = [
|
const COMMENT_PAYLOADS = [`<img src=x onerror="window.__x=1">`, `"><svg onload="window.__x=1">`];
|
||||||
`<img src=x onerror="window.__x=1">`,
|
|
||||||
`"><svg onload="window.__x=1">`,
|
|
||||||
];
|
|
||||||
for (const payload of COMMENT_PAYLOADS) {
|
for (const payload of COMMENT_PAYLOADS) {
|
||||||
test(`comment with XSS payload ${JSON.stringify(payload).slice(0, 40)} renders inert`, async ({ guest, page, signIn }) => {
|
test(`comment with XSS payload ${JSON.stringify(payload).slice(0, 40)} renders inert`, async ({
|
||||||
|
guest,
|
||||||
|
page,
|
||||||
|
signIn,
|
||||||
|
}) => {
|
||||||
const author = await guest('CmtXss');
|
const author = await guest('CmtXss');
|
||||||
const id = await seedUpload(author.jwt, { caption: 'pic CAPMARK' });
|
const id = await seedUpload(author.jwt, { caption: 'pic CAPMARK' });
|
||||||
// Post the XSS comment via the API (verbatim storage).
|
// Post the XSS comment via the API (verbatim storage).
|
||||||
await seedComment(author.jwt, id, `${payload} CMTMARK`);
|
await seedComment(author.jwt, id, `${payload} CMTMARK`);
|
||||||
|
|
||||||
const dialogs: string[] = [];
|
const dialogs: string[] = [];
|
||||||
page.on('dialog', (d) => { dialogs.push(d.message()); d.dismiss().catch(() => {}); });
|
page.on('dialog', (d) => {
|
||||||
|
dialogs.push(d.message());
|
||||||
|
d.dismiss().catch(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
await signIn(page, author);
|
await signIn(page, author);
|
||||||
// Open the lightbox (which loads + renders comments).
|
// Open the lightbox (which loads + renders comments).
|
||||||
const imageButton = page.locator('article').filter({ hasText: 'CAPMARK' }).first()
|
const imageButton = page
|
||||||
|
.locator('article')
|
||||||
|
.filter({ hasText: 'CAPMARK' })
|
||||||
|
.first()
|
||||||
.getByRole('button', { name: 'Bild vergrößern' });
|
.getByRole('button', { name: 'Bild vergrößern' });
|
||||||
await expect(imageButton).toBeVisible({ timeout: 10_000 });
|
await expect(imageButton).toBeVisible({ timeout: 10_000 });
|
||||||
await imageButton.click();
|
await imageButton.click();
|
||||||
@@ -142,18 +173,28 @@ test.describe('Adversarial — stored XSS (comment)', () => {
|
|||||||
const lightbox = page.locator('[role="dialog"][aria-labelledby="lightbox-title"]');
|
const lightbox = page.locator('[role="dialog"][aria-labelledby="lightbox-title"]');
|
||||||
await expect(lightbox).toBeVisible();
|
await expect(lightbox).toBeVisible();
|
||||||
// Wait until the comment (marker) has rendered.
|
// Wait until the comment (marker) has rendered.
|
||||||
await expect(lightbox.getByText('CMTMARK', { exact: false })).toBeVisible({ timeout: 10_000 });
|
await expect(lightbox.getByText('CMTMARK', { exact: false })).toBeVisible({
|
||||||
|
timeout: 10_000,
|
||||||
|
});
|
||||||
|
|
||||||
expect(await page.evaluate(() => (window as any).__x === 1), 'comment XSS must not fire').toBe(false);
|
expect(
|
||||||
|
await page.evaluate(() => (window as any).__x === 1),
|
||||||
|
'comment XSS must not fire'
|
||||||
|
).toBe(false);
|
||||||
expect(dialogs, 'no dialogs from a comment').toHaveLength(0);
|
expect(dialogs, 'no dialogs from a comment').toHaveLength(0);
|
||||||
expect(await page.locator('img[onerror]').count(), 'no live onerror img from comment').toBe(0);
|
expect(await page.locator('img[onerror]').count(), 'no live onerror img from comment').toBe(
|
||||||
|
0
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe('Adversarial — input injection (SQL-injection patterns)', () => {
|
test.describe('Adversarial — input injection (SQL-injection patterns)', () => {
|
||||||
for (const payload of SQLI_PAYLOADS) {
|
for (const payload of SQLI_PAYLOADS) {
|
||||||
test(`SQL-shaped name ${JSON.stringify(payload).slice(0, 40)} round-trips without breaking the DB`, async ({ api, adminToken }) => {
|
test(`SQL-shaped name ${JSON.stringify(payload).slice(0, 40)} round-trips without breaking the DB`, async ({
|
||||||
|
api,
|
||||||
|
adminToken,
|
||||||
|
}) => {
|
||||||
const res = await api.join(payload);
|
const res = await api.join(payload);
|
||||||
expect(res.jwt).toBeTruthy();
|
expect(res.jwt).toBeTruthy();
|
||||||
|
|
||||||
@@ -196,7 +237,10 @@ test.describe('Adversarial — input length & encoding', () => {
|
|||||||
expect([400, 201, 409]).toContain(res.status);
|
expect([400, 201, 409]).toContain(res.status);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Unicode RTL override character in name does not corrupt rendering', async ({ api, page }) => {
|
test('Unicode RTL override character in name does not corrupt rendering', async ({
|
||||||
|
api,
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
const rtlName = `AliceeciVlA`; // U+202E RIGHT-TO-LEFT OVERRIDE
|
const rtlName = `AliceeciVlA`; // U+202E RIGHT-TO-LEFT OVERRIDE
|
||||||
const r = await api.join(rtlName);
|
const r = await api.join(rtlName);
|
||||||
expect(r.user_id).toMatch(/^[0-9a-f-]{36}$/);
|
expect(r.user_id).toMatch(/^[0-9a-f-]{36}$/);
|
||||||
|
|||||||
@@ -16,7 +16,11 @@ test.describe('Browser chaos — environment', () => {
|
|||||||
await ctx.close();
|
await ctx.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('localStorage quota exhausted — writing JWT does not crash the app', async ({ page, guest, signIn }) => {
|
test('localStorage quota exhausted — writing JWT does not crash the app', async ({
|
||||||
|
page,
|
||||||
|
guest,
|
||||||
|
signIn,
|
||||||
|
}) => {
|
||||||
const g = await guest('QuotaFull');
|
const g = await guest('QuotaFull');
|
||||||
|
|
||||||
// Pre-fill localStorage with junk to push us near the quota.
|
// Pre-fill localStorage with junk to push us near the quota.
|
||||||
@@ -46,7 +50,11 @@ test.describe('Browser chaos — environment', () => {
|
|||||||
expect(errors.filter((e) => !/storage|quota/i.test(e.message))).toHaveLength(0);
|
expect(errors.filter((e) => !/storage|quota/i.test(e.message))).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('hostile extension simulation: CSS hiding bottom nav does not break navigation', async ({ page, guest, signIn }) => {
|
test('hostile extension simulation: CSS hiding bottom nav does not break navigation', async ({
|
||||||
|
page,
|
||||||
|
guest,
|
||||||
|
signIn,
|
||||||
|
}) => {
|
||||||
const g = await guest('HostileCss');
|
const g = await guest('HostileCss');
|
||||||
await signIn(page, g);
|
await signIn(page, g);
|
||||||
await page.goto('/feed');
|
await page.goto('/feed');
|
||||||
@@ -59,7 +67,11 @@ test.describe('Browser chaos — environment', () => {
|
|||||||
await expect(page).toHaveURL(/\/account$/);
|
await expect(page).toHaveURL(/\/account$/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('clock skew: browser ahead by 1 hour — JWT still valid (no nbf claim)', async ({ page, guest, signIn }) => {
|
test('clock skew: browser ahead by 1 hour — JWT still valid (no nbf claim)', async ({
|
||||||
|
page,
|
||||||
|
guest,
|
||||||
|
signIn,
|
||||||
|
}) => {
|
||||||
const g = await guest('ClockSkew');
|
const g = await guest('ClockSkew');
|
||||||
|
|
||||||
// Override Date.now() to be 1h in the future BEFORE the JWT check.
|
// Override Date.now() to be 1h in the future BEFORE the JWT check.
|
||||||
@@ -74,7 +86,11 @@ test.describe('Browser chaos — environment', () => {
|
|||||||
await expect(page.getByRole('link', { name: 'Galerie' })).toBeVisible();
|
await expect(page.getByRole('link', { name: 'Galerie' })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('clock skew: browser behind by 2 days — JWT exp may be in the past, app handles 401', async ({ page, guest, signIn }) => {
|
test('clock skew: browser behind by 2 days — JWT exp may be in the past, app handles 401', async ({
|
||||||
|
page,
|
||||||
|
guest,
|
||||||
|
signIn,
|
||||||
|
}) => {
|
||||||
const g = await guest('ClockBack');
|
const g = await guest('ClockBack');
|
||||||
|
|
||||||
await page.addInitScript(() => {
|
await page.addInitScript(() => {
|
||||||
|
|||||||
@@ -7,7 +7,11 @@
|
|||||||
import { test, expect } from '../../fixtures/test';
|
import { test, expect } from '../../fixtures/test';
|
||||||
|
|
||||||
test.describe('Browser chaos — IndexedDB', () => {
|
test.describe('Browser chaos — IndexedDB', () => {
|
||||||
test('IndexedDB cleared mid-session does not break navigation', async ({ page, guest, signIn }) => {
|
test('IndexedDB cleared mid-session does not break navigation', async ({
|
||||||
|
page,
|
||||||
|
guest,
|
||||||
|
signIn,
|
||||||
|
}) => {
|
||||||
const g = await guest('IdbPurge');
|
const g = await guest('IdbPurge');
|
||||||
await signIn(page, g);
|
await signIn(page, g);
|
||||||
await page.goto('/feed');
|
await page.goto('/feed');
|
||||||
@@ -16,11 +20,12 @@ test.describe('Browser chaos — IndexedDB', () => {
|
|||||||
// Drop every IndexedDB database the app might use.
|
// Drop every IndexedDB database the app might use.
|
||||||
const dbs = (await (indexedDB as any).databases?.()) ?? [];
|
const dbs = (await (indexedDB as any).databases?.()) ?? [];
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
dbs.map(({ name }: { name: string }) =>
|
dbs.map(
|
||||||
new Promise<void>((resolve) => {
|
({ name }: { name: string }) =>
|
||||||
const req = indexedDB.deleteDatabase(name);
|
new Promise<void>((resolve) => {
|
||||||
req.onsuccess = req.onerror = req.onblocked = () => resolve();
|
const req = indexedDB.deleteDatabase(name);
|
||||||
})
|
req.onsuccess = req.onerror = req.onblocked = () => resolve();
|
||||||
|
})
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,7 +6,12 @@ import { test, expect } from '../../fixtures/test';
|
|||||||
import { trackStreamOpens } from '../../helpers/sse';
|
import { trackStreamOpens } from '../../helpers/sse';
|
||||||
|
|
||||||
test.describe('Browser chaos — multi-tab', () => {
|
test.describe('Browser chaos — multi-tab', () => {
|
||||||
test('same user in two tabs — each tab establishes its own SSE stream', async ({ page, context, guest, signIn }) => {
|
test('same user in two tabs — each tab establishes its own SSE stream', async ({
|
||||||
|
page,
|
||||||
|
context,
|
||||||
|
guest,
|
||||||
|
signIn,
|
||||||
|
}) => {
|
||||||
const g = await guest('Twin');
|
const g = await guest('Twin');
|
||||||
|
|
||||||
// Count each tab's own EventSource open. Asserting *connection establishment* is
|
// Count each tab's own EventSource open. Asserting *connection establishment* is
|
||||||
@@ -27,7 +32,10 @@ test.describe('Browser chaos — multi-tab', () => {
|
|||||||
await tab2.close();
|
await tab2.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('two different users in separate browser contexts have isolated localStorage', async ({ browser, guest }) => {
|
test('two different users in separate browser contexts have isolated localStorage', async ({
|
||||||
|
browser,
|
||||||
|
guest,
|
||||||
|
}) => {
|
||||||
const a = await guest('IsoA');
|
const a = await guest('IsoA');
|
||||||
const b = await guest('IsoB');
|
const b = await guest('IsoB');
|
||||||
|
|
||||||
@@ -37,20 +45,26 @@ test.describe('Browser chaos — multi-tab', () => {
|
|||||||
const pageB = await ctxB.newPage();
|
const pageB = await ctxB.newPage();
|
||||||
|
|
||||||
await pageA.goto('http://localhost:3101/');
|
await pageA.goto('http://localhost:3101/');
|
||||||
await pageA.evaluate(({ jwt, pin, userId, name }) => {
|
await pageA.evaluate(
|
||||||
localStorage.setItem('eventsnap_jwt', jwt);
|
({ jwt, pin, userId, name }) => {
|
||||||
localStorage.setItem('eventsnap_pin', pin);
|
localStorage.setItem('eventsnap_jwt', jwt);
|
||||||
localStorage.setItem('eventsnap_user_id', userId);
|
localStorage.setItem('eventsnap_pin', pin);
|
||||||
localStorage.setItem('eventsnap_display_name', name);
|
localStorage.setItem('eventsnap_user_id', userId);
|
||||||
}, { jwt: a.jwt, pin: a.pin, userId: a.userId, name: a.displayName });
|
localStorage.setItem('eventsnap_display_name', name);
|
||||||
|
},
|
||||||
|
{ jwt: a.jwt, pin: a.pin, userId: a.userId, name: a.displayName }
|
||||||
|
);
|
||||||
|
|
||||||
await pageB.goto('http://localhost:3101/');
|
await pageB.goto('http://localhost:3101/');
|
||||||
await pageB.evaluate(({ jwt, pin, userId, name }) => {
|
await pageB.evaluate(
|
||||||
localStorage.setItem('eventsnap_jwt', jwt);
|
({ jwt, pin, userId, name }) => {
|
||||||
localStorage.setItem('eventsnap_pin', pin);
|
localStorage.setItem('eventsnap_jwt', jwt);
|
||||||
localStorage.setItem('eventsnap_user_id', userId);
|
localStorage.setItem('eventsnap_pin', pin);
|
||||||
localStorage.setItem('eventsnap_display_name', name);
|
localStorage.setItem('eventsnap_user_id', userId);
|
||||||
}, { jwt: b.jwt, pin: b.pin, userId: b.userId, name: b.displayName });
|
localStorage.setItem('eventsnap_display_name', name);
|
||||||
|
},
|
||||||
|
{ jwt: b.jwt, pin: b.pin, userId: b.userId, name: b.displayName }
|
||||||
|
);
|
||||||
|
|
||||||
// Each context sees only its own user.
|
// Each context sees only its own user.
|
||||||
const aUid = await pageA.evaluate(() => localStorage.getItem('eventsnap_user_id'));
|
const aUid = await pageA.evaluate(() => localStorage.getItem('eventsnap_user_id'));
|
||||||
@@ -63,7 +77,11 @@ test.describe('Browser chaos — multi-tab', () => {
|
|||||||
await ctxB.close();
|
await ctxB.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('localStorage is shared across tabs of the same context (real browser behavior)', async ({ context, guest, signIn }) => {
|
test('localStorage is shared across tabs of the same context (real browser behavior)', async ({
|
||||||
|
context,
|
||||||
|
guest,
|
||||||
|
signIn,
|
||||||
|
}) => {
|
||||||
// Real browsers share localStorage across tabs of the same origin. Tab A's
|
// Real browsers share localStorage across tabs of the same origin. Tab A's
|
||||||
// removeItem is instantly visible in tab B's localStorage. The UX gap to
|
// removeItem is instantly visible in tab B's localStorage. The UX gap to
|
||||||
// document is that tab B's React/Svelte state isn't *re-rendered* until the
|
// document is that tab B's React/Svelte state isn't *re-rendered* until the
|
||||||
|
|||||||
@@ -6,7 +6,11 @@
|
|||||||
import { test, expect } from '../../fixtures/test';
|
import { test, expect } from '../../fixtures/test';
|
||||||
|
|
||||||
test.describe('Browser chaos — network', () => {
|
test.describe('Browser chaos — network', () => {
|
||||||
test('offline → reconnect — page does not crash and bottom nav is responsive', async ({ page, guest, signIn }) => {
|
test('offline → reconnect — page does not crash and bottom nav is responsive', async ({
|
||||||
|
page,
|
||||||
|
guest,
|
||||||
|
signIn,
|
||||||
|
}) => {
|
||||||
const g = await guest('Offline1');
|
const g = await guest('Offline1');
|
||||||
await signIn(page, g);
|
await signIn(page, g);
|
||||||
await page.goto('/feed');
|
await page.goto('/feed');
|
||||||
@@ -16,7 +20,10 @@ test.describe('Browser chaos — network', () => {
|
|||||||
|
|
||||||
await page.context().setOffline(true);
|
await page.context().setOffline(true);
|
||||||
// Tap the bottom nav — should not raise unhandled errors.
|
// Tap the bottom nav — should not raise unhandled errors.
|
||||||
await page.getByRole('link', { name: 'Konto' }).click().catch(() => {});
|
await page
|
||||||
|
.getByRole('link', { name: 'Konto' })
|
||||||
|
.click()
|
||||||
|
.catch(() => {});
|
||||||
await page.waitForTimeout(500);
|
await page.waitForTimeout(500);
|
||||||
await page.context().setOffline(false);
|
await page.context().setOffline(false);
|
||||||
|
|
||||||
@@ -27,7 +34,11 @@ test.describe('Browser chaos — network', () => {
|
|||||||
expect(errors.filter((e) => !e.message.toLowerCase().includes('fetch'))).toHaveLength(0);
|
expect(errors.filter((e) => !e.message.toLowerCase().includes('fetch'))).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('slow 3G simulation — initial nav completes within reasonable bound', async ({ page, guest, signIn }) => {
|
test('slow 3G simulation — initial nav completes within reasonable bound', async ({
|
||||||
|
page,
|
||||||
|
guest,
|
||||||
|
signIn,
|
||||||
|
}) => {
|
||||||
const g = await guest('Slow3g');
|
const g = await guest('Slow3g');
|
||||||
|
|
||||||
// 50ms latency on every request, applied to /api/* only so navigation isn't catastrophic.
|
// 50ms latency on every request, applied to /api/* only so navigation isn't catastrophic.
|
||||||
@@ -41,7 +52,11 @@ test.describe('Browser chaos — network', () => {
|
|||||||
await expect(page.getByRole('link', { name: 'Galerie' })).toBeVisible({ timeout: 15_000 });
|
await expect(page.getByRole('link', { name: 'Galerie' })).toBeVisible({ timeout: 15_000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('intermittent API failures during navigation — UI surfaces an error state', async ({ page, guest, signIn }) => {
|
test('intermittent API failures during navigation — UI surfaces an error state', async ({
|
||||||
|
page,
|
||||||
|
guest,
|
||||||
|
signIn,
|
||||||
|
}) => {
|
||||||
const g = await guest('FlakyApi');
|
const g = await guest('FlakyApi');
|
||||||
|
|
||||||
let count = 0;
|
let count = 0;
|
||||||
|
|||||||
@@ -9,7 +9,11 @@ import { test, expect } from '../../fixtures/test';
|
|||||||
import { readStorage, clearLocalStorage, clearAllStorage } from '../../helpers/storage-helpers';
|
import { readStorage, clearLocalStorage, clearAllStorage } from '../../helpers/storage-helpers';
|
||||||
|
|
||||||
test.describe('Browser chaos — storage purge', () => {
|
test.describe('Browser chaos — storage purge', () => {
|
||||||
test('localStorage.clear() mid-session → next nav goes to /join, no crash', async ({ page, guest, signIn }) => {
|
test('localStorage.clear() mid-session → next nav goes to /join, no crash', async ({
|
||||||
|
page,
|
||||||
|
guest,
|
||||||
|
signIn,
|
||||||
|
}) => {
|
||||||
const g = await guest('Purge1');
|
const g = await guest('Purge1');
|
||||||
await signIn(page, g);
|
await signIn(page, g);
|
||||||
await page.goto('/feed');
|
await page.goto('/feed');
|
||||||
@@ -31,7 +35,11 @@ test.describe('Browser chaos — storage purge', () => {
|
|||||||
expect(['/join', '/feed', '/recover', '/']).toContain(url.pathname);
|
expect(['/join', '/feed', '/recover', '/']).toContain(url.pathname);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('cookies cleared mid-session — JWT in localStorage still works (no cookie dependency)', async ({ page, guest, signIn }) => {
|
test('cookies cleared mid-session — JWT in localStorage still works (no cookie dependency)', async ({
|
||||||
|
page,
|
||||||
|
guest,
|
||||||
|
signIn,
|
||||||
|
}) => {
|
||||||
const g = await guest('Purge2');
|
const g = await guest('Purge2');
|
||||||
await signIn(page, g);
|
await signIn(page, g);
|
||||||
await page.goto('/feed');
|
await page.goto('/feed');
|
||||||
@@ -48,7 +56,11 @@ test.describe('Browser chaos — storage purge', () => {
|
|||||||
expect(stillAuthed).toBe(200);
|
expect(stillAuthed).toBe(200);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('sessionStorage cleared has no effect on auth (auth lives in localStorage)', async ({ page, guest, signIn }) => {
|
test('sessionStorage cleared has no effect on auth (auth lives in localStorage)', async ({
|
||||||
|
page,
|
||||||
|
guest,
|
||||||
|
signIn,
|
||||||
|
}) => {
|
||||||
const g = await guest('Purge3');
|
const g = await guest('Purge3');
|
||||||
await signIn(page, g);
|
await signIn(page, g);
|
||||||
await page.goto('/feed');
|
await page.goto('/feed');
|
||||||
@@ -74,7 +86,11 @@ test.describe('Browser chaos — storage purge', () => {
|
|||||||
await page.waitForURL(/admin\/login|join/, { timeout: 5_000 });
|
await page.waitForURL(/admin\/login|join/, { timeout: 5_000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('PIN survives clearAuth (intentional per auth.ts comment)', async ({ page, guest, signIn }) => {
|
test('PIN survives clearAuth (intentional per auth.ts comment)', async ({
|
||||||
|
page,
|
||||||
|
guest,
|
||||||
|
signIn,
|
||||||
|
}) => {
|
||||||
const g = await guest('PurgePin');
|
const g = await guest('PurgePin');
|
||||||
await signIn(page, g);
|
await signIn(page, g);
|
||||||
await page.goto('/account');
|
await page.goto('/account');
|
||||||
|
|||||||
@@ -34,18 +34,25 @@ test.describe('Mobile a11y — focus trap on LightboxModal', () => {
|
|||||||
await trigger.click();
|
await trigger.click();
|
||||||
|
|
||||||
// Lightbox is role="dialog" aria-modal="true".
|
// Lightbox is role="dialog" aria-modal="true".
|
||||||
const lightbox = page.locator('[role="dialog"][aria-modal="true"]').filter({ has: page.locator('img, video') });
|
const lightbox = page
|
||||||
|
.locator('[role="dialog"][aria-modal="true"]')
|
||||||
|
.filter({ has: page.locator('img, video') });
|
||||||
await expect(lightbox).toBeVisible();
|
await expect(lightbox).toBeVisible();
|
||||||
|
|
||||||
// After opening, focus should be inside the lightbox (trap autoFocus moves
|
// After opening, focus should be inside the lightbox (trap autoFocus moves
|
||||||
// focus to the first focusable). Verify by checking activeElement is
|
// focus to the first focusable). Verify by checking activeElement is
|
||||||
// contained.
|
// contained.
|
||||||
await expect.poll(async () => {
|
await expect
|
||||||
return await page.evaluate(() => {
|
.poll(
|
||||||
const dlg = document.querySelector('[role="dialog"][aria-modal="true"]');
|
async () => {
|
||||||
return !!dlg && dlg.contains(document.activeElement);
|
return await page.evaluate(() => {
|
||||||
});
|
const dlg = document.querySelector('[role="dialog"][aria-modal="true"]');
|
||||||
}, { timeout: 2_000 }).toBe(true);
|
return !!dlg && dlg.contains(document.activeElement);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{ timeout: 2_000 }
|
||||||
|
)
|
||||||
|
.toBe(true);
|
||||||
|
|
||||||
// Escape dismisses the lightbox.
|
// Escape dismisses the lightbox.
|
||||||
await page.keyboard.press('Escape');
|
await page.keyboard.press('Escape');
|
||||||
|
|||||||
@@ -26,12 +26,18 @@ async function seedUpload(token: string, caption = 'Doubletap fixture'): Promise
|
|||||||
contentType: 'image/jpeg',
|
contentType: 'image/jpeg',
|
||||||
caption,
|
caption,
|
||||||
});
|
});
|
||||||
if (res.status !== 201) throw new Error(`Upload seed failed (${res.status}): ${await res.text()}`);
|
if (res.status !== 201)
|
||||||
|
throw new Error(`Upload seed failed (${res.status}): ${await res.text()}`);
|
||||||
return (await res.json()) as { id: string };
|
return (await res.json()) as { id: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
test.describe('Mobile — double-tap gesture', () => {
|
test.describe('Mobile — double-tap gesture', () => {
|
||||||
test('double-tap on a feed card image button registers a like', async ({ api, page, guest, signIn }) => {
|
test('double-tap on a feed card image button registers a like', async ({
|
||||||
|
api,
|
||||||
|
page,
|
||||||
|
guest,
|
||||||
|
signIn,
|
||||||
|
}) => {
|
||||||
const author = await guest('DtAuthor');
|
const author = await guest('DtAuthor');
|
||||||
const liker = await guest('DtLiker');
|
const liker = await guest('DtLiker');
|
||||||
const { id: uploadId } = await seedUpload(author.jwt, 'Double-tap me');
|
const { id: uploadId } = await seedUpload(author.jwt, 'Double-tap me');
|
||||||
@@ -40,7 +46,8 @@ test.describe('Mobile — double-tap gesture', () => {
|
|||||||
await page.goto('/feed');
|
await page.goto('/feed');
|
||||||
|
|
||||||
// Locate the image button inside the card. The aria-label is "Bild vergrößern".
|
// Locate the image button inside the card. The aria-label is "Bild vergrößern".
|
||||||
const imageButton = page.locator('article')
|
const imageButton = page
|
||||||
|
.locator('article')
|
||||||
.filter({ hasText: author.displayName })
|
.filter({ hasText: author.displayName })
|
||||||
.first()
|
.first()
|
||||||
.getByRole('button', { name: 'Bild vergrößern' });
|
.getByRole('button', { name: 'Bild vergrößern' });
|
||||||
@@ -50,16 +57,26 @@ test.describe('Mobile — double-tap gesture', () => {
|
|||||||
|
|
||||||
// Wait for the optimistic increment OR the SSE-confirmed count. We assert via the
|
// Wait for the optimistic increment OR the SSE-confirmed count. We assert via the
|
||||||
// API to avoid coupling to specific DOM markup for the like badge.
|
// API to avoid coupling to specific DOM markup for the like badge.
|
||||||
await expect.poll(async () => {
|
await expect
|
||||||
const feed = await api.getFeed(liker.jwt);
|
.poll(
|
||||||
// Backend returns { uploads: [...], next_cursor }.
|
async () => {
|
||||||
const list: any[] = feed.uploads ?? feed.items ?? feed;
|
const feed = await api.getFeed(liker.jwt);
|
||||||
const row = Array.isArray(list) ? list.find((u: any) => u.id === uploadId) : undefined;
|
// Backend returns { uploads: [...], next_cursor }.
|
||||||
return row?.like_count ?? row?.likes ?? 0;
|
const list: any[] = feed.uploads ?? feed.items ?? feed;
|
||||||
}, { timeout: 5_000 }).toBeGreaterThanOrEqual(1);
|
const row = Array.isArray(list) ? list.find((u: any) => u.id === uploadId) : undefined;
|
||||||
|
return row?.like_count ?? row?.likes ?? 0;
|
||||||
|
},
|
||||||
|
{ timeout: 5_000 }
|
||||||
|
)
|
||||||
|
.toBeGreaterThanOrEqual(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('double-tap inside the lightbox triggers the heart-burst (like recorded)', async ({ api, page, guest, signIn }) => {
|
test('double-tap inside the lightbox triggers the heart-burst (like recorded)', async ({
|
||||||
|
api,
|
||||||
|
page,
|
||||||
|
guest,
|
||||||
|
signIn,
|
||||||
|
}) => {
|
||||||
const author = await guest('LbAuthor');
|
const author = await guest('LbAuthor');
|
||||||
const liker = await guest('LbLiker');
|
const liker = await guest('LbLiker');
|
||||||
const { id: uploadId } = await seedUpload(author.jwt, 'Lightbox heart');
|
const { id: uploadId } = await seedUpload(author.jwt, 'Lightbox heart');
|
||||||
@@ -68,7 +85,8 @@ test.describe('Mobile — double-tap gesture', () => {
|
|||||||
await page.goto('/feed');
|
await page.goto('/feed');
|
||||||
|
|
||||||
// Open the lightbox by clicking the image button.
|
// Open the lightbox by clicking the image button.
|
||||||
const imageButton = page.locator('article')
|
const imageButton = page
|
||||||
|
.locator('article')
|
||||||
.filter({ hasText: author.displayName })
|
.filter({ hasText: author.displayName })
|
||||||
.first()
|
.first()
|
||||||
.getByRole('button', { name: 'Bild vergrößern' });
|
.getByRole('button', { name: 'Bild vergrößern' });
|
||||||
@@ -88,12 +106,17 @@ test.describe('Mobile — double-tap gesture', () => {
|
|||||||
|
|
||||||
await doubleTap(page, media);
|
await doubleTap(page, media);
|
||||||
|
|
||||||
await expect.poll(async () => {
|
await expect
|
||||||
const feed = await api.getFeed(liker.jwt);
|
.poll(
|
||||||
// Backend returns { uploads: [...], next_cursor }.
|
async () => {
|
||||||
const list: any[] = feed.uploads ?? feed.items ?? feed;
|
const feed = await api.getFeed(liker.jwt);
|
||||||
const row = Array.isArray(list) ? list.find((u: any) => u.id === uploadId) : undefined;
|
// Backend returns { uploads: [...], next_cursor }.
|
||||||
return row?.like_count ?? row?.likes ?? 0;
|
const list: any[] = feed.uploads ?? feed.items ?? feed;
|
||||||
}, { timeout: 5_000 }).toBeGreaterThanOrEqual(1);
|
const row = Array.isArray(list) ? list.find((u: any) => u.id === uploadId) : undefined;
|
||||||
|
return row?.like_count ?? row?.likes ?? 0;
|
||||||
|
},
|
||||||
|
{ timeout: 5_000 }
|
||||||
|
)
|
||||||
|
.toBeGreaterThanOrEqual(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
* helper.
|
* helper.
|
||||||
*/
|
*/
|
||||||
import { test, expect } from '../../fixtures/test';
|
import { test, expect } from '../../fixtures/test';
|
||||||
import { uploadRaw, JPEG_MAGIC } from '../../helpers/upload-client';
|
import { uploadRaw } from '../../helpers/upload-client';
|
||||||
import { longPress } from '../../helpers/touch';
|
import { longPress } from '../../helpers/touch';
|
||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
@@ -54,7 +54,11 @@ test.describe('Mobile — long-press gesture', () => {
|
|||||||
await expect(sheet.getByRole('button', { name: /abbrechen/i })).toBeVisible();
|
await expect(sheet.getByRole('button', { name: /abbrechen/i })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a quick tap (< 500 ms) does NOT open the ContextSheet — only opens the lightbox', async ({ page, guest, signIn }) => {
|
test('a quick tap (< 500 ms) does NOT open the ContextSheet — only opens the lightbox', async ({
|
||||||
|
page,
|
||||||
|
guest,
|
||||||
|
signIn,
|
||||||
|
}) => {
|
||||||
const g = await guest('Lp2');
|
const g = await guest('Lp2');
|
||||||
await seedUpload(g.jwt, 'Quick tap');
|
await seedUpload(g.jwt, 'Quick tap');
|
||||||
await signIn(page, g);
|
await signIn(page, g);
|
||||||
@@ -68,10 +72,16 @@ test.describe('Mobile — long-press gesture', () => {
|
|||||||
|
|
||||||
// Within 1 s, the ContextSheet must not be open (aria-modal is set only when
|
// Within 1 s, the ContextSheet must not be open (aria-modal is set only when
|
||||||
// open). A quick tap opens the lightbox instead, which is a different element.
|
// open). A quick tap opens the lightbox instead, which is a different element.
|
||||||
await expect(page.locator('[data-testid="context-sheet"][aria-modal="true"]')).toHaveCount(0, { timeout: 1_000 });
|
await expect(page.locator('[data-testid="context-sheet"][aria-modal="true"]')).toHaveCount(0, {
|
||||||
|
timeout: 1_000,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('long-press suppresses the click that lands at pointerup (no double-open of lightbox)', async ({ page, guest, signIn }) => {
|
test('long-press suppresses the click that lands at pointerup (no double-open of lightbox)', async ({
|
||||||
|
page,
|
||||||
|
guest,
|
||||||
|
signIn,
|
||||||
|
}) => {
|
||||||
const g = await guest('Lp3');
|
const g = await guest('Lp3');
|
||||||
await seedUpload(g.jwt, 'Suppress click');
|
await seedUpload(g.jwt, 'Suppress click');
|
||||||
await signIn(page, g);
|
await signIn(page, g);
|
||||||
@@ -85,6 +95,8 @@ test.describe('Mobile — long-press gesture', () => {
|
|||||||
// The longpress action sets `suppressNextClick = true` — so the lightbox
|
// The longpress action sets `suppressNextClick = true` — so the lightbox
|
||||||
// (separate role=dialog) should NOT appear in addition to the context sheet.
|
// (separate role=dialog) should NOT appear in addition to the context sheet.
|
||||||
// Exactly one aria-modal=true dialog should be open: the context sheet.
|
// Exactly one aria-modal=true dialog should be open: the context sheet.
|
||||||
await expect(page.locator('[role="dialog"][aria-modal="true"]')).toHaveCount(1, { timeout: 2_000 });
|
await expect(page.locator('[role="dialog"][aria-modal="true"]')).toHaveCount(1, {
|
||||||
|
timeout: 2_000,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,7 +19,11 @@ import { join } from 'node:path';
|
|||||||
const SAMPLE_JPG = join(process.cwd(), 'fixtures', 'media', 'sample.jpg');
|
const SAMPLE_JPG = join(process.cwd(), 'fixtures', 'media', 'sample.jpg');
|
||||||
|
|
||||||
test.describe('Mobile — planned gestures (fixme until shipped)', () => {
|
test.describe('Mobile — planned gestures (fixme until shipped)', () => {
|
||||||
test.fixme('swipe left in lightbox navigates to next filtered item', async ({ page, guest, signIn }) => {
|
test.fixme('swipe left in lightbox navigates to next filtered item', async ({
|
||||||
|
page,
|
||||||
|
guest,
|
||||||
|
signIn,
|
||||||
|
}) => {
|
||||||
const author = await guest('SwipeAuthor');
|
const author = await guest('SwipeAuthor');
|
||||||
// Seed two uploads so there's a "next" to navigate to.
|
// Seed two uploads so there's a "next" to navigate to.
|
||||||
for (const cap of ['First', 'Second']) {
|
for (const cap of ['First', 'Second']) {
|
||||||
@@ -35,7 +39,10 @@ test.describe('Mobile — planned gestures (fixme until shipped)', () => {
|
|||||||
await page.goto('/feed');
|
await page.goto('/feed');
|
||||||
|
|
||||||
// Open the lightbox on the first card.
|
// Open the lightbox on the first card.
|
||||||
const firstImage = page.locator('article').filter({ hasText: 'First' }).getByRole('button', { name: 'Bild vergrößern' });
|
const firstImage = page
|
||||||
|
.locator('article')
|
||||||
|
.filter({ hasText: 'First' })
|
||||||
|
.getByRole('button', { name: 'Bild vergrößern' });
|
||||||
await firstImage.click();
|
await firstImage.click();
|
||||||
const lightbox = page.getByRole('dialog');
|
const lightbox = page.getByRole('dialog');
|
||||||
await expect(lightbox).toBeVisible();
|
await expect(lightbox).toBeVisible();
|
||||||
@@ -96,11 +103,7 @@ test.describe('Mobile — planned gestures (fixme until shipped)', () => {
|
|||||||
const box = await page.locator('body').boundingBox();
|
const box = await page.locator('body').boundingBox();
|
||||||
if (!box) throw new Error('body not visible');
|
if (!box) throw new Error('body not visible');
|
||||||
// Pull down from the top of the viewport.
|
// Pull down from the top of the viewport.
|
||||||
await swipe(
|
await swipe(page, { x: box.x + box.width / 2, y: 20 }, { x: box.x + box.width / 2, y: 200 });
|
||||||
page,
|
|
||||||
{ x: box.x + box.width / 2, y: 20 },
|
|
||||||
{ x: box.x + box.width / 2, y: 200 }
|
|
||||||
);
|
|
||||||
|
|
||||||
await page.waitForTimeout(1_000);
|
await page.waitForTimeout(1_000);
|
||||||
expect(deltaCalled).toBe(true);
|
expect(deltaCalled).toBe(true);
|
||||||
|
|||||||
@@ -17,24 +17,38 @@ import { test, expect } from '../../fixtures/test';
|
|||||||
import { inlineStyle } from '../../helpers/touch';
|
import { inlineStyle } from '../../helpers/touch';
|
||||||
|
|
||||||
test.describe('Mobile — safe-area insets', () => {
|
test.describe('Mobile — safe-area insets', () => {
|
||||||
test('bottom nav declares safe-area-inset-bottom in its inline style', async ({ page, guest, signIn }) => {
|
test('bottom nav declares safe-area-inset-bottom in its inline style', async ({
|
||||||
|
page,
|
||||||
|
guest,
|
||||||
|
signIn,
|
||||||
|
}) => {
|
||||||
const g = await guest('SafeAreaNav');
|
const g = await guest('SafeAreaNav');
|
||||||
await signIn(page, g);
|
await signIn(page, g);
|
||||||
await page.goto('/feed');
|
await page.goto('/feed');
|
||||||
|
|
||||||
const nav = page.locator('nav').filter({ has: page.getByRole('link', { name: 'Galerie' }) }).first();
|
const nav = page
|
||||||
|
.locator('nav')
|
||||||
|
.filter({ has: page.getByRole('link', { name: 'Galerie' }) })
|
||||||
|
.first();
|
||||||
await expect(nav).toBeVisible();
|
await expect(nav).toBeVisible();
|
||||||
|
|
||||||
const style = await inlineStyle(nav);
|
const style = await inlineStyle(nav);
|
||||||
expect(style).toContain('env(safe-area-inset-bottom)');
|
expect(style).toContain('env(safe-area-inset-bottom)');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('bottom nav stays flush with viewport bottom (no large gap)', async ({ page, guest, signIn }) => {
|
test('bottom nav stays flush with viewport bottom (no large gap)', async ({
|
||||||
|
page,
|
||||||
|
guest,
|
||||||
|
signIn,
|
||||||
|
}) => {
|
||||||
const g = await guest('SafeAreaFlush');
|
const g = await guest('SafeAreaFlush');
|
||||||
await signIn(page, g);
|
await signIn(page, g);
|
||||||
await page.goto('/feed');
|
await page.goto('/feed');
|
||||||
|
|
||||||
const nav = page.locator('nav').filter({ has: page.getByRole('link', { name: 'Galerie' }) }).first();
|
const nav = page
|
||||||
|
.locator('nav')
|
||||||
|
.filter({ has: page.getByRole('link', { name: 'Galerie' }) })
|
||||||
|
.first();
|
||||||
const viewport = page.viewportSize();
|
const viewport = page.viewportSize();
|
||||||
if (!viewport) throw new Error('No viewport size set on this project');
|
if (!viewport) throw new Error('No viewport size set on this project');
|
||||||
const box = await nav.boundingBox();
|
const box = await nav.boundingBox();
|
||||||
@@ -48,7 +62,11 @@ test.describe('Mobile — safe-area insets', () => {
|
|||||||
// (A vacuous `/join` probe that only asserted `Array.isArray(...)` — always true —
|
// (A vacuous `/join` probe that only asserted `Array.isArray(...)` — always true —
|
||||||
// was removed; the real sheet-level env() check is the structural test below.)
|
// was removed; the real sheet-level env() check is the structural test below.)
|
||||||
|
|
||||||
test('upload sheet and context sheet both honor env() (structural check)', async ({ page, guest, signIn }) => {
|
test('upload sheet and context sheet both honor env() (structural check)', async ({
|
||||||
|
page,
|
||||||
|
guest,
|
||||||
|
signIn,
|
||||||
|
}) => {
|
||||||
const g = await guest('SafeAreaSheets');
|
const g = await guest('SafeAreaSheets');
|
||||||
await signIn(page, g);
|
await signIn(page, g);
|
||||||
await page.goto('/feed');
|
await page.goto('/feed');
|
||||||
@@ -57,9 +75,9 @@ test.describe('Mobile — safe-area insets', () => {
|
|||||||
await page.getByRole('button', { name: 'Hochladen' }).click();
|
await page.getByRole('button', { name: 'Hochladen' }).click();
|
||||||
// Even if the sheet is offscreen / hidden, the style attribute is present in the DOM.
|
// Even if the sheet is offscreen / hidden, the style attribute is present in the DOM.
|
||||||
const hits: number = await page.evaluate(() => {
|
const hits: number = await page.evaluate(() => {
|
||||||
return Array.from(document.querySelectorAll<HTMLElement>('[style]'))
|
return Array.from(document.querySelectorAll<HTMLElement>('[style]')).filter((el) =>
|
||||||
.filter((el) => (el.getAttribute('style') ?? '').includes('env(safe-area-inset-bottom)'))
|
(el.getAttribute('style') ?? '').includes('env(safe-area-inset-bottom)')
|
||||||
.length;
|
).length;
|
||||||
});
|
});
|
||||||
expect(hits).toBeGreaterThanOrEqual(1);
|
expect(hits).toBeGreaterThanOrEqual(1);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -24,7 +24,11 @@ test.describe('Mobile a11y — sheets dismiss on Escape', () => {
|
|||||||
await expect(sheet).not.toBeVisible({ timeout: 2_000 });
|
await expect(sheet).not.toBeVisible({ timeout: 2_000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('leave-confirm sheet (built on ConfirmSheet) closes on Escape', async ({ page, guest, signIn }) => {
|
test('leave-confirm sheet (built on ConfirmSheet) closes on Escape', async ({
|
||||||
|
page,
|
||||||
|
guest,
|
||||||
|
signIn,
|
||||||
|
}) => {
|
||||||
const g = await guest('LeaveEsc');
|
const g = await guest('LeaveEsc');
|
||||||
await signIn(page, g);
|
await signIn(page, g);
|
||||||
await page.goto('/account');
|
await page.goto('/account');
|
||||||
|
|||||||
@@ -16,8 +16,12 @@ const MIN_TOUCH = 44;
|
|||||||
|
|
||||||
async function assertTouchTarget(box: { width: number; height: number } | null, name: string) {
|
async function assertTouchTarget(box: { width: number; height: number } | null, name: string) {
|
||||||
if (!box) throw new Error(`${name} not visible — no bounding box`);
|
if (!box) throw new Error(`${name} not visible — no bounding box`);
|
||||||
expect.soft(box.width, `${name} width ≥ ${MIN_TOUCH}px (got ${box.width})`).toBeGreaterThanOrEqual(MIN_TOUCH);
|
expect
|
||||||
expect.soft(box.height, `${name} height ≥ ${MIN_TOUCH}px (got ${box.height})`).toBeGreaterThanOrEqual(MIN_TOUCH);
|
.soft(box.width, `${name} width ≥ ${MIN_TOUCH}px (got ${box.width})`)
|
||||||
|
.toBeGreaterThanOrEqual(MIN_TOUCH);
|
||||||
|
expect
|
||||||
|
.soft(box.height, `${name} height ≥ ${MIN_TOUCH}px (got ${box.height})`)
|
||||||
|
.toBeGreaterThanOrEqual(MIN_TOUCH);
|
||||||
}
|
}
|
||||||
|
|
||||||
test.describe('Mobile — touch target audit', () => {
|
test.describe('Mobile — touch target audit', () => {
|
||||||
@@ -55,6 +59,9 @@ test.describe('Mobile — touch target audit', () => {
|
|||||||
await expect(page.getByTestId('pin-modal')).toBeVisible();
|
await expect(page.getByTestId('pin-modal')).toBeVisible();
|
||||||
|
|
||||||
await assertTouchTarget(await page.getByTestId('pin-copy').boundingBox(), 'PIN copy button');
|
await assertTouchTarget(await page.getByTestId('pin-copy').boundingBox(), 'PIN copy button');
|
||||||
await assertTouchTarget(await page.getByTestId('continue-to-feed').boundingBox(), 'Continue-to-feed button');
|
await assertTouchTarget(
|
||||||
|
await page.getByTestId('continue-to-feed').boundingBox(),
|
||||||
|
'Continue-to-feed button'
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,7 +6,11 @@
|
|||||||
import { test, expect } from '../../fixtures/test';
|
import { test, expect } from '../../fixtures/test';
|
||||||
|
|
||||||
test.describe('Mobile — upload composer cancel confirmation', () => {
|
test.describe('Mobile — upload composer cancel confirmation', () => {
|
||||||
test('typing a caption then tapping X opens the discard ConfirmSheet', async ({ page, guest, signIn }) => {
|
test('typing a caption then tapping X opens the discard ConfirmSheet', async ({
|
||||||
|
page,
|
||||||
|
guest,
|
||||||
|
signIn,
|
||||||
|
}) => {
|
||||||
const g = await guest('CancelConf');
|
const g = await guest('CancelConf');
|
||||||
await signIn(page, g);
|
await signIn(page, g);
|
||||||
|
|
||||||
@@ -31,7 +35,11 @@ test.describe('Mobile — upload composer cancel confirmation', () => {
|
|||||||
await expect(caption).toHaveValue(/a meaningful caption/);
|
await expect(caption).toHaveValue(/a meaningful caption/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('with no content, tapping X navigates directly to /feed', async ({ page, guest, signIn }) => {
|
test('with no content, tapping X navigates directly to /feed', async ({
|
||||||
|
page,
|
||||||
|
guest,
|
||||||
|
signIn,
|
||||||
|
}) => {
|
||||||
const g = await guest('CancelEmpty');
|
const g = await guest('CancelEmpty');
|
||||||
await signIn(page, g);
|
await signIn(page, g);
|
||||||
await page.goto('/upload');
|
await page.goto('/upload');
|
||||||
|
|||||||
@@ -16,13 +16,20 @@ const VIEWPORTS = [
|
|||||||
|
|
||||||
test.describe('Mobile — viewport reflow', () => {
|
test.describe('Mobile — viewport reflow', () => {
|
||||||
for (const vp of VIEWPORTS) {
|
for (const vp of VIEWPORTS) {
|
||||||
test(`bottom nav remains usable at ${vp.name} (${vp.width}×${vp.height})`, async ({ page, guest, signIn }) => {
|
test(`bottom nav remains usable at ${vp.name} (${vp.width}×${vp.height})`, async ({
|
||||||
|
page,
|
||||||
|
guest,
|
||||||
|
signIn,
|
||||||
|
}) => {
|
||||||
const g = await guest(`Reflow_${vp.width}x${vp.height}`);
|
const g = await guest(`Reflow_${vp.width}x${vp.height}`);
|
||||||
await signIn(page, g);
|
await signIn(page, g);
|
||||||
await page.setViewportSize({ width: vp.width, height: vp.height });
|
await page.setViewportSize({ width: vp.width, height: vp.height });
|
||||||
await page.goto('/feed');
|
await page.goto('/feed');
|
||||||
|
|
||||||
const nav = page.locator('nav').filter({ has: page.getByRole('link', { name: 'Galerie' }) }).first();
|
const nav = page
|
||||||
|
.locator('nav')
|
||||||
|
.filter({ has: page.getByRole('link', { name: 'Galerie' }) })
|
||||||
|
.first();
|
||||||
const fab = page.getByRole('button', { name: 'Hochladen' });
|
const fab = page.getByRole('button', { name: 'Hochladen' });
|
||||||
|
|
||||||
await expect(nav).toBeVisible();
|
await expect(nav).toBeVisible();
|
||||||
@@ -40,11 +47,15 @@ test.describe('Mobile — viewport reflow', () => {
|
|||||||
if (!fabBox) throw new Error('FAB has no bounding box');
|
if (!fabBox) throw new Error('FAB has no bounding box');
|
||||||
const fabMidX = fabBox.x + fabBox.width / 2;
|
const fabMidX = fabBox.x + fabBox.width / 2;
|
||||||
const expectedMid = vp.width / 2;
|
const expectedMid = vp.width / 2;
|
||||||
expect.soft(Math.abs(fabMidX - expectedMid)).toBeLessThanOrEqual(vp.width * 0.30);
|
expect.soft(Math.abs(fabMidX - expectedMid)).toBeLessThanOrEqual(vp.width * 0.3);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
test('rotation portrait → landscape preserves auth + bottom nav', async ({ page, guest, signIn }) => {
|
test('rotation portrait → landscape preserves auth + bottom nav', async ({
|
||||||
|
page,
|
||||||
|
guest,
|
||||||
|
signIn,
|
||||||
|
}) => {
|
||||||
const g = await guest('Rotate');
|
const g = await guest('Rotate');
|
||||||
await signIn(page, g);
|
await signIn(page, g);
|
||||||
await page.goto('/feed');
|
await page.goto('/feed');
|
||||||
|
|||||||
@@ -10,8 +10,7 @@
|
|||||||
*/
|
*/
|
||||||
import { test, expect } from '../../fixtures/test';
|
import { test, expect } from '../../fixtures/test';
|
||||||
import { seedUpload } from '../../helpers/seed';
|
import { seedUpload } from '../../helpers/seed';
|
||||||
|
import { BASE } from '../../helpers/env';
|
||||||
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
|
|
||||||
|
|
||||||
async function feedDelta(jwt: string, since: string): Promise<any> {
|
async function feedDelta(jwt: string, since: string): Promise<any> {
|
||||||
const res = await fetch(`${BASE}/api/v1/feed/delta?since=${encodeURIComponent(since)}`, {
|
const res = await fetch(`${BASE}/api/v1/feed/delta?since=${encodeURIComponent(since)}`, {
|
||||||
@@ -40,9 +39,10 @@ test.describe('Realtime — ban replays in the reconnect delta (H1 audit fix)',
|
|||||||
|
|
||||||
// A delta from the pre-ban cursor MUST surface the ban so the client can evict.
|
// A delta from the pre-ban cursor MUST surface the ban so the client can evict.
|
||||||
const afterBan = await feedDelta(host.jwt, cursorBefore);
|
const afterBan = await feedDelta(host.jwt, cursorBefore);
|
||||||
expect(afterBan.hidden_user_ids, 'ban replayed to a client that missed the live event').toContain(
|
expect(
|
||||||
g.userId
|
afterBan.hidden_user_ids,
|
||||||
);
|
'ban replayed to a client that missed the live event'
|
||||||
|
).toContain(g.userId);
|
||||||
|
|
||||||
// And a delta from a cursor AFTER the ban must NOT re-surface it (the `>= since` filter
|
// And a delta from a cursor AFTER the ban must NOT re-surface it (the `>= since` filter
|
||||||
// means a client already past the ban isn't told again forever).
|
// means a client already past the ban isn't told again forever).
|
||||||
|
|||||||
@@ -44,8 +44,8 @@ import { join } from 'node:path';
|
|||||||
import { seedUpload } from '../../helpers/seed';
|
import { seedUpload } from '../../helpers/seed';
|
||||||
import { uploadRaw, uploadPausedMidStream } from '../../helpers/upload-client';
|
import { uploadRaw, uploadPausedMidStream } from '../../helpers/upload-client';
|
||||||
import { SseListener } from '../../helpers/sse-listener';
|
import { SseListener } from '../../helpers/sse-listener';
|
||||||
|
import { BASE } from '../../helpers/env';
|
||||||
|
|
||||||
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
|
|
||||||
const SLUG = 'e2e-test-event';
|
const SLUG = 'e2e-test-event';
|
||||||
const N_UPLOADS = 6;
|
const N_UPLOADS = 6;
|
||||||
/** A ZIP holding no entries: just the 22-byte end-of-central-directory record. */
|
/** A ZIP holding no entries: just the 22-byte end-of-central-directory record. */
|
||||||
@@ -74,7 +74,9 @@ function post(path: string, jwt: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function exportStatus(jwt: string): Promise<any> {
|
async function exportStatus(jwt: string): Promise<any> {
|
||||||
const res = await fetch(BASE + '/api/v1/export/status', { headers: { Authorization: `Bearer ${jwt}` } });
|
const res = await fetch(BASE + '/api/v1/export/status', {
|
||||||
|
headers: { Authorization: `Bearer ${jwt}` },
|
||||||
|
});
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,10 +87,13 @@ async function mintTicket(jwt: string): Promise<string> {
|
|||||||
|
|
||||||
async function waitExportDone(jwt: string) {
|
async function waitExportDone(jwt: string) {
|
||||||
await expect
|
await expect
|
||||||
.poll(async () => {
|
.poll(
|
||||||
const s = await exportStatus(jwt);
|
async () => {
|
||||||
return s.released === true && s.zip?.status === 'done' && s.html?.status === 'done';
|
const s = await exportStatus(jwt);
|
||||||
}, { timeout: 60_000, intervals: [500] })
|
return s.released === true && s.zip?.status === 'done' && s.html?.status === 'done';
|
||||||
|
},
|
||||||
|
{ timeout: 60_000, intervals: [500] }
|
||||||
|
)
|
||||||
.toBe(true);
|
.toBe(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,10 +156,14 @@ test.describe('Flow re-review — reopen→re-release export integrity + stale-k
|
|||||||
expect((await post('/api/v1/host/gallery/release', host.jwt)).status).toBe(204);
|
expect((await post('/api/v1/host/gallery/release', host.jwt)).status).toBe(204);
|
||||||
|
|
||||||
// Post-release uploads must be rejected (belt-and-suspenders on top of the lock).
|
// Post-release uploads must be rejected (belt-and-suspenders on top of the lock).
|
||||||
const rejected = await uploadRaw(host.jwt, readFileSync(join(process.cwd(), 'fixtures', 'media', 'sample.jpg')), {
|
const rejected = await uploadRaw(
|
||||||
filename: 'late.jpg',
|
host.jwt,
|
||||||
contentType: 'image/jpeg',
|
readFileSync(join(process.cwd(), 'fixtures', 'media', 'sample.jpg')),
|
||||||
});
|
{
|
||||||
|
filename: 'late.jpg',
|
||||||
|
contentType: 'image/jpeg',
|
||||||
|
}
|
||||||
|
);
|
||||||
expect(rejected.status).toBe(403);
|
expect(rejected.status).toBe(403);
|
||||||
|
|
||||||
// Churn: reopen (retires the generation) then re-release, several times in quick
|
// Churn: reopen (retires the generation) then re-release, several times in quick
|
||||||
@@ -418,7 +427,10 @@ test.describe('Flow re-review — reopen→re-release export integrity + stale-k
|
|||||||
listing.some((n) => n.includes(takedown)),
|
listing.some((n) => n.includes(takedown)),
|
||||||
'the taken-down photo must be gone from the regenerated keepsake'
|
'the taken-down photo must be gone from the regenerated keepsake'
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
expect(listing.some((n) => n.includes(keep)), 'the kept photo survives').toBe(true);
|
expect(
|
||||||
|
listing.some((n) => n.includes(keep)),
|
||||||
|
'the kept photo survives'
|
||||||
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('BANNING a guest after release removes their photos from the keepsake', async ({
|
test('BANNING a guest after release removes their photos from the keepsake', async ({
|
||||||
@@ -451,7 +463,10 @@ test.describe('Flow re-review — reopen→re-release export integrity + stale-k
|
|||||||
listing.some((n) => n.includes(badPhoto)),
|
listing.some((n) => n.includes(badPhoto)),
|
||||||
"a banned guest's photo must not survive in the keepsake everyone downloads"
|
"a banned guest's photo must not survive in the keepsake everyone downloads"
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
expect(listing.some((n) => n.includes(goodPhoto)), 'everyone else keeps their photos').toBe(true);
|
expect(
|
||||||
|
listing.some((n) => n.includes(goodPhoto)),
|
||||||
|
'everyone else keeps their photos'
|
||||||
|
).toBe(true);
|
||||||
expect(listing).toHaveLength(1);
|
expect(listing).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -543,7 +558,9 @@ test.describe('Flow re-review — reopen→re-release export integrity + stale-k
|
|||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
// ... and it bumped the epoch (the viewer must rebuild) ...
|
// ... and it bumped the epoch (the viewer must rebuild) ...
|
||||||
await expect.poll(async () => await eventEpoch(), { timeout: 10_000 }).toBeGreaterThan(eventEpochBefore);
|
await expect
|
||||||
|
.poll(async () => await eventEpoch(), { timeout: 10_000 })
|
||||||
|
.toBeGreaterThan(eventEpochBefore);
|
||||||
|
|
||||||
// ... while the ZIP was CARRIED FORWARD, not rebuilt: its row rides the new epoch (the media
|
// ... while the ZIP was CARRIED FORWARD, not rebuilt: its row rides the new epoch (the media
|
||||||
// didn't change, so a full ZIP rebuild would be wasted work).
|
// didn't change, so a full ZIP rebuild would be wasted work).
|
||||||
@@ -663,7 +680,9 @@ test.describe('Flow re-review — reopen→re-release export integrity + stale-k
|
|||||||
|
|
||||||
// Stuck: the keepsake is not downloadable and no amount of re-releasing helps.
|
// Stuck: the keepsake is not downloadable and no amount of re-releasing helps.
|
||||||
const ticket = await mintTicket(host.jwt);
|
const ticket = await mintTicket(host.jwt);
|
||||||
expect((await fetch(BASE + '/api/v1/export/zip?ticket=' + encodeURIComponent(ticket))).status).toBe(404);
|
expect(
|
||||||
|
(await fetch(BASE + '/api/v1/export/zip?ticket=' + encodeURIComponent(ticket))).status
|
||||||
|
).toBe(404);
|
||||||
// ("Galerie wurde bereits freigegeben." — this is the dead end the rebuild endpoint exists for.)
|
// ("Galerie wurde bereits freigegeben." — this is the dead end the rebuild endpoint exists for.)
|
||||||
expect((await post('/api/v1/host/gallery/release', host.jwt)).status).toBe(400);
|
expect((await post('/api/v1/host/gallery/release', host.jwt)).status).toBe(400);
|
||||||
|
|
||||||
|
|||||||
@@ -75,9 +75,7 @@ test.describe('Flow re-review — offline upload auto-resume (B1)', () => {
|
|||||||
// While offline: nothing may have reached the server, and — the crux of the
|
// While offline: nothing may have reached the server, and — the crux of the
|
||||||
// fix — the item must be parked as `pending`, NOT burned to `error`/`blocked`
|
// fix — the item must be parked as `pending`, NOT burned to `error`/`blocked`
|
||||||
// (which on the old code would require a manual retry tap).
|
// (which on the old code would require a manual retry tap).
|
||||||
await expect
|
await expect.poll(() => queueStatuses(page), { timeout: 8_000 }).toEqual(['pending']);
|
||||||
.poll(() => queueStatuses(page), { timeout: 8_000 })
|
|
||||||
.toEqual(['pending']);
|
|
||||||
expect(await db.countUploadsForUser(g.userId)).toBe(0);
|
expect(await db.countUploadsForUser(g.userId)).toBe(0);
|
||||||
|
|
||||||
// Reconnect. The `online` listener must resume the queue automatically — we do
|
// Reconnect. The `online` listener must resume the queue automatically — we do
|
||||||
@@ -85,16 +83,17 @@ test.describe('Flow re-review — offline upload auto-resume (B1)', () => {
|
|||||||
await page.context().setOffline(false);
|
await page.context().setOffline(false);
|
||||||
|
|
||||||
// The queued upload is now created server-side without any manual interaction.
|
// The queued upload is now created server-side without any manual interaction.
|
||||||
await expect
|
await expect.poll(() => db.countUploadsForUser(g.userId), { timeout: 20_000 }).toBe(1);
|
||||||
.poll(() => db.countUploadsForUser(g.userId), { timeout: 20_000 })
|
|
||||||
.toBe(1);
|
|
||||||
|
|
||||||
// And the queue item ends terminal-good (done) or is drained — never stuck in error.
|
// And the queue item ends terminal-good (done) or is drained — never stuck in error.
|
||||||
await expect
|
await expect
|
||||||
.poll(async () => {
|
.poll(
|
||||||
const s = await queueStatuses(page);
|
async () => {
|
||||||
return s.every((x) => x === 'done') || s.length === 0;
|
const s = await queueStatuses(page);
|
||||||
}, { timeout: 10_000 })
|
return s.every((x) => x === 'done') || s.length === 0;
|
||||||
|
},
|
||||||
|
{ timeout: 10_000 }
|
||||||
|
)
|
||||||
.toBe(true);
|
.toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { test, expect } from '../../fixtures/test';
|
|||||||
import { uploadRaw } from '../../helpers/upload-client';
|
import { uploadRaw } from '../../helpers/upload-client';
|
||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
|
import { BASE } from '../../helpers/env';
|
||||||
|
|
||||||
const SAMPLE = () => readFileSync(join(process.cwd(), 'fixtures', 'media', 'sample.jpg'));
|
const SAMPLE = () => readFileSync(join(process.cwd(), 'fixtures', 'media', 'sample.jpg'));
|
||||||
|
|
||||||
@@ -36,7 +37,6 @@ test.describe('Upload — locked event uses a distinct, reversible 403 (audit fi
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('released gallery → 403 uploads_locked (also reversible via reopen)', async ({ host }) => {
|
test('released gallery → 403 uploads_locked (also reversible via reopen)', async ({ host }) => {
|
||||||
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
|
|
||||||
const rel = await fetch(`${BASE}/api/v1/host/gallery/release`, {
|
const rel = await fetch(`${BASE}/api/v1/host/gallery/release`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${host.jwt}` },
|
headers: { Authorization: `Bearer ${host.jwt}` },
|
||||||
|
|||||||
6
frontend/.prettierignore
Normal file
6
frontend/.prettierignore
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
.svelte-kit/
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
node_modules/
|
||||||
|
static/export-viewer/
|
||||||
|
package-lock.json
|
||||||
8
frontend/.prettierrc
Normal file
8
frontend/.prettierrc
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"useTabs": true,
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "none",
|
||||||
|
"printWidth": 100,
|
||||||
|
"plugins": ["prettier-plugin-svelte"],
|
||||||
|
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
|
||||||
|
}
|
||||||
70
frontend/eslint.config.js
Normal file
70
frontend/eslint.config.js
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import js from '@eslint/js';
|
||||||
|
import ts from 'typescript-eslint';
|
||||||
|
import svelte from 'eslint-plugin-svelte';
|
||||||
|
import prettier from 'eslint-config-prettier';
|
||||||
|
import globals from 'globals';
|
||||||
|
import svelteConfig from './svelte.config.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flat-config ESLint for the SvelteKit frontend. Type-aware where it's cheap; Prettier owns
|
||||||
|
* formatting (its config is last so it disables every stylistic rule that would fight the formatter).
|
||||||
|
*/
|
||||||
|
export default ts.config(
|
||||||
|
js.configs.recommended,
|
||||||
|
...ts.configs.recommended,
|
||||||
|
...svelte.configs.recommended,
|
||||||
|
prettier,
|
||||||
|
...svelte.configs.prettier,
|
||||||
|
{
|
||||||
|
languageOptions: {
|
||||||
|
globals: { ...globals.browser, ...globals.node }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
projectService: true,
|
||||||
|
extraFileExtensions: ['.svelte'],
|
||||||
|
parser: ts.parser,
|
||||||
|
svelteConfig
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rules: {
|
||||||
|
// A leading underscore is the deliberate "intentionally unused" marker (e.g. `{#each x as _}`).
|
||||||
|
'@typescript-eslint/no-unused-vars': [
|
||||||
|
'error',
|
||||||
|
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_' }
|
||||||
|
],
|
||||||
|
// Off on purpose. This rule wants every `goto('/x')` / `href="/x"` wrapped in `resolve()`
|
||||||
|
// for typed routes. That is a taste/ergonomics preference, not a correctness rule — string
|
||||||
|
// routes work fine and the app uses them deliberately. We keep the rules that catch actual
|
||||||
|
// bugs (require-each-key, prefer-svelte-reactivity) and drop this churn.
|
||||||
|
'svelte/no-navigation-without-resolve': 'off',
|
||||||
|
// Off: `svelte-ignore` comments are consumed by the Svelte compiler / svelte-check, which
|
||||||
|
// ESLint cannot see — so it reports every one as "unused" even when it is actively
|
||||||
|
// suppressing a real svelte-check a11y warning. Removing them on ESLint's say-so would
|
||||||
|
// reintroduce those warnings. svelte-check is the authority on these, not ESLint.
|
||||||
|
'svelte/no-unused-svelte-ignore': 'off'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Test fixtures legitimately use `any` for partial/mock shapes (e.g. a stub upload that only
|
||||||
|
// sets the two fields the function under test reads). Not worth threading full types through.
|
||||||
|
files: ['**/*.test.ts'],
|
||||||
|
rules: { '@typescript-eslint/no-explicit-any': 'off' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ignores: [
|
||||||
|
'.svelte-kit/',
|
||||||
|
'build/',
|
||||||
|
'dist/',
|
||||||
|
'node_modules/',
|
||||||
|
'static/export-viewer/',
|
||||||
|
'*.config.js',
|
||||||
|
'*.config.ts'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
/* Pulls the live app's design tokens so the offline keepsake matches visually.
|
/* Pulls the live app's design tokens so the offline keepsake matches visually.
|
||||||
* See ../../src/tailwind-theme.css for the source of truth. */
|
* See ../../src/tailwind-theme.css for the source of truth. */
|
||||||
@import "../../src/tailwind-theme.css";
|
@import '../../src/tailwind-theme.css';
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import type { ViewerData, ViewerPost, ViewerComment } from '$lib/types';
|
import type { ViewerData, ViewerPost } from '$lib/types';
|
||||||
|
|
||||||
let data = $state<ViewerData | null>(null);
|
let data = $state<ViewerData | null>(null);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
@@ -13,7 +13,10 @@
|
|||||||
let searchQuery = $state('');
|
let searchQuery = $state('');
|
||||||
let showAutocomplete = $state(false);
|
let showAutocomplete = $state(false);
|
||||||
|
|
||||||
interface Filter { type: 'tag' | 'user'; value: string }
|
interface Filter {
|
||||||
|
type: 'tag' | 'user';
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
let activeFilters = $state<Filter[]>([]);
|
let activeFilters = $state<Filter[]>([]);
|
||||||
|
|
||||||
// Lightbox state
|
// Lightbox state
|
||||||
@@ -29,6 +32,7 @@
|
|||||||
let posts = $derived(data?.posts ?? []);
|
let posts = $derived(data?.posts ?? []);
|
||||||
|
|
||||||
let allTags = $derived.by(() => {
|
let allTags = $derived.by(() => {
|
||||||
|
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- local throwaway counter inside a $derived.by; never stored in $state.
|
||||||
const freq = new Map<string, number>();
|
const freq = new Map<string, number>();
|
||||||
for (const p of posts) {
|
for (const p of posts) {
|
||||||
for (const t of p.tags) {
|
for (const t of p.tags) {
|
||||||
@@ -47,7 +51,7 @@
|
|||||||
if (!showAutocomplete) return [];
|
if (!showAutocomplete) return [];
|
||||||
return [
|
return [
|
||||||
...allUploaders.slice(0, 3).map((u) => ({ type: 'user' as const, value: u })),
|
...allUploaders.slice(0, 3).map((u) => ({ type: 'user' as const, value: u })),
|
||||||
...allTags.slice(0, 3).map(([t]) => ({ type: 'tag' as const, value: t })),
|
...allTags.slice(0, 3).map(([t]) => ({ type: 'tag' as const, value: t }))
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
if (q.startsWith('#')) {
|
if (q.startsWith('#')) {
|
||||||
@@ -66,7 +70,7 @@
|
|||||||
...allTags
|
...allTags
|
||||||
.filter(([t]) => t.includes(lower))
|
.filter(([t]) => t.includes(lower))
|
||||||
.slice(0, 4)
|
.slice(0, 4)
|
||||||
.map(([t]) => ({ type: 'tag' as const, value: t })),
|
.map(([t]) => ({ type: 'tag' as const, value: t }))
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -98,8 +102,9 @@
|
|||||||
const res = await fetch('./data.json');
|
const res = await fetch('./data.json');
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
data = await res.json();
|
data = await res.json();
|
||||||
} catch (e) {
|
} catch {
|
||||||
error = 'Daten konnten nicht geladen werden. Stelle sicher, dass data.json im selben Ordner liegt.';
|
error =
|
||||||
|
'Daten konnten nicht geladen werden. Stelle sicher, dass data.json im selben Ordner liegt.';
|
||||||
} finally {
|
} finally {
|
||||||
loading = false;
|
loading = false;
|
||||||
}
|
}
|
||||||
@@ -110,14 +115,20 @@
|
|||||||
function formatDate(iso: string): string {
|
function formatDate(iso: string): string {
|
||||||
const d = new Date(iso);
|
const d = new Date(iso);
|
||||||
return d.toLocaleString('de-DE', {
|
return d.toLocaleString('de-DE', {
|
||||||
day: '2-digit', month: '2-digit', year: 'numeric',
|
day: '2-digit',
|
||||||
hour: '2-digit', minute: '2-digit',
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatShortDate(iso: string): string {
|
function formatShortDate(iso: string): string {
|
||||||
return new Date(iso).toLocaleString('de-DE', {
|
return new Date(iso).toLocaleString('de-DE', {
|
||||||
day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit',
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,7 +142,7 @@
|
|||||||
'bg-green-100 text-green-700',
|
'bg-green-100 text-green-700',
|
||||||
'bg-amber-100 text-amber-700',
|
'bg-amber-100 text-amber-700',
|
||||||
'bg-rose-100 text-rose-700',
|
'bg-rose-100 text-rose-700',
|
||||||
'bg-teal-100 text-teal-700',
|
'bg-teal-100 text-teal-700'
|
||||||
];
|
];
|
||||||
function avatarColor(name: string): string {
|
function avatarColor(name: string): string {
|
||||||
let hash = 0;
|
let hash = 0;
|
||||||
@@ -214,7 +225,9 @@
|
|||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<div class="flex min-h-screen items-center justify-center bg-gray-50">
|
<div class="flex min-h-screen items-center justify-center bg-gray-50">
|
||||||
<div class="inline-block h-8 w-8 animate-spin rounded-full border-2 border-gray-300 border-t-blue-600"></div>
|
<div
|
||||||
|
class="inline-block h-8 w-8 animate-spin rounded-full border-2 border-gray-300 border-t-blue-600"
|
||||||
|
></div>
|
||||||
</div>
|
</div>
|
||||||
{:else if error}
|
{:else if error}
|
||||||
<div class="flex min-h-screen items-center justify-center bg-gray-50 p-4">
|
<div class="flex min-h-screen items-center justify-center bg-gray-50 p-4">
|
||||||
@@ -233,20 +246,44 @@
|
|||||||
<div class="flex items-center gap-1 rounded-lg bg-gray-100 p-1">
|
<div class="flex items-center gap-1 rounded-lg bg-gray-100 p-1">
|
||||||
<button
|
<button
|
||||||
onclick={() => switchView('list')}
|
onclick={() => switchView('list')}
|
||||||
class="rounded-md p-1.5 transition-colors {viewMode === 'list' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-400 hover:text-gray-600'}"
|
class="rounded-md p-1.5 transition-colors {viewMode === 'list'
|
||||||
|
? 'bg-white text-gray-900 shadow-sm'
|
||||||
|
: 'text-gray-400 hover:text-gray-600'}"
|
||||||
aria-label="Listenansicht"
|
aria-label="Listenansicht"
|
||||||
>
|
>
|
||||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
<svg
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
|
class="h-5 w-5"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onclick={() => switchView('grid')}
|
onclick={() => switchView('grid')}
|
||||||
class="rounded-md p-1.5 transition-colors {viewMode === 'grid' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-400 hover:text-gray-600'}"
|
class="rounded-md p-1.5 transition-colors {viewMode === 'grid'
|
||||||
|
? 'bg-white text-gray-900 shadow-sm'
|
||||||
|
: 'text-gray-400 hover:text-gray-600'}"
|
||||||
aria-label="Rasteransicht"
|
aria-label="Rasteransicht"
|
||||||
>
|
>
|
||||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
<svg
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6A2.25 2.25 0 0 1 6 3.75h2.25A2.25 2.25 0 0 1 10.5 6v2.25a2.25 2.25 0 0 1-2.25 2.25H6a2.25 2.25 0 0 1-2.25-2.25V6ZM3.75 15.75A2.25 2.25 0 0 1 6 13.5h2.25a2.25 2.25 0 0 1 2.25 2.25V18a2.25 2.25 0 0 1-2.25 2.25H6A2.25 2.25 0 0 1 3.75 18v-2.25ZM13.5 6a2.25 2.25 0 0 1 2.25-2.25H18A2.25 2.25 0 0 1 20.25 6v2.25A2.25 2.25 0 0 1 18 10.5h-2.25a2.25 2.25 0 0 1-2.25-2.25V6ZM13.5 15.75a2.25 2.25 0 0 1 2.25-2.25H18a2.25 2.25 0 0 1 2.25 2.25V18A2.25 2.25 0 0 1 18 20.25h-2.25A2.25 2.25 0 0 1 13.5 18v-2.25Z" />
|
class="h-5 w-5"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M3.75 6A2.25 2.25 0 0 1 6 3.75h2.25A2.25 2.25 0 0 1 10.5 6v2.25a2.25 2.25 0 0 1-2.25 2.25H6a2.25 2.25 0 0 1-2.25-2.25V6ZM3.75 15.75A2.25 2.25 0 0 1 6 13.5h2.25a2.25 2.25 0 0 1 2.25 2.25V18a2.25 2.25 0 0 1-2.25 2.25H6A2.25 2.25 0 0 1 3.75 18v-2.25ZM13.5 6a2.25 2.25 0 0 1 2.25-2.25H18A2.25 2.25 0 0 1 20.25 6v2.25A2.25 2.25 0 0 1 18 10.5h-2.25a2.25 2.25 0 0 1-2.25-2.25V6ZM13.5 15.75a2.25 2.25 0 0 1 2.25-2.25H18a2.25 2.25 0 0 1 2.25 2.25V18A2.25 2.25 0 0 1 18 20.25h-2.25A2.25 2.25 0 0 1 13.5 18v-2.25Z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -258,22 +295,20 @@
|
|||||||
<div class="flex gap-2 overflow-x-auto pb-2">
|
<div class="flex gap-2 overflow-x-auto pb-2">
|
||||||
<button
|
<button
|
||||||
onclick={() => selectHashtag(null)}
|
onclick={() => selectHashtag(null)}
|
||||||
class="shrink-0 rounded-full px-3 py-1 text-sm font-medium transition {
|
class="shrink-0 rounded-full px-3 py-1 text-sm font-medium transition {selectedHashtag ===
|
||||||
selectedHashtag === null
|
null
|
||||||
? 'bg-blue-600 text-white'
|
? 'bg-blue-600 text-white'
|
||||||
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
|
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'}"
|
||||||
}"
|
|
||||||
>
|
>
|
||||||
Alle
|
Alle
|
||||||
</button>
|
</button>
|
||||||
{#each allTags as [tag, count] (tag)}
|
{#each allTags as [tag, count] (tag)}
|
||||||
<button
|
<button
|
||||||
onclick={() => selectHashtag(tag)}
|
onclick={() => selectHashtag(tag)}
|
||||||
class="shrink-0 rounded-full px-3 py-1 text-sm font-medium transition {
|
class="shrink-0 rounded-full px-3 py-1 text-sm font-medium transition {selectedHashtag ===
|
||||||
selectedHashtag === tag
|
tag
|
||||||
? 'bg-blue-600 text-white'
|
? 'bg-blue-600 text-white'
|
||||||
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
|
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'}"
|
||||||
}"
|
|
||||||
>
|
>
|
||||||
#{tag}
|
#{tag}
|
||||||
<span class="ml-1 text-xs opacity-70">{count}</span>
|
<span class="ml-1 text-xs opacity-70">{count}</span>
|
||||||
@@ -287,9 +322,21 @@
|
|||||||
{#if viewMode === 'grid'}
|
{#if viewMode === 'grid'}
|
||||||
<div class="mx-auto max-w-2xl px-4 pb-3">
|
<div class="mx-auto max-w-2xl px-4 pb-3">
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="flex items-center gap-2 rounded-xl border border-gray-200 bg-gray-50 px-3 py-2 focus-within:border-blue-400 focus-within:bg-white focus-within:ring-1 focus-within:ring-blue-200">
|
<div
|
||||||
<svg class="h-4 w-4 shrink-0 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
class="flex items-center gap-2 rounded-xl border border-gray-200 bg-gray-50 px-3 py-2 focus-within:border-blue-400 focus-within:bg-white focus-within:ring-1 focus-within:ring-blue-200"
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
|
>
|
||||||
|
<svg
|
||||||
|
class="h-4 w-4 shrink-0 text-gray-400"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<input
|
<input
|
||||||
type="search"
|
type="search"
|
||||||
@@ -301,11 +348,19 @@
|
|||||||
/>
|
/>
|
||||||
{#if searchQuery}
|
{#if searchQuery}
|
||||||
<button
|
<button
|
||||||
onclick={() => { searchQuery = ''; }}
|
onclick={() => {
|
||||||
|
searchQuery = '';
|
||||||
|
}}
|
||||||
class="shrink-0 text-gray-400 hover:text-gray-600"
|
class="shrink-0 text-gray-400 hover:text-gray-600"
|
||||||
aria-label="Suche loschen"
|
aria-label="Suche loschen"
|
||||||
>
|
>
|
||||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
<svg
|
||||||
|
class="h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
@@ -314,15 +369,27 @@
|
|||||||
|
|
||||||
<!-- Autocomplete dropdown -->
|
<!-- Autocomplete dropdown -->
|
||||||
{#if showAutocomplete && suggestions.length > 0}
|
{#if showAutocomplete && suggestions.length > 0}
|
||||||
<div class="absolute left-0 right-0 top-full z-50 mt-1 overflow-hidden rounded-xl border border-gray-200 bg-white shadow-lg">
|
<div
|
||||||
{#each suggestions as item}
|
class="absolute left-0 right-0 top-full z-50 mt-1 overflow-hidden rounded-xl border border-gray-200 bg-white shadow-lg"
|
||||||
|
>
|
||||||
|
{#each suggestions as item (item.type + ':' + item.value)}
|
||||||
<button
|
<button
|
||||||
class="flex w-full items-center gap-3 px-4 py-2.5 text-left text-sm hover:bg-gray-50"
|
class="flex w-full items-center gap-3 px-4 py-2.5 text-left text-sm hover:bg-gray-50"
|
||||||
onmousedown={() => selectSuggestion(item)}
|
onmousedown={() => selectSuggestion(item)}
|
||||||
>
|
>
|
||||||
{#if item.type === 'user'}
|
{#if item.type === 'user'}
|
||||||
<svg class="h-4 w-4 shrink-0 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
<svg
|
||||||
<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" />
|
class="h-4 w-4 shrink-0 text-gray-400"
|
||||||
|
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>
|
</svg>
|
||||||
<span class="font-medium text-gray-900">{item.value}</span>
|
<span class="font-medium text-gray-900">{item.value}</span>
|
||||||
{:else}
|
{:else}
|
||||||
@@ -338,12 +405,28 @@
|
|||||||
<!-- Active filter chips -->
|
<!-- Active filter chips -->
|
||||||
{#if activeFilters.length > 0}
|
{#if activeFilters.length > 0}
|
||||||
<div class="mt-2 flex flex-wrap items-center gap-1.5">
|
<div class="mt-2 flex flex-wrap items-center gap-1.5">
|
||||||
{#each activeFilters as filter}
|
{#each activeFilters as filter (filter.type + ':' + filter.value)}
|
||||||
<span class="flex items-center gap-1 rounded-full bg-blue-100 px-2.5 py-0.5 text-xs font-medium text-blue-700">
|
<span
|
||||||
|
class="flex items-center gap-1 rounded-full bg-blue-100 px-2.5 py-0.5 text-xs font-medium text-blue-700"
|
||||||
|
>
|
||||||
{filter.type === 'tag' ? '#' : ''}{filter.value}
|
{filter.type === 'tag' ? '#' : ''}{filter.value}
|
||||||
<button onclick={() => removeFilter(filter)} class="ml-0.5 hover:text-blue-900" aria-label="Filter entfernen">
|
<button
|
||||||
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
|
onclick={() => removeFilter(filter)}
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
|
class="ml-0.5 hover:text-blue-900"
|
||||||
|
aria-label="Filter entfernen"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="h-3 w-3"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2.5"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M6 18 18 6M6 6l12 12"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
@@ -364,7 +447,9 @@
|
|||||||
<div class="py-20 text-center">
|
<div class="py-20 text-center">
|
||||||
<p class="text-lg text-gray-400">Noch keine Fotos.</p>
|
<p class="text-lg text-gray-400">Noch keine Fotos.</p>
|
||||||
{#if activeFilters.length > 0}
|
{#if activeFilters.length > 0}
|
||||||
<button onclick={clearFilters} class="mt-2 text-sm text-blue-600 hover:underline">Filter zurucksetzen</button>
|
<button onclick={clearFilters} class="mt-2 text-sm text-blue-600 hover:underline"
|
||||||
|
>Filter zurucksetzen</button
|
||||||
|
>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else if viewMode === 'list'}
|
{:else if viewMode === 'list'}
|
||||||
@@ -395,10 +480,16 @@
|
|||||||
{#if post.media.type === 'video'}
|
{#if post.media.type === 'video'}
|
||||||
<div class="relative aspect-video w-full bg-gray-900">
|
<div class="relative aspect-video w-full bg-gray-900">
|
||||||
{#if post.media.thumb}
|
{#if post.media.thumb}
|
||||||
<img src={post.media.thumb} alt="" class="h-full w-full object-cover opacity-80" />
|
<img
|
||||||
|
src={post.media.thumb}
|
||||||
|
alt=""
|
||||||
|
class="h-full w-full object-cover opacity-80"
|
||||||
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="absolute inset-0 flex items-center justify-center">
|
<div class="absolute inset-0 flex items-center justify-center">
|
||||||
<span class="flex h-14 w-14 items-center justify-center rounded-full bg-black/50 text-white">
|
<span
|
||||||
|
class="flex h-14 w-14 items-center justify-center rounded-full bg-black/50 text-white"
|
||||||
|
>
|
||||||
<svg class="h-7 w-7 pl-0.5" fill="currentColor" viewBox="0 0 24 24">
|
<svg class="h-7 w-7 pl-0.5" fill="currentColor" viewBox="0 0 24 24">
|
||||||
<path d="M8 5v14l11-7z" />
|
<path d="M8 5v14l11-7z" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -415,8 +506,18 @@
|
|||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="flex aspect-square w-full items-center justify-center bg-gray-100">
|
<div class="flex aspect-square w-full items-center justify-center bg-gray-100">
|
||||||
<svg class="h-12 w-12 text-gray-300" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
class="h-12 w-12 text-gray-300"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="1.5"
|
||||||
|
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -425,14 +526,34 @@
|
|||||||
<!-- Stats row (read-only) -->
|
<!-- Stats row (read-only) -->
|
||||||
<div class="flex items-center gap-4 px-4 py-2">
|
<div class="flex items-center gap-4 px-4 py-2">
|
||||||
<span class="flex items-center gap-1.5 text-sm font-medium text-gray-500">
|
<span class="flex items-center gap-1.5 text-sm font-medium text-gray-500">
|
||||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
<svg
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
|
class="h-5 w-5"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{post.likes}
|
{post.likes}
|
||||||
</span>
|
</span>
|
||||||
<span class="flex items-center gap-1.5 text-sm font-medium text-gray-500">
|
<span class="flex items-center gap-1.5 text-sm font-medium text-gray-500">
|
||||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
<svg
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
class="h-5 w-5"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{post.comments.length}
|
{post.comments.length}
|
||||||
</span>
|
</span>
|
||||||
@@ -454,7 +575,9 @@
|
|||||||
<div class="mx-auto max-w-2xl">
|
<div class="mx-auto max-w-2xl">
|
||||||
<div class="grid grid-cols-3 gap-0.5">
|
<div class="grid grid-cols-3 gap-0.5">
|
||||||
{#each displayPosts as post (post.id)}
|
{#each displayPosts as post (post.id)}
|
||||||
<div class="group relative aspect-square cursor-pointer overflow-hidden rounded-lg bg-gray-100">
|
<div
|
||||||
|
class="group relative aspect-square cursor-pointer overflow-hidden rounded-lg bg-gray-100"
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
onclick={() => openLightbox(post)}
|
onclick={() => openLightbox(post)}
|
||||||
class="block h-full w-full"
|
class="block h-full w-full"
|
||||||
@@ -472,29 +595,51 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else if post.media.thumb}
|
{:else if post.media.thumb}
|
||||||
<img src={post.media.thumb} alt="" class="h-full w-full object-cover" loading="lazy" />
|
<img
|
||||||
|
src={post.media.thumb}
|
||||||
|
alt=""
|
||||||
|
class="h-full w-full object-cover"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="flex h-full items-center justify-center text-gray-400">
|
<div class="flex h-full items-center justify-center text-gray-400">
|
||||||
<svg class="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg class="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="1.5"
|
||||||
|
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Overlay with name and stats -->
|
<!-- Overlay with name and stats -->
|
||||||
<div class="pointer-events-none absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/60 to-transparent p-2">
|
<div
|
||||||
|
class="pointer-events-none absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/60 to-transparent p-2"
|
||||||
|
>
|
||||||
<p class="truncate text-xs font-medium text-white">{post.uploader}</p>
|
<p class="truncate text-xs font-medium text-white">{post.uploader}</p>
|
||||||
<div class="mt-0.5 flex items-center gap-3 text-xs text-white/80">
|
<div class="mt-0.5 flex items-center gap-3 text-xs text-white/80">
|
||||||
<span class="flex items-center gap-0.5">
|
<span class="flex items-center gap-0.5">
|
||||||
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{post.likes}
|
{post.likes}
|
||||||
</span>
|
</span>
|
||||||
<span class="flex items-center gap-0.5">
|
<span class="flex items-center gap-0.5">
|
||||||
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{post.comments.length}
|
{post.comments.length}
|
||||||
</span>
|
</span>
|
||||||
@@ -519,16 +664,28 @@
|
|||||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/80"
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black/80"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
onclick={(e) => { if (e.target === e.currentTarget) closeLightbox(); }}
|
onclick={(e) => {
|
||||||
|
if (e.target === e.currentTarget) closeLightbox();
|
||||||
|
}}
|
||||||
ontouchstart={handleTouchStart}
|
ontouchstart={handleTouchStart}
|
||||||
ontouchend={handleTouchEnd}
|
ontouchend={handleTouchEnd}
|
||||||
>
|
>
|
||||||
<div class="flex max-h-[95vh] w-full max-w-2xl flex-col overflow-hidden rounded-xl bg-white sm:m-4">
|
<div
|
||||||
|
class="flex max-h-[95vh] w-full max-w-2xl flex-col overflow-hidden rounded-xl bg-white sm:m-4"
|
||||||
|
>
|
||||||
<!-- Media -->
|
<!-- Media -->
|
||||||
<div class="relative bg-black">
|
<div class="relative bg-black">
|
||||||
<button onclick={closeLightbox} class="absolute right-2 top-2 z-10 rounded-full bg-black/50 p-1.5 text-white hover:bg-black/70">
|
<button
|
||||||
|
onclick={closeLightbox}
|
||||||
|
class="absolute right-2 top-2 z-10 rounded-full bg-black/50 p-1.5 text-white hover:bg-black/70"
|
||||||
|
>
|
||||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M6 18L18 6M6 6l12 12"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -539,8 +696,18 @@
|
|||||||
class="absolute left-2 top-1/2 z-10 -translate-y-1/2 rounded-full bg-black/50 p-1.5 text-white hover:bg-black/70"
|
class="absolute left-2 top-1/2 z-10 -translate-y-1/2 rounded-full bg-black/50 p-1.5 text-white hover:bg-black/70"
|
||||||
aria-label="Vorheriges"
|
aria-label="Vorheriges"
|
||||||
>
|
>
|
||||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
<svg
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5 8.25 12l7.5-7.5" />
|
class="h-5 w-5"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M15.75 19.5 8.25 12l7.5-7.5"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@@ -548,8 +715,18 @@
|
|||||||
class="absolute right-2 top-1/2 z-10 -translate-y-1/2 rounded-full bg-black/50 p-1.5 text-white hover:bg-black/70"
|
class="absolute right-2 top-1/2 z-10 -translate-y-1/2 rounded-full bg-black/50 p-1.5 text-white hover:bg-black/70"
|
||||||
aria-label="Nachstes"
|
aria-label="Nachstes"
|
||||||
>
|
>
|
||||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
<svg
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
|
class="h-5 w-5"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="m8.25 4.5 7.5 7.5-7.5 7.5"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -562,11 +739,7 @@
|
|||||||
poster={selectedPost.media.thumb || undefined}
|
poster={selectedPost.media.thumb || undefined}
|
||||||
></video>
|
></video>
|
||||||
{:else}
|
{:else}
|
||||||
<img
|
<img src={selectedPost.media.full} alt="" class="max-h-[50vh] w-full object-contain" />
|
||||||
src={selectedPost.media.full}
|
|
||||||
alt=""
|
|
||||||
class="max-h-[50vh] w-full object-contain"
|
|
||||||
/>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -576,17 +749,28 @@
|
|||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<span class="font-medium text-gray-900">{selectedPost.uploader}</span>
|
<span class="font-medium text-gray-900">{selectedPost.uploader}</span>
|
||||||
<span class="ml-2 text-xs text-gray-400">{formatShortDate(selectedPost.timestamp)}</span>
|
<span class="ml-2 text-xs text-gray-400"
|
||||||
|
>{formatShortDate(selectedPost.timestamp)}</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
<span class="flex items-center gap-1 rounded-full bg-gray-100 px-2.5 py-1 text-sm text-gray-600">
|
<span
|
||||||
|
class="flex items-center gap-1 rounded-full bg-gray-100 px-2.5 py-1 text-sm text-gray-600"
|
||||||
|
>
|
||||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{selectedPost.likes}
|
{selectedPost.likes}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{#if selectedPost.caption}
|
{#if selectedPost.caption}
|
||||||
<p class="mt-1 text-sm text-gray-700 [overflow-wrap:anywhere]">{selectedPost.caption}</p>
|
<p class="mt-1 text-sm text-gray-700 [overflow-wrap:anywhere]">
|
||||||
|
{selectedPost.caption}
|
||||||
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -596,11 +780,13 @@
|
|||||||
<p class="text-center text-sm text-gray-400">Keine Kommentare.</p>
|
<p class="text-center text-sm text-gray-400">Keine Kommentare.</p>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
{#each selectedPost.comments as comment}
|
{#each selectedPost.comments as comment, i (i)}
|
||||||
<div>
|
<div>
|
||||||
<span class="text-sm font-medium text-gray-900">{comment.author}</span>
|
<span class="text-sm font-medium text-gray-900">{comment.author}</span>
|
||||||
<span class="ml-1 text-sm text-gray-700">{comment.text}</span>
|
<span class="ml-1 text-sm text-gray-700">{comment.text}</span>
|
||||||
<div class="mt-0.5 text-xs text-gray-400">{formatShortDate(comment.timestamp)}</div>
|
<div class="mt-0.5 text-xs text-gray-400">
|
||||||
|
{formatShortDate(comment.timestamp)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
1642
frontend/package-lock.json
generated
1642
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -11,20 +11,33 @@
|
|||||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||||
"test:unit": "vitest run",
|
"test:unit": "vitest run",
|
||||||
"test:unit:watch": "vitest"
|
"test:unit:watch": "vitest",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"lint:fix": "eslint . --fix",
|
||||||
|
"format": "prettier --write .",
|
||||||
|
"format:check": "prettier --check ."
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.39.5",
|
||||||
"@sveltejs/adapter-auto": "^7.0.0",
|
"@sveltejs/adapter-auto": "^7.0.0",
|
||||||
"@sveltejs/adapter-node": "^5.5.4",
|
"@sveltejs/adapter-node": "^5.5.4",
|
||||||
"@sveltejs/kit": "^2.50.2",
|
"@sveltejs/kit": "^2.50.2",
|
||||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||||
"@tailwindcss/vite": "^4.2.2",
|
"@tailwindcss/vite": "^4.2.2",
|
||||||
"@types/qrcode": "^1.5.6",
|
"@types/qrcode": "^1.5.6",
|
||||||
|
"eslint": "^9.39.5",
|
||||||
|
"eslint-config-prettier": "^9.1.2",
|
||||||
|
"eslint-plugin-svelte": "^3.20.0",
|
||||||
|
"globals": "^15.15.0",
|
||||||
"jsdom": "^29.1.1",
|
"jsdom": "^29.1.1",
|
||||||
|
"prettier": "^3.9.5",
|
||||||
|
"prettier-plugin-svelte": "^3.5.2",
|
||||||
"svelte": "^5.54.0",
|
"svelte": "^5.54.0",
|
||||||
"svelte-check": "^4.4.2",
|
"svelte-check": "^4.4.2",
|
||||||
|
"svelte-eslint-parser": "^1.8.0",
|
||||||
"tailwindcss": "^4.2.2",
|
"tailwindcss": "^4.2.2",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
|
"typescript-eslint": "^8.64.0",
|
||||||
"vite": "^7.3.1",
|
"vite": "^7.3.1",
|
||||||
"vitest": "^4.1.9"
|
"vitest": "^4.1.9"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
@import "./tailwind-theme.css";
|
@import './tailwind-theme.css';
|
||||||
|
|
||||||
/* Respect the OS "reduce motion" setting. Collapses every animation/transition
|
/* Respect the OS "reduce motion" setting. Collapses every animation/transition
|
||||||
* (HeartBurst, Skeleton pulse, diashow cross-fades, sheet/FAB slides) to a
|
* (HeartBurst, Skeleton pulse, diashow cross-fades, sheet/FAB slides) to a
|
||||||
|
|||||||
@@ -28,7 +28,8 @@
|
|||||||
(function () {
|
(function () {
|
||||||
try {
|
try {
|
||||||
var pref = localStorage.getItem('eventsnap_theme') || 'system';
|
var pref = localStorage.getItem('eventsnap_theme') || 'system';
|
||||||
var dark = pref === 'dark' ||
|
var dark =
|
||||||
|
pref === 'dark' ||
|
||||||
(pref === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
(pref === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||||
if (dark) document.documentElement.classList.add('dark');
|
if (dark) document.documentElement.classList.add('dark');
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
Short rules. The patterns we already follow as of v0.16 — write new code that fits.
|
Short rules. The patterns we already follow as of v0.16 — write new code that fits.
|
||||||
|
|
||||||
**One store per cross-cutting concern.** A single `*-store.ts` file owns each one:
|
**One store per cross-cutting concern.** A single `*-store.ts` file owns each one:
|
||||||
|
|
||||||
- `auth.ts` — JWT / PIN in `localStorage`, `isAuthenticated` writable
|
- `auth.ts` — JWT / PIN in `localStorage`, `isAuthenticated` writable
|
||||||
- `ui-store.ts` — bottom-nav visibility, upload-sheet open state, FAB badge count
|
- `ui-store.ts` — bottom-nav visibility, upload-sheet open state, FAB badge count
|
||||||
- `data-mode-store.ts` — Saver vs Original media-loading preference
|
- `data-mode-store.ts` — Saver vs Original media-loading preference
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ export interface DoubletapOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface DoubletapAttributes {
|
interface DoubletapAttributes {
|
||||||
'ondoubletap'?: (event: CustomEvent<void>) => void;
|
ondoubletap?: (event: CustomEvent<void>) => void;
|
||||||
'onsingletap'?: (event: CustomEvent<void>) => void;
|
onsingletap?: (event: CustomEvent<void>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function doubletap(
|
export function doubletap(
|
||||||
|
|||||||
@@ -30,7 +30,9 @@ export function focusTrap(
|
|||||||
options: FocusTrapOptions = {}
|
options: FocusTrapOptions = {}
|
||||||
): ActionReturn<FocusTrapOptions> {
|
): ActionReturn<FocusTrapOptions> {
|
||||||
let opts = options;
|
let opts = options;
|
||||||
const previouslyFocused = (typeof document !== 'undefined' ? document.activeElement : null) as HTMLElement | null;
|
const previouslyFocused = (
|
||||||
|
typeof document !== 'undefined' ? document.activeElement : null
|
||||||
|
) as HTMLElement | null;
|
||||||
|
|
||||||
function onKeyDown(e: KeyboardEvent) {
|
function onKeyDown(e: KeyboardEvent) {
|
||||||
if (e.key === 'Escape' && opts.closeOnEscape !== false && opts.onclose) {
|
if (e.key === 'Escape' && opts.closeOnEscape !== false && opts.onclose) {
|
||||||
@@ -85,7 +87,11 @@ export function focusTrap(
|
|||||||
destroy() {
|
destroy() {
|
||||||
node.removeEventListener('keydown', onKeyDown);
|
node.removeEventListener('keydown', onKeyDown);
|
||||||
if (previouslyFocused && typeof previouslyFocused.focus === 'function') {
|
if (previouslyFocused && typeof previouslyFocused.focus === 'function') {
|
||||||
try { previouslyFocused.focus({ preventScroll: true }); } catch { /* element may have unmounted */ }
|
try {
|
||||||
|
previouslyFocused.focus({ preventScroll: true });
|
||||||
|
} catch {
|
||||||
|
/* element may have unmounted */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export interface LongpressOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface LongpressAttributes {
|
interface LongpressAttributes {
|
||||||
'onlongpress'?: (event: CustomEvent<void>) => void;
|
onlongpress?: (event: CustomEvent<void>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function longpress(
|
export function longpress(
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ function unlock() {
|
|||||||
* dialog is open (e.g. inside `{#if open}` or alongside a `class:` toggle) so the
|
* dialog is open (e.g. inside `{#if open}` or alongside a `class:` toggle) so the
|
||||||
* action's create/destroy line up with open/close.
|
* action's create/destroy line up with open/close.
|
||||||
*/
|
*/
|
||||||
export function scrollLock(node: HTMLElement): ActionReturn {
|
export function scrollLock(_node: HTMLElement): ActionReturn {
|
||||||
lock();
|
lock();
|
||||||
return {
|
return {
|
||||||
destroy() {
|
destroy() {
|
||||||
|
|||||||
@@ -15,11 +15,7 @@ export class ApiError extends Error {
|
|||||||
|
|
||||||
const TIMEOUT_MS = 20_000;
|
const TIMEOUT_MS = 20_000;
|
||||||
|
|
||||||
async function request<T>(
|
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||||
method: string,
|
|
||||||
path: string,
|
|
||||||
body?: unknown
|
|
||||||
): Promise<T> {
|
|
||||||
const headers: Record<string, string> = {};
|
const headers: Record<string, string> = {};
|
||||||
const token = getToken();
|
const token = getToken();
|
||||||
if (token) {
|
if (token) {
|
||||||
@@ -75,7 +71,11 @@ async function request<T>(
|
|||||||
clearAuth();
|
clearAuth();
|
||||||
}
|
}
|
||||||
const d = (data ?? {}) as { error?: string; message?: string };
|
const d = (data ?? {}) as { error?: string; message?: string };
|
||||||
throw new ApiError(res.status, d.error ?? 'unknown', d.message ?? `Serverfehler (${res.status}).`);
|
throw new ApiError(
|
||||||
|
res.status,
|
||||||
|
d.error ?? 'unknown',
|
||||||
|
d.message ?? `Serverfehler (${res.status}).`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return data as T;
|
return data as T;
|
||||||
|
|||||||
@@ -3,100 +3,115 @@ import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|||||||
|
|
||||||
// auth.ts guards every localStorage access behind `browser`; force it true so the
|
// auth.ts guards every localStorage access behind `browser`; force it true so the
|
||||||
// jsdom localStorage is actually used (the shared mock stubs it to false).
|
// jsdom localStorage is actually used (the shared mock stubs it to false).
|
||||||
vi.mock('$app/environment', () => ({ browser: true, dev: false, building: false, version: 'test' }));
|
vi.mock('$app/environment', () => ({
|
||||||
|
browser: true,
|
||||||
|
dev: false,
|
||||||
|
building: false,
|
||||||
|
version: 'test'
|
||||||
|
}));
|
||||||
|
|
||||||
import { setAuth, setAdminAuth, getToken, getPin, getExpiry, getRole, getUserId, clearAuth, clearPin } from './auth';
|
import {
|
||||||
|
setAuth,
|
||||||
|
setAdminAuth,
|
||||||
|
getToken,
|
||||||
|
getPin,
|
||||||
|
getExpiry,
|
||||||
|
getRole,
|
||||||
|
getUserId,
|
||||||
|
clearAuth,
|
||||||
|
clearPin
|
||||||
|
} from './auth';
|
||||||
|
|
||||||
/** Build a JWT-shaped string (header.payload.sig) with the given claims. */
|
/** Build a JWT-shaped string (header.payload.sig) with the given claims. */
|
||||||
function makeJwt(claims: object): string {
|
function makeJwt(claims: object): string {
|
||||||
const seg = (o: object) => btoa(JSON.stringify(o));
|
const seg = (o: object) => btoa(JSON.stringify(o));
|
||||||
return `${seg({ alg: 'HS256', typ: 'JWT' })}.${seg(claims)}.sig`;
|
return `${seg({ alg: 'HS256', typ: 'JWT' })}.${seg(claims)}.sig`;
|
||||||
}
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
sessionStorage.clear();
|
sessionStorage.clear();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('auth — token storage', () => {
|
describe('auth — token storage', () => {
|
||||||
it('setAuth then getToken/getPin round-trips', () => {
|
it('setAuth then getToken/getPin round-trips', () => {
|
||||||
setAuth('a.b.c', '1234', 'uid-1', 'Alice');
|
setAuth('a.b.c', '1234', 'uid-1', 'Alice');
|
||||||
expect(getToken()).toBe('a.b.c');
|
expect(getToken()).toBe('a.b.c');
|
||||||
expect(getPin()).toBe('1234');
|
expect(getPin()).toBe('1234');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('setAuth without a PIN leaves the PIN unset', () => {
|
it('setAuth without a PIN leaves the PIN unset', () => {
|
||||||
setAuth('a.b.c', null, 'uid-1');
|
setAuth('a.b.c', null, 'uid-1');
|
||||||
expect(getToken()).toBe('a.b.c');
|
expect(getToken()).toBe('a.b.c');
|
||||||
expect(getPin()).toBeNull();
|
expect(getPin()).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('clearAuth removes the token but KEEPS the PIN (needed for recovery)', () => {
|
it('clearAuth removes the token but KEEPS the PIN (needed for recovery)', () => {
|
||||||
setAuth('a.b.c', '1234', 'uid');
|
setAuth('a.b.c', '1234', 'uid');
|
||||||
clearAuth();
|
clearAuth();
|
||||||
expect(getToken()).toBeNull();
|
expect(getToken()).toBeNull();
|
||||||
expect(getPin()).toBe('1234');
|
expect(getPin()).toBe('1234');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('clearPin removes the cached PIN', () => {
|
it('clearPin removes the cached PIN', () => {
|
||||||
setAuth('a.b.c', '1234', 'uid');
|
setAuth('a.b.c', '1234', 'uid');
|
||||||
clearPin();
|
clearPin();
|
||||||
expect(getPin()).toBeNull();
|
expect(getPin()).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('auth — admin (sessionStorage) vs guest (localStorage) identity displacement', () => {
|
describe('auth — admin (sessionStorage) vs guest (localStorage) identity displacement', () => {
|
||||||
// Regression guard for a privilege-escalation: reads are sessionStorage-first, so a guest
|
// Regression guard for a privilege-escalation: reads are sessionStorage-first, so a guest
|
||||||
// login MUST displace any resident admin token (else the guest silently runs as admin on a
|
// login MUST displace any resident admin token (else the guest silently runs as admin on a
|
||||||
// shared device), and symmetrically an admin login must displace a resident guest token.
|
// shared device), and symmetrically an admin login must displace a resident guest token.
|
||||||
it('a guest login displaces a resident admin session (no privilege escalation)', () => {
|
it('a guest login displaces a resident admin session (no privilege escalation)', () => {
|
||||||
setAdminAuth(makeJwt({ role: 'admin' }), 'admin-uid', 'Admin');
|
setAdminAuth(makeJwt({ role: 'admin' }), 'admin-uid', 'Admin');
|
||||||
expect(getRole()).toBe('admin');
|
expect(getRole()).toBe('admin');
|
||||||
|
|
||||||
setAuth(makeJwt({ role: 'guest' }), '1234', 'guest-uid', 'Guest');
|
setAuth(makeJwt({ role: 'guest' }), '1234', 'guest-uid', 'Guest');
|
||||||
expect(getRole()).toBe('guest'); // NOT admin
|
expect(getRole()).toBe('guest'); // NOT admin
|
||||||
expect(getUserId()).toBe('guest-uid');
|
expect(getUserId()).toBe('guest-uid');
|
||||||
expect(sessionStorage.getItem('eventsnap_jwt')).toBeNull(); // admin token cleared
|
expect(sessionStorage.getItem('eventsnap_jwt')).toBeNull(); // admin token cleared
|
||||||
});
|
});
|
||||||
|
|
||||||
it('an admin login displaces a resident guest session but keeps the guest PIN', () => {
|
it('an admin login displaces a resident guest session but keeps the guest PIN', () => {
|
||||||
setAuth(makeJwt({ role: 'guest' }), '1234', 'guest-uid', 'Guest');
|
setAuth(makeJwt({ role: 'guest' }), '1234', 'guest-uid', 'Guest');
|
||||||
setAdminAuth(makeJwt({ role: 'admin' }), 'admin-uid', 'Admin');
|
setAdminAuth(makeJwt({ role: 'admin' }), 'admin-uid', 'Admin');
|
||||||
expect(getRole()).toBe('admin');
|
expect(getRole()).toBe('admin');
|
||||||
expect(getUserId()).toBe('admin-uid');
|
expect(getUserId()).toBe('admin-uid');
|
||||||
expect(localStorage.getItem('eventsnap_jwt')).toBeNull(); // guest token cleared
|
expect(localStorage.getItem('eventsnap_jwt')).toBeNull(); // guest token cleared
|
||||||
expect(getPin()).toBe('1234'); // PIN preserved for later recovery
|
expect(getPin()).toBe('1234'); // PIN preserved for later recovery
|
||||||
});
|
});
|
||||||
|
|
||||||
it('clearAuth wipes both stores', () => {
|
it('clearAuth wipes both stores', () => {
|
||||||
setAdminAuth(makeJwt({ role: 'admin' }), 'admin-uid', 'Admin');
|
setAdminAuth(makeJwt({ role: 'admin' }), 'admin-uid', 'Admin');
|
||||||
clearAuth();
|
clearAuth();
|
||||||
expect(getToken()).toBeNull();
|
expect(getToken()).toBeNull();
|
||||||
expect(sessionStorage.getItem('eventsnap_jwt')).toBeNull();
|
expect(sessionStorage.getItem('eventsnap_jwt')).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('auth — JWT claim decode', () => {
|
describe('auth — JWT claim decode', () => {
|
||||||
it('getExpiry decodes the exp claim (seconds → ms Date)', () => {
|
it('getExpiry decodes the exp claim (seconds → ms Date)', () => {
|
||||||
const exp = 2_000_000_000; // far-future unix seconds
|
const exp = 2_000_000_000; // far-future unix seconds
|
||||||
setAuth(makeJwt({ exp }), null, 'u');
|
setAuth(makeJwt({ exp }), null, 'u');
|
||||||
expect(getExpiry()?.getTime()).toBe(exp * 1000);
|
expect(getExpiry()?.getTime()).toBe(exp * 1000);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('getExpiry is null with no token, no exp claim, or a malformed token', () => {
|
it('getExpiry is null with no token, no exp claim, or a malformed token', () => {
|
||||||
expect(getExpiry()).toBeNull(); // no token
|
expect(getExpiry()).toBeNull(); // no token
|
||||||
setAuth(makeJwt({ role: 'guest' }), null, 'u'); // token without exp
|
setAuth(makeJwt({ role: 'guest' }), null, 'u'); // token without exp
|
||||||
expect(getExpiry()).toBeNull();
|
expect(getExpiry()).toBeNull();
|
||||||
localStorage.setItem('eventsnap_jwt', 'not-a-jwt'); // unparseable
|
localStorage.setItem('eventsnap_jwt', 'not-a-jwt'); // unparseable
|
||||||
expect(getExpiry()).toBeNull();
|
expect(getExpiry()).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('getRole extracts the role claim; null when absent or malformed', () => {
|
it('getRole extracts the role claim; null when absent or malformed', () => {
|
||||||
setAuth(makeJwt({ role: 'host' }), null, 'u');
|
setAuth(makeJwt({ role: 'host' }), null, 'u');
|
||||||
expect(getRole()).toBe('host');
|
expect(getRole()).toBe('host');
|
||||||
setAuth(makeJwt({ exp: 1 }), null, 'u'); // no role claim
|
setAuth(makeJwt({ exp: 1 }), null, 'u'); // no role claim
|
||||||
expect(getRole()).toBeNull();
|
expect(getRole()).toBeNull();
|
||||||
localStorage.setItem('eventsnap_jwt', 'x.y.z'); // unparseable payload
|
localStorage.setItem('eventsnap_jwt', 'x.y.z'); // unparseable payload
|
||||||
expect(getRole()).toBeNull();
|
expect(getRole()).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -65,7 +65,12 @@ export function getExpiry(): Date | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setAuth(jwt: string, pin: string | null, userId: string, displayName?: string): void {
|
export function setAuth(
|
||||||
|
jwt: string,
|
||||||
|
pin: string | null,
|
||||||
|
userId: string,
|
||||||
|
displayName?: string
|
||||||
|
): void {
|
||||||
if (!browser) return;
|
if (!browser) return;
|
||||||
// DISPLACE any resident admin session: reads are sessionStorage-first, so a leftover
|
// DISPLACE any resident admin session: reads are sessionStorage-first, so a leftover
|
||||||
// admin token there would otherwise shadow this guest login and hand the guest admin
|
// admin token there would otherwise shadow this guest login and hand the guest admin
|
||||||
@@ -130,7 +135,11 @@ export function clearAuth(): void {
|
|||||||
// if you ever need ordering, introduce a priority field rather than relying
|
// if you ever need ordering, introduce a priority field rather than relying
|
||||||
// on import-load timing, which is fragile across refactors.
|
// on import-load timing, which is fragile across refactors.
|
||||||
for (const fn of clearAuthHooks) {
|
for (const fn of clearAuthHooks) {
|
||||||
try { fn(); } catch { /* hook failure is non-fatal */ }
|
try {
|
||||||
|
fn();
|
||||||
|
} catch {
|
||||||
|
/* hook failure is non-fatal */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,62 +2,79 @@ import { describe, it, expect } from 'vitest';
|
|||||||
import { avatarPalette, initials } from './avatar';
|
import { avatarPalette, initials } from './avatar';
|
||||||
|
|
||||||
describe('avatarPalette', () => {
|
describe('avatarPalette', () => {
|
||||||
it('returns the neutral palette for empty / nullish names', () => {
|
it('returns the neutral palette for empty / nullish names', () => {
|
||||||
expect(avatarPalette(null)).toContain('bg-gray-100');
|
expect(avatarPalette(null)).toContain('bg-gray-100');
|
||||||
expect(avatarPalette(undefined)).toContain('bg-gray-100');
|
expect(avatarPalette(undefined)).toContain('bg-gray-100');
|
||||||
expect(avatarPalette('')).toContain('bg-gray-100');
|
expect(avatarPalette('')).toContain('bg-gray-100');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('is deterministic for the same name', () => {
|
it('is deterministic for the same name', () => {
|
||||||
// Same input, repeated calls AND a separately-constructed equal string — the palette
|
// Same input, repeated calls AND a separately-constructed equal string — the palette
|
||||||
// must be a pure function of the name's characters, not of identity or call order.
|
// must be a pure function of the name's characters, not of identity or call order.
|
||||||
expect(avatarPalette('Alice')).toBe(avatarPalette('Alice'));
|
expect(avatarPalette('Alice')).toBe(avatarPalette('Alice'));
|
||||||
expect(avatarPalette('Alice')).toBe(avatarPalette('Ali' + 'ce'));
|
expect(avatarPalette('Alice')).toBe(avatarPalette('Ali' + 'ce'));
|
||||||
expect(avatarPalette('Zoë Müller')).toBe(avatarPalette('Zoë Müller'));
|
expect(avatarPalette('Zoë Müller')).toBe(avatarPalette('Zoë Müller'));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns a real palette entry (not neutral) for a non-empty name', () => {
|
it('returns a real palette entry (not neutral) for a non-empty name', () => {
|
||||||
expect(avatarPalette('Bob')).toMatch(/bg-(blue|purple|green|amber|rose|teal)-100/);
|
expect(avatarPalette('Bob')).toMatch(/bg-(blue|purple|green|amber|rose|teal)-100/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('maps different names to different palette entries', () => {
|
it('maps different names to different palette entries', () => {
|
||||||
// Without this, `name => name ? PALETTE[0] : NEUTRAL` (i.e. the hash loop deleted)
|
// Without this, `name => name ? PALETTE[0] : NEUTRAL` (i.e. the hash loop deleted)
|
||||||
// passes every other test in this file — the palette would be a constant and every
|
// passes every other test in this file — the palette would be a constant and every
|
||||||
// avatar in the app would render the same colour.
|
// avatar in the app would render the same colour.
|
||||||
expect(avatarPalette('Alice')).not.toBe(avatarPalette('Bob'));
|
expect(avatarPalette('Alice')).not.toBe(avatarPalette('Bob'));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('spreads names across the whole palette, not just one bucket', () => {
|
it('spreads names across the whole palette, not just one bucket', () => {
|
||||||
const names = [
|
const names = [
|
||||||
'Alice', 'Bob', 'Carol', 'Dave', 'Erin', 'Frank', 'Grace', 'Heidi',
|
'Alice',
|
||||||
'Ivan', 'Judy', 'Mallory', 'Niaj', 'Olivia', 'Peggy', 'Rupert', 'Sybil',
|
'Bob',
|
||||||
'Trent', 'Victor', 'Walter', 'Xena'
|
'Carol',
|
||||||
];
|
'Dave',
|
||||||
const distinct = new Set(names.map((n) => avatarPalette(n)));
|
'Erin',
|
||||||
// 6 colours in the palette; 20 names must land on more than a couple of them. This
|
'Frank',
|
||||||
// catches a hash that collapses (e.g. always returns index 0, or ignores all but the
|
'Grace',
|
||||||
// first character in a way that clusters).
|
'Heidi',
|
||||||
expect(distinct.size).toBeGreaterThanOrEqual(4);
|
'Ivan',
|
||||||
});
|
'Judy',
|
||||||
|
'Mallory',
|
||||||
|
'Niaj',
|
||||||
|
'Olivia',
|
||||||
|
'Peggy',
|
||||||
|
'Rupert',
|
||||||
|
'Sybil',
|
||||||
|
'Trent',
|
||||||
|
'Victor',
|
||||||
|
'Walter',
|
||||||
|
'Xena'
|
||||||
|
];
|
||||||
|
const distinct = new Set(names.map((n) => avatarPalette(n)));
|
||||||
|
// 6 colours in the palette; 20 names must land on more than a couple of them. This
|
||||||
|
// catches a hash that collapses (e.g. always returns index 0, or ignores all but the
|
||||||
|
// first character in a way that clusters).
|
||||||
|
expect(distinct.size).toBeGreaterThanOrEqual(4);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('initials', () => {
|
describe('initials', () => {
|
||||||
it('returns "?" for empty / nullish / whitespace-only names', () => {
|
it('returns "?" for empty / nullish / whitespace-only names', () => {
|
||||||
expect(initials(null)).toBe('?');
|
expect(initials(null)).toBe('?');
|
||||||
expect(initials(undefined)).toBe('?');
|
expect(initials(undefined)).toBe('?');
|
||||||
expect(initials('')).toBe('?');
|
expect(initials('')).toBe('?');
|
||||||
expect(initials(' ')).toBe('?');
|
expect(initials(' ')).toBe('?');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses the first letter (uppercased) for a single word', () => {
|
it('uses the first letter (uppercased) for a single word', () => {
|
||||||
expect(initials('alice')).toBe('A');
|
expect(initials('alice')).toBe('A');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses the first letters of the first two words', () => {
|
it('uses the first letters of the first two words', () => {
|
||||||
expect(initials('Alice Bob Carol')).toBe('AB');
|
expect(initials('Alice Bob Carol')).toBe('AB');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('collapses runs of whitespace between words', () => {
|
it('collapses runs of whitespace between words', () => {
|
||||||
expect(initials(' john doe ')).toBe('JD');
|
expect(initials(' john doe ')).toBe('JD');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -30,11 +30,17 @@
|
|||||||
<a
|
<a
|
||||||
href="/feed"
|
href="/feed"
|
||||||
class="flex flex-col items-center gap-0.5 px-4 py-1 text-xs font-medium transition-colors
|
class="flex flex-col items-center gap-0.5 px-4 py-1 text-xs font-medium transition-colors
|
||||||
{isActive('/feed') ? 'text-blue-600 dark:text-blue-400' : 'text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300'}"
|
{isActive('/feed')
|
||||||
|
? 'text-blue-600 dark:text-blue-400'
|
||||||
|
: 'text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300'}"
|
||||||
aria-label="Galerie"
|
aria-label="Galerie"
|
||||||
>
|
>
|
||||||
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
<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="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Zm10.5-11.25h.008v.008h-.008V8.25Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z" />
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Zm10.5-11.25h.008v.008h-.008V8.25Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<span>Galerie</span>
|
<span>Galerie</span>
|
||||||
</a>
|
</a>
|
||||||
@@ -47,9 +53,23 @@
|
|||||||
aria-label="Hochladen"
|
aria-label="Hochladen"
|
||||||
>
|
>
|
||||||
<!-- Camera + plus icon -->
|
<!-- Camera + plus icon -->
|
||||||
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
<svg
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6.827 6.175A2.31 2.31 0 0 1 5.186 7.23c-.38.054-.757.112-1.134.175C2.999 7.58 2.25 8.507 2.25 9.574V18a2.25 2.25 0 0 0 2.25 2.25h15A2.25 2.25 0 0 0 21.75 18V9.574c0-1.067-.75-1.994-1.802-2.169a47.865 47.865 0 0 0-1.134-.175 2.31 2.31 0 0 1-1.64-1.055l-.822-1.316a2.192 2.192 0 0 0-1.736-1.039 48.774 48.774 0 0 0-5.232 0 2.192 2.192 0 0 0-1.736 1.039l-.821 1.316Z" />
|
class="h-6 w-6"
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M16.5 12.75a4.5 4.5 0 1 1-9 0 4.5 4.5 0 0 1 9 0ZM18.75 10.5h.008v.008h-.008V10.5Z" />
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M6.827 6.175A2.31 2.31 0 0 1 5.186 7.23c-.38.054-.757.112-1.134.175C2.999 7.58 2.25 8.507 2.25 9.574V18a2.25 2.25 0 0 0 2.25 2.25h15A2.25 2.25 0 0 0 21.75 18V9.574c0-1.067-.75-1.994-1.802-2.169a47.865 47.865 0 0 0-1.134-.175 2.31 2.31 0 0 1-1.64-1.055l-.822-1.316a2.192 2.192 0 0 0-1.736-1.039 48.774 48.774 0 0 0-5.232 0 2.192 2.192 0 0 0-1.736 1.039l-.821 1.316Z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M16.5 12.75a4.5 4.5 0 1 1-9 0 4.5 4.5 0 0 1 9 0ZM18.75 10.5h.008v.008h-.008V10.5Z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<!-- Badge -->
|
<!-- Badge -->
|
||||||
{#if $uploadBadgeCount > 0}
|
{#if $uploadBadgeCount > 0}
|
||||||
@@ -68,15 +88,28 @@
|
|||||||
href="/export"
|
href="/export"
|
||||||
data-testid="bottom-nav-export"
|
data-testid="bottom-nav-export"
|
||||||
class="relative flex flex-col items-center gap-0.5 px-4 py-1 text-xs font-medium transition-colors
|
class="relative flex flex-col items-center gap-0.5 px-4 py-1 text-xs font-medium transition-colors
|
||||||
{isActive('/export') ? 'text-blue-600 dark:text-blue-400' : 'text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300'}"
|
{isActive('/export')
|
||||||
|
? 'text-blue-600 dark:text-blue-400'
|
||||||
|
: 'text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300'}"
|
||||||
aria-label="Export"
|
aria-label="Export"
|
||||||
>
|
>
|
||||||
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
<svg
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" />
|
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="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<span>Export</span>
|
<span>Export</span>
|
||||||
{#if zipReady}
|
{#if zipReady}
|
||||||
<span class="absolute right-2 top-0 h-2 w-2 rounded-full bg-blue-500" aria-hidden="true"></span>
|
<span class="absolute right-2 top-0 h-2 w-2 rounded-full bg-blue-500" aria-hidden="true"
|
||||||
|
></span>
|
||||||
{/if}
|
{/if}
|
||||||
</a>
|
</a>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -85,11 +118,17 @@
|
|||||||
<a
|
<a
|
||||||
href="/account"
|
href="/account"
|
||||||
class="flex flex-col items-center gap-0.5 px-4 py-1 text-xs font-medium transition-colors
|
class="flex flex-col items-center gap-0.5 px-4 py-1 text-xs font-medium transition-colors
|
||||||
{isActive('/account') ? 'text-blue-600 dark:text-blue-400' : 'text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300'}"
|
{isActive('/account')
|
||||||
|
? 'text-blue-600 dark:text-blue-400'
|
||||||
|
: 'text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300'}"
|
||||||
aria-label="Konto"
|
aria-label="Konto"
|
||||||
>
|
>
|
||||||
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
<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" />
|
<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>
|
</svg>
|
||||||
<span>Konto</span>
|
<span>Konto</span>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -57,7 +57,8 @@
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof DOMException && err.name === 'NotAllowedError') {
|
if (err instanceof DOMException && err.name === 'NotAllowedError') {
|
||||||
error = 'Kamerazugriff wurde verweigert. Bitte erlaube den Zugriff in den Browsereinstellungen.';
|
error =
|
||||||
|
'Kamerazugriff wurde verweigert. Bitte erlaube den Zugriff in den Browsereinstellungen.';
|
||||||
} else if (err instanceof DOMException && err.name === 'NotFoundError') {
|
} else if (err instanceof DOMException && err.name === 'NotFoundError') {
|
||||||
error = 'Keine Kamera gefunden.';
|
error = 'Keine Kamera gefunden.';
|
||||||
} else {
|
} else {
|
||||||
@@ -162,8 +163,18 @@
|
|||||||
{#if error}
|
{#if error}
|
||||||
<div class="flex h-full items-center justify-center p-8">
|
<div class="flex h-full items-center justify-center p-8">
|
||||||
<div class="rounded-lg bg-gray-900 p-6 text-center">
|
<div class="rounded-lg bg-gray-900 p-6 text-center">
|
||||||
<svg class="mx-auto mb-3 h-12 w-12 text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15 10l-4 4m0-4l4 4m6-4a9 9 0 11-18 0 9 9 0 0118 0z" />
|
class="mx-auto mb-3 h-12 w-12 text-red-400"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="1.5"
|
||||||
|
d="M15 10l-4 4m0-4l4 4m6-4a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<p class="text-sm text-white">{error}</p>
|
<p class="text-sm text-white">{error}</p>
|
||||||
<div class="mt-4 flex justify-center gap-2">
|
<div class="mt-4 flex justify-center gap-2">
|
||||||
@@ -173,10 +184,7 @@
|
|||||||
>
|
>
|
||||||
Erneut versuchen
|
Erneut versuchen
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button onclick={onclose} class="rounded-lg bg-white/20 px-4 py-2 text-sm text-white">
|
||||||
onclick={onclose}
|
|
||||||
class="rounded-lg bg-white/20 px-4 py-2 text-sm text-white"
|
|
||||||
>
|
|
||||||
Schließen
|
Schließen
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -193,7 +201,9 @@
|
|||||||
></video>
|
></video>
|
||||||
|
|
||||||
{#if recording}
|
{#if recording}
|
||||||
<div class="absolute left-4 top-4 flex items-center gap-2 rounded-full bg-red-600 px-3 py-1">
|
<div
|
||||||
|
class="absolute left-4 top-4 flex items-center gap-2 rounded-full bg-red-600 px-3 py-1"
|
||||||
|
>
|
||||||
<div class="h-2 w-2 animate-pulse rounded-full bg-white"></div>
|
<div class="h-2 w-2 animate-pulse rounded-full bg-white"></div>
|
||||||
<span class="text-sm font-medium text-white">{formatRecordingTime(recordingTime)}</span>
|
<span class="text-sm font-medium text-white">{formatRecordingTime(recordingTime)}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -218,7 +228,9 @@
|
|||||||
aria-selected={mode === 'photo'}
|
aria-selected={mode === 'photo'}
|
||||||
onclick={() => setMode('photo')}
|
onclick={() => setMode('photo')}
|
||||||
data-testid="camera-mode-photo"
|
data-testid="camera-mode-photo"
|
||||||
class="rounded-full px-4 py-1 text-sm font-medium transition {mode === 'photo' ? 'bg-white text-gray-900' : 'text-white/70 hover:text-white active:text-white'}"
|
class="rounded-full px-4 py-1 text-sm font-medium transition {mode === 'photo'
|
||||||
|
? 'bg-white text-gray-900'
|
||||||
|
: 'text-white/70 hover:text-white active:text-white'}"
|
||||||
>
|
>
|
||||||
Foto
|
Foto
|
||||||
</button>
|
</button>
|
||||||
@@ -228,7 +240,9 @@
|
|||||||
aria-selected={mode === 'video'}
|
aria-selected={mode === 'video'}
|
||||||
onclick={() => setMode('video')}
|
onclick={() => setMode('video')}
|
||||||
data-testid="camera-mode-video"
|
data-testid="camera-mode-video"
|
||||||
class="rounded-full px-4 py-1 text-sm font-medium transition {mode === 'video' ? 'bg-white text-gray-900' : 'text-white/70 hover:text-white active:text-white'}"
|
class="rounded-full px-4 py-1 text-sm font-medium transition {mode === 'video'
|
||||||
|
? 'bg-white text-gray-900'
|
||||||
|
: 'text-white/70 hover:text-white active:text-white'}"
|
||||||
>
|
>
|
||||||
Video
|
Video
|
||||||
</button>
|
</button>
|
||||||
@@ -236,7 +250,10 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="flex items-center justify-center gap-8 bg-black/80 px-4 pt-4 pb-6" style="padding-bottom: calc(env(safe-area-inset-bottom) + 1.5rem)">
|
<div
|
||||||
|
class="flex items-center justify-center gap-8 bg-black/80 px-4 pt-4 pb-6"
|
||||||
|
style="padding-bottom: calc(env(safe-area-inset-bottom) + 1.5rem)"
|
||||||
|
>
|
||||||
<!-- Close -->
|
<!-- Close -->
|
||||||
<button
|
<button
|
||||||
onclick={onclose}
|
onclick={onclose}
|
||||||
@@ -244,7 +261,12 @@
|
|||||||
aria-label="Schließen"
|
aria-label="Schließen"
|
||||||
>
|
>
|
||||||
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M6 18L18 6M6 6l12 12"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -252,8 +274,14 @@
|
|||||||
<button
|
<button
|
||||||
onclick={handleShutter}
|
onclick={handleShutter}
|
||||||
data-testid="camera-shutter"
|
data-testid="camera-shutter"
|
||||||
class="flex h-16 w-16 items-center justify-center rounded-full border-4 border-white transition active:scale-95 {recording ? 'bg-red-600' : 'bg-white/20'}"
|
class="flex h-16 w-16 items-center justify-center rounded-full border-4 border-white transition active:scale-95 {recording
|
||||||
aria-label={mode === 'photo' ? 'Foto aufnehmen' : recording ? 'Aufnahme stoppen' : 'Video aufnehmen'}
|
? 'bg-red-600'
|
||||||
|
: 'bg-white/20'}"
|
||||||
|
aria-label={mode === 'photo'
|
||||||
|
? 'Foto aufnehmen'
|
||||||
|
: recording
|
||||||
|
? 'Aufnahme stoppen'
|
||||||
|
: 'Video aufnehmen'}
|
||||||
>
|
>
|
||||||
{#if mode === 'photo'}
|
{#if mode === 'photo'}
|
||||||
<div class="h-12 w-12 rounded-full bg-white"></div>
|
<div class="h-12 w-12 rounded-full bg-white"></div>
|
||||||
@@ -274,7 +302,12 @@
|
|||||||
aria-label="Kamera wechseln"
|
aria-label="Kamera wechseln"
|
||||||
>
|
>
|
||||||
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -80,12 +80,16 @@
|
|||||||
onclick={handleConfirm}
|
onclick={handleConfirm}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
data-testid="confirm-sheet-confirm"
|
data-testid="confirm-sheet-confirm"
|
||||||
class="mb-3 flex w-full items-center justify-center gap-2 rounded-xl py-3 text-sm font-semibold text-white transition disabled:opacity-60 {tone === 'danger'
|
class="mb-3 flex w-full items-center justify-center gap-2 rounded-xl py-3 text-sm font-semibold text-white transition disabled:opacity-60 {tone ===
|
||||||
|
'danger'
|
||||||
? 'bg-red-600 hover:bg-red-700 active:bg-red-700 dark:bg-red-500 dark:hover:bg-red-400 dark:active:bg-red-400'
|
? 'bg-red-600 hover:bg-red-700 active:bg-red-700 dark:bg-red-500 dark:hover:bg-red-400 dark:active:bg-red-400'
|
||||||
: 'bg-blue-600 hover:bg-blue-700 active:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-400 dark:active:bg-blue-400'}"
|
: 'bg-blue-600 hover:bg-blue-700 active:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-400 dark:active:bg-blue-400'}"
|
||||||
>
|
>
|
||||||
{#if busy}
|
{#if busy}
|
||||||
<span class="inline-block h-4 w-4 animate-spin rounded-full border-2 border-white/40 border-t-white" aria-hidden="true"></span>
|
<span
|
||||||
|
class="inline-block h-4 w-4 animate-spin rounded-full border-2 border-white/40 border-t-white"
|
||||||
|
aria-hidden="true"
|
||||||
|
></span>
|
||||||
{/if}
|
{/if}
|
||||||
{confirmLabel}
|
{confirmLabel}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -63,7 +63,11 @@
|
|||||||
window.addEventListener('keydown', onKeyDown);
|
window.addEventListener('keydown', onKeyDown);
|
||||||
return () => window.removeEventListener('keydown', onKeyDown);
|
return () => window.removeEventListener('keydown', onKeyDown);
|
||||||
} else if (returnFocus) {
|
} else if (returnFocus) {
|
||||||
try { returnFocus.focus({ preventScroll: true }); } catch { /* element gone */ }
|
try {
|
||||||
|
returnFocus.focus({ preventScroll: true });
|
||||||
|
} catch {
|
||||||
|
/* element gone */
|
||||||
|
}
|
||||||
returnFocus = null;
|
returnFocus = null;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -115,7 +119,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if title}
|
{#if title}
|
||||||
<p class="px-5 pt-1 pb-2 text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
<p
|
||||||
|
class="px-5 pt-1 pb-2 text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400"
|
||||||
|
>
|
||||||
{title}
|
{title}
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -18,14 +18,7 @@
|
|||||||
oncontextmenu?: (upload: FeedUpload) => void;
|
oncontextmenu?: (upload: FeedUpload) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let { upload, isOwn = false, onlike, oncomment, onselect, oncontextmenu }: Props = $props();
|
||||||
upload,
|
|
||||||
isOwn = false,
|
|
||||||
onlike,
|
|
||||||
oncomment,
|
|
||||||
onselect,
|
|
||||||
oncontextmenu
|
|
||||||
}: Props = $props();
|
|
||||||
|
|
||||||
function isVideo(mime: string): boolean {
|
function isVideo(mime: string): boolean {
|
||||||
return mime.startsWith('video/');
|
return mime.startsWith('video/');
|
||||||
@@ -88,7 +81,9 @@
|
|||||||
{initials(upload.uploader_name)}
|
{initials(upload.uploader_name)}
|
||||||
</div>
|
</div>
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<p class="truncate text-sm font-semibold text-gray-900 dark:text-gray-100">{upload.uploader_name}</p>
|
<p class="truncate text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
{upload.uploader_name}
|
||||||
|
</p>
|
||||||
<p class="text-xs text-gray-400 dark:text-gray-500">{relTime}</p>
|
<p class="text-xs text-gray-400 dark:text-gray-500">{relTime}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -97,11 +92,20 @@
|
|||||||
<!-- Desktop kebab — same actions as the mobile long-press context sheet. -->
|
<!-- Desktop kebab — same actions as the mobile long-press context sheet. -->
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={(e) => { e.stopPropagation(); openContext(); }}
|
onclick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
openContext();
|
||||||
|
}}
|
||||||
class="rounded-full p-1 text-gray-400 hover:bg-gray-100 active:bg-gray-200 hover:text-gray-700 dark:text-gray-500 dark:hover:bg-gray-800 dark:active:bg-gray-800 dark:hover:text-gray-200"
|
class="rounded-full p-1 text-gray-400 hover:bg-gray-100 active:bg-gray-200 hover:text-gray-700 dark:text-gray-500 dark:hover:bg-gray-800 dark:active:bg-gray-800 dark:hover:text-gray-200"
|
||||||
aria-label="Mehr Aktionen"
|
aria-label="Mehr Aktionen"
|
||||||
>
|
>
|
||||||
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 24 24"><circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/></svg>
|
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 24 24"
|
||||||
|
><circle cx="5" cy="12" r="2" /><circle cx="12" cy="12" r="2" /><circle
|
||||||
|
cx="19"
|
||||||
|
cy="12"
|
||||||
|
r="2"
|
||||||
|
/></svg
|
||||||
|
>
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -112,7 +116,9 @@
|
|||||||
use:doubletap
|
use:doubletap
|
||||||
onsingletap={() => onselect(upload)}
|
onsingletap={() => onselect(upload)}
|
||||||
ondoubletap={handleDoubleTap}
|
ondoubletap={handleDoubleTap}
|
||||||
onclick={(e) => { if (e.detail === 0) onselect(upload); }}
|
onclick={(e) => {
|
||||||
|
if (e.detail === 0) onselect(upload);
|
||||||
|
}}
|
||||||
aria-label="Bild vergrößern"
|
aria-label="Bild vergrößern"
|
||||||
>
|
>
|
||||||
<HeartBurst active={heartBurst} />
|
<HeartBurst active={heartBurst} />
|
||||||
@@ -128,7 +134,9 @@
|
|||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="absolute inset-0 flex items-center justify-center">
|
<div class="absolute inset-0 flex items-center justify-center">
|
||||||
<span class="flex h-14 w-14 items-center justify-center rounded-full bg-black/50 text-white">
|
<span
|
||||||
|
class="flex h-14 w-14 items-center justify-center rounded-full bg-black/50 text-white"
|
||||||
|
>
|
||||||
<svg class="h-7 w-7 pl-0.5" fill="currentColor" viewBox="0 0 24 24">
|
<svg class="h-7 w-7 pl-0.5" fill="currentColor" viewBox="0 0 24 24">
|
||||||
<path d="M8 5v14l11-7z" />
|
<path d="M8 5v14l11-7z" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -149,9 +157,21 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="flex aspect-square w-full items-center justify-center bg-gray-100 dark:bg-gray-800">
|
<div
|
||||||
<svg class="h-12 w-12 text-gray-300 dark:text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
class="flex aspect-square w-full items-center justify-center bg-gray-100 dark:bg-gray-800"
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
>
|
||||||
|
<svg
|
||||||
|
class="h-12 w-12 text-gray-300 dark:text-gray-600"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="1.5"
|
||||||
|
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -160,11 +180,16 @@
|
|||||||
<!-- Actions row -->
|
<!-- Actions row -->
|
||||||
<div class="flex items-center gap-4 px-4 py-2">
|
<div class="flex items-center gap-4 px-4 py-2">
|
||||||
<button
|
<button
|
||||||
onclick={() => { vibrate(10); onlike(upload.id); }}
|
onclick={() => {
|
||||||
|
vibrate(10);
|
||||||
|
onlike(upload.id);
|
||||||
|
}}
|
||||||
aria-pressed={upload.liked_by_me}
|
aria-pressed={upload.liked_by_me}
|
||||||
aria-label={upload.liked_by_me ? 'Gefällt mir nicht mehr' : 'Gefällt mir'}
|
aria-label={upload.liked_by_me ? 'Gefällt mir nicht mehr' : 'Gefällt mir'}
|
||||||
class="flex items-center gap-1.5 text-sm font-medium transition-colors
|
class="flex items-center gap-1.5 text-sm font-medium transition-colors
|
||||||
{upload.liked_by_me ? 'text-red-500 dark:text-red-400' : 'text-gray-500 hover:text-red-400 active:text-red-400 dark:text-gray-400 dark:hover:text-red-400 dark:active:text-red-400'}"
|
{upload.liked_by_me
|
||||||
|
? 'text-red-500 dark:text-red-400'
|
||||||
|
: 'text-gray-500 hover:text-red-400 active:text-red-400 dark:text-gray-400 dark:hover:text-red-400 dark:active:text-red-400'}"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
class="h-5 w-5 {upload.liked_by_me ? 'fill-red-500' : ''}"
|
class="h-5 w-5 {upload.liked_by_me ? 'fill-red-500' : ''}"
|
||||||
@@ -173,7 +198,11 @@
|
|||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
stroke-width="2"
|
stroke-width="2"
|
||||||
>
|
>
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{upload.like_count}
|
{upload.like_count}
|
||||||
</button>
|
</button>
|
||||||
@@ -182,7 +211,11 @@
|
|||||||
class="flex items-center gap-1.5 text-sm font-medium text-gray-500 transition-colors hover:text-blue-500 active:text-blue-500 dark:text-gray-400 dark:hover:text-blue-400 dark:active:text-blue-400"
|
class="flex items-center gap-1.5 text-sm font-medium text-gray-500 transition-colors hover:text-blue-500 active:text-blue-500 dark:text-gray-400 dark:hover:text-blue-400 dark:active:text-blue-400"
|
||||||
>
|
>
|
||||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{upload.comment_count}
|
{upload.comment_count}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -18,11 +18,10 @@
|
|||||||
<button
|
<button
|
||||||
onclick={() => onselect(null)}
|
onclick={() => onselect(null)}
|
||||||
aria-pressed={selected === null}
|
aria-pressed={selected === null}
|
||||||
class="inline-flex min-h-11 shrink-0 items-center rounded-full px-4 py-2 text-sm font-medium transition {
|
class="inline-flex min-h-11 shrink-0 items-center rounded-full px-4 py-2 text-sm font-medium transition {selected ===
|
||||||
selected === null
|
null
|
||||||
? 'bg-blue-600 text-white dark:bg-blue-500'
|
? 'bg-blue-600 text-white dark:bg-blue-500'
|
||||||
: 'bg-gray-200 text-gray-700 hover:bg-gray-300 active:bg-gray-300 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700 dark:active:bg-gray-700'
|
: 'bg-gray-200 text-gray-700 hover:bg-gray-300 active:bg-gray-300 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700 dark:active:bg-gray-700'}"
|
||||||
}"
|
|
||||||
>
|
>
|
||||||
Alle
|
Alle
|
||||||
</button>
|
</button>
|
||||||
@@ -30,11 +29,10 @@
|
|||||||
<button
|
<button
|
||||||
onclick={() => onselect(h.tag)}
|
onclick={() => onselect(h.tag)}
|
||||||
aria-pressed={selected === h.tag}
|
aria-pressed={selected === h.tag}
|
||||||
class="inline-flex min-h-11 shrink-0 items-center rounded-full px-4 py-2 text-sm font-medium transition {
|
class="inline-flex min-h-11 shrink-0 items-center rounded-full px-4 py-2 text-sm font-medium transition {selected ===
|
||||||
selected === h.tag
|
h.tag
|
||||||
? 'bg-blue-600 text-white dark:bg-blue-500'
|
? 'bg-blue-600 text-white dark:bg-blue-500'
|
||||||
: 'bg-gray-200 text-gray-700 hover:bg-gray-300 active:bg-gray-300 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700 dark:active:bg-gray-700'
|
: 'bg-gray-200 text-gray-700 hover:bg-gray-300 active:bg-gray-300 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700 dark:active:bg-gray-700'}"
|
||||||
}"
|
|
||||||
>
|
>
|
||||||
#{h.tag}
|
#{h.tag}
|
||||||
<span class="ml-1 text-xs opacity-70">{h.count}</span>
|
<span class="ml-1 text-xs opacity-70">{h.count}</span>
|
||||||
|
|||||||
@@ -5,15 +5,6 @@
|
|||||||
let { active }: Props = $props();
|
let { active }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
|
||||||
@keyframes heart-burst {
|
|
||||||
0% { transform: scale(0.5); opacity: 0; }
|
|
||||||
30% { transform: scale(1.2); opacity: 1; }
|
|
||||||
70% { transform: scale(1); opacity: 1; }
|
|
||||||
100% { transform: scale(1.4); opacity: 0; }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
{#if active}
|
{#if active}
|
||||||
<span
|
<span
|
||||||
class="pointer-events-none absolute inset-0 flex items-center justify-center"
|
class="pointer-events-none absolute inset-0 flex items-center justify-center"
|
||||||
@@ -26,7 +17,30 @@
|
|||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
>
|
>
|
||||||
<path d="M12 21s-7-4.534-9.5-9.034C.5 9.466 2.5 5 7 5c2.09 0 3.534 1.083 5 3 1.466-1.917 2.91-3 5-3 4.5 0 6.5 4.466 4.5 6.966C19 16.466 12 21 12 21z" />
|
<path
|
||||||
|
d="M12 21s-7-4.534-9.5-9.034C.5 9.466 2.5 5 7 5c2.09 0 3.534 1.083 5 3 1.466-1.917 2.91-3 5-3 4.5 0 6.5 4.466 4.5 6.966C19 16.466 12 21 12 21z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
@keyframes heart-burst {
|
||||||
|
0% {
|
||||||
|
transform: scale(0.5);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
30% {
|
||||||
|
transform: scale(1.2);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
70% {
|
||||||
|
transform: scale(1);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: scale(1.4);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -55,7 +55,9 @@
|
|||||||
try {
|
try {
|
||||||
const { comment_id } = JSON.parse(data) as { comment_id: string };
|
const { comment_id } = JSON.parse(data) as { comment_id: string };
|
||||||
comments = comments.filter((c) => c.id !== comment_id);
|
comments = comments.filter((c) => c.id !== comment_id);
|
||||||
} catch { /* ignore */ }
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Pull in a comment posted from another device on the CURRENTLY-open upload, so the
|
// Pull in a comment posted from another device on the CURRENTLY-open upload, so the
|
||||||
@@ -66,7 +68,9 @@
|
|||||||
try {
|
try {
|
||||||
const { upload_id } = JSON.parse(data) as { upload_id: string };
|
const { upload_id } = JSON.parse(data) as { upload_id: string };
|
||||||
if (upload_id === upload.id) void loadComments(upload.id);
|
if (upload_id === upload.id) void loadComments(upload.id);
|
||||||
} catch { /* ignore */ }
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
@@ -124,7 +128,10 @@
|
|||||||
|
|
||||||
function formatTime(iso: string): string {
|
function formatTime(iso: string): string {
|
||||||
return new Date(iso).toLocaleString('de-DE', {
|
return new Date(iso).toLocaleString('de-DE', {
|
||||||
day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit'
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -138,7 +145,9 @@
|
|||||||
use:scrollLock
|
use:scrollLock
|
||||||
use:modalInert
|
use:modalInert
|
||||||
>
|
>
|
||||||
<div class="flex max-h-[90vh] w-full max-w-2xl flex-col overflow-hidden rounded-xl bg-white dark:bg-gray-900">
|
<div
|
||||||
|
class="flex max-h-[90vh] w-full max-w-2xl flex-col overflow-hidden rounded-xl bg-white dark:bg-gray-900"
|
||||||
|
>
|
||||||
<!-- Media -->
|
<!-- Media -->
|
||||||
<div class="relative bg-black">
|
<div class="relative bg-black">
|
||||||
<button
|
<button
|
||||||
@@ -147,14 +156,15 @@
|
|||||||
class="absolute right-2 top-2 z-10 inline-flex min-h-11 min-w-11 items-center justify-center rounded-full bg-black/50 text-white hover:bg-black/70 active:bg-black/70"
|
class="absolute right-2 top-2 z-10 inline-flex min-h-11 min-w-11 items-center justify-center rounded-full bg-black/50 text-white hover:bg-black/70 active:bg-black/70"
|
||||||
>
|
>
|
||||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M6 18L18 6M6 6l12 12"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<div
|
<div class="relative" use:doubletap ondoubletap={triggerHeartBurst}>
|
||||||
class="relative"
|
|
||||||
use:doubletap
|
|
||||||
ondoubletap={triggerHeartBurst}
|
|
||||||
>
|
|
||||||
{#if isVideo(upload.mime_type)}
|
{#if isVideo(upload.mime_type)}
|
||||||
<video
|
<video
|
||||||
src={mediaSrc}
|
src={mediaSrc}
|
||||||
@@ -180,19 +190,31 @@
|
|||||||
<div class="border-b border-gray-100 p-3 dark:border-gray-800">
|
<div class="border-b border-gray-100 p-3 dark:border-gray-800">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<span id="lightbox-title" class="font-medium text-gray-900 dark:text-gray-100">{upload.uploader_name}</span>
|
<span id="lightbox-title" class="font-medium text-gray-900 dark:text-gray-100"
|
||||||
<span class="ml-2 text-xs text-gray-400 dark:text-gray-500">{formatTime(upload.created_at)}</span>
|
>{upload.uploader_name}</span
|
||||||
|
>
|
||||||
|
<span class="ml-2 text-xs text-gray-400 dark:text-gray-500"
|
||||||
|
>{formatTime(upload.created_at)}</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onclick={() => onlike(upload.id)}
|
onclick={() => onlike(upload.id)}
|
||||||
class="flex items-center gap-1 rounded-full px-2.5 py-1 text-sm transition {
|
class="flex items-center gap-1 rounded-full px-2.5 py-1 text-sm transition {upload.liked_by_me
|
||||||
upload.liked_by_me
|
? 'bg-red-50 text-red-600 dark:bg-red-950/40 dark:text-red-300'
|
||||||
? 'bg-red-50 text-red-600 dark:bg-red-950/40 dark:text-red-300'
|
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700'}"
|
||||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700'
|
|
||||||
}"
|
|
||||||
>
|
>
|
||||||
<svg class="h-4 w-4 {upload.liked_by_me ? 'fill-current' : ''}" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
|
class="h-4 w-4 {upload.liked_by_me ? 'fill-current' : ''}"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{upload.like_count}
|
{upload.like_count}
|
||||||
</button>
|
</button>
|
||||||
@@ -211,9 +233,13 @@
|
|||||||
{#each comments as comment (comment.id)}
|
{#each comments as comment (comment.id)}
|
||||||
<div class="flex items-start gap-2">
|
<div class="flex items-start gap-2">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<span class="text-sm font-medium text-gray-900 dark:text-gray-100">{comment.uploader_name}</span>
|
<span class="text-sm font-medium text-gray-900 dark:text-gray-100"
|
||||||
|
>{comment.uploader_name}</span
|
||||||
|
>
|
||||||
<span class="ml-1 text-sm text-gray-700 dark:text-gray-300">{comment.body}</span>
|
<span class="ml-1 text-sm text-gray-700 dark:text-gray-300">{comment.body}</span>
|
||||||
<div class="mt-0.5 text-xs text-gray-400 dark:text-gray-500">{formatTime(comment.created_at)}</div>
|
<div class="mt-0.5 text-xs text-gray-400 dark:text-gray-500">
|
||||||
|
{formatTime(comment.created_at)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{#if comment.user_id === userId}
|
{#if comment.user_id === userId}
|
||||||
<button
|
<button
|
||||||
@@ -222,7 +248,12 @@
|
|||||||
aria-label="Löschen"
|
aria-label="Löschen"
|
||||||
>
|
>
|
||||||
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M6 18L18 6M6 6l12 12"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -234,7 +265,10 @@
|
|||||||
|
|
||||||
<!-- Comment input -->
|
<!-- Comment input -->
|
||||||
<form
|
<form
|
||||||
onsubmit={(e) => { e.preventDefault(); submitComment(); }}
|
onsubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
submitComment();
|
||||||
|
}}
|
||||||
class="border-t border-gray-100 p-3 dark:border-gray-800"
|
class="border-t border-gray-100 p-3 dark:border-gray-800"
|
||||||
>
|
>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
|
|||||||
@@ -19,14 +19,7 @@
|
|||||||
children: Snippet;
|
children: Snippet;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let { open, titleId, ariaLabel, onClose, closeOnBackdrop = true, children }: Props = $props();
|
||||||
open,
|
|
||||||
titleId,
|
|
||||||
ariaLabel,
|
|
||||||
onClose,
|
|
||||||
closeOnBackdrop = true,
|
|
||||||
children
|
|
||||||
}: Props = $props();
|
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (open && !titleId && !ariaLabel && typeof console !== 'undefined') {
|
if (open && !titleId && !ariaLabel && typeof console !== 'undefined') {
|
||||||
@@ -56,7 +49,9 @@
|
|||||||
use:focusTrap={{ onclose: onClose }}
|
use:focusTrap={{ onclose: onClose }}
|
||||||
use:scrollLock
|
use:scrollLock
|
||||||
>
|
>
|
||||||
<div class="pointer-events-auto w-full max-w-sm rounded-2xl bg-white p-6 shadow-xl dark:bg-gray-900">
|
<div
|
||||||
|
class="pointer-events-auto w-full max-w-sm rounded-2xl bg-white p-6 shadow-xl dark:bg-gray-900"
|
||||||
|
>
|
||||||
{@render children()}
|
{@render children()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -63,10 +63,15 @@
|
|||||||
// Derived in the script so TypeScript can narrow on `.kind` inside the template.
|
// Derived in the script so TypeScript can narrow on `.kind` inside the template.
|
||||||
let currentStep = $derived(steps[step]);
|
let currentStep = $derived(steps[step]);
|
||||||
|
|
||||||
const THEME_OPTIONS: Array<{ value: ThemePreference; label: string; hint: string; icon: string }> = [
|
const THEME_OPTIONS: Array<{
|
||||||
|
value: ThemePreference;
|
||||||
|
label: string;
|
||||||
|
hint: string;
|
||||||
|
icon: string;
|
||||||
|
}> = [
|
||||||
{ value: 'system', label: 'System', hint: 'Folgt der Geräteeinstellung', icon: '🖥️' },
|
{ value: 'system', label: 'System', hint: 'Folgt der Geräteeinstellung', icon: '🖥️' },
|
||||||
{ value: 'light', label: 'Hell', hint: 'Heller Hintergrund', icon: '☀️' },
|
{ value: 'light', label: 'Hell', hint: 'Heller Hintergrund', icon: '☀️' },
|
||||||
{ value: 'dark', label: 'Dunkel', hint: 'Dunkler Hintergrund', icon: '🌙' }
|
{ value: 'dark', label: 'Dunkel', hint: 'Dunkler Hintergrund', icon: '🌙' }
|
||||||
];
|
];
|
||||||
|
|
||||||
if (browser && !localStorage.getItem(GUIDE_SEEN_KEY)) {
|
if (browser && !localStorage.getItem(GUIDE_SEEN_KEY)) {
|
||||||
@@ -106,7 +111,7 @@
|
|||||||
<!-- Step indicator — tap a pip to jump back. The visible dot is small but
|
<!-- Step indicator — tap a pip to jump back. The visible dot is small but
|
||||||
the touch target is padded to ~44 px so it remains tappable on mobile. -->
|
the touch target is padded to ~44 px so it remains tappable on mobile. -->
|
||||||
<div class="mb-3 flex justify-center">
|
<div class="mb-3 flex justify-center">
|
||||||
{#each steps as _, i}
|
{#each steps as _, i (i)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() => goToStep(i)}
|
onclick={() => goToStep(i)}
|
||||||
@@ -148,8 +153,8 @@
|
|||||||
onclick={() => themePreference.set(opt.value)}
|
onclick={() => themePreference.set(opt.value)}
|
||||||
class="flex w-full cursor-pointer items-center gap-3 rounded-xl border-2 p-3 transition focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-200
|
class="flex w-full cursor-pointer items-center gap-3 rounded-xl border-2 p-3 transition focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-200
|
||||||
{selected
|
{selected
|
||||||
? 'border-blue-600 bg-blue-50 dark:border-blue-500 dark:bg-blue-950/40'
|
? 'border-blue-600 bg-blue-50 dark:border-blue-500 dark:bg-blue-950/40'
|
||||||
: 'border-gray-200 hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-800'}"
|
: 'border-gray-200 hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-800'}"
|
||||||
>
|
>
|
||||||
<span class="text-2xl leading-none">{opt.icon}</span>
|
<span class="text-2xl leading-none">{opt.icon}</span>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
@@ -159,11 +164,17 @@
|
|||||||
<span
|
<span
|
||||||
class="flex h-5 w-5 shrink-0 items-center justify-center rounded-full border-2
|
class="flex h-5 w-5 shrink-0 items-center justify-center rounded-full border-2
|
||||||
{selected
|
{selected
|
||||||
? 'border-blue-600 bg-blue-600 dark:border-blue-500 dark:bg-blue-500'
|
? 'border-blue-600 bg-blue-600 dark:border-blue-500 dark:bg-blue-500'
|
||||||
: 'border-gray-300 dark:border-gray-600'}"
|
: 'border-gray-300 dark:border-gray-600'}"
|
||||||
>
|
>
|
||||||
{#if selected}
|
{#if selected}
|
||||||
<svg class="h-3 w-3 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3">
|
<svg
|
||||||
|
class="h-3 w-3 text-white"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="3"
|
||||||
|
>
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||||
</svg>
|
</svg>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -186,7 +197,7 @@
|
|||||||
onclick={next}
|
onclick={next}
|
||||||
class="flex-1 rounded-xl bg-blue-600 py-3 text-sm font-semibold text-white hover:bg-blue-700 active:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-400 dark:active:bg-blue-400"
|
class="flex-1 rounded-xl bg-blue-600 py-3 text-sm font-semibold text-white hover:bg-blue-700 active:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-400 dark:active:bg-blue-400"
|
||||||
>
|
>
|
||||||
{step < steps.length - 1 ? 'Weiter' : 'Los geht\'s!'}
|
{step < steps.length - 1 ? 'Weiter' : "Los geht's!"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -27,7 +27,9 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() => dismissToast(t.id)}
|
onclick={() => dismissToast(t.id)}
|
||||||
class="pointer-events-auto w-full max-w-sm rounded-xl px-4 py-3 text-left text-sm font-medium shadow-lg transition active:scale-[0.98] {toneClasses(t.tone)}"
|
class="pointer-events-auto w-full max-w-sm rounded-xl px-4 py-3 text-left text-sm font-medium shadow-lg transition active:scale-[0.98] {toneClasses(
|
||||||
|
t.tone
|
||||||
|
)}"
|
||||||
role={t.tone === 'error' || t.tone === 'warning' ? 'alert' : 'status'}
|
role={t.tone === 'error' || t.tone === 'warning' ? 'alert' : 'status'}
|
||||||
aria-live={t.tone === 'error' || t.tone === 'warning' ? 'assertive' : 'polite'}
|
aria-live={t.tone === 'error' || t.tone === 'warning' ? 'assertive' : 'polite'}
|
||||||
data-testid="toast"
|
data-testid="toast"
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { queueItems, isProcessing, retryItem, removeItem, clearCompleted, rateLimitRetryAt } from '$lib/upload-queue';
|
import {
|
||||||
|
queueItems,
|
||||||
|
isProcessing,
|
||||||
|
retryItem,
|
||||||
|
removeItem,
|
||||||
|
clearCompleted,
|
||||||
|
rateLimitRetryAt
|
||||||
|
} from '$lib/upload-queue';
|
||||||
import type { QueueItem } from '$lib/upload-queue';
|
import type { QueueItem } from '$lib/upload-queue';
|
||||||
|
|
||||||
function formatSize(bytes: number): string {
|
function formatSize(bytes: number): string {
|
||||||
@@ -10,21 +17,31 @@
|
|||||||
|
|
||||||
function statusLabel(status: QueueItem['status']): string {
|
function statusLabel(status: QueueItem['status']): string {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'pending': return 'Wartend';
|
case 'pending':
|
||||||
case 'uploading': return 'Wird hochgeladen';
|
return 'Wartend';
|
||||||
case 'done': return 'Fertig';
|
case 'uploading':
|
||||||
case 'error': return 'Fehler';
|
return 'Wird hochgeladen';
|
||||||
case 'blocked': return 'Gesperrt';
|
case 'done':
|
||||||
|
return 'Fertig';
|
||||||
|
case 'error':
|
||||||
|
return 'Fehler';
|
||||||
|
case 'blocked':
|
||||||
|
return 'Gesperrt';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function statusColor(status: QueueItem['status']): string {
|
function statusColor(status: QueueItem['status']): string {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'pending': return 'text-gray-500 dark:text-gray-400';
|
case 'pending':
|
||||||
case 'uploading': return 'text-blue-600 dark:text-blue-400';
|
return 'text-gray-500 dark:text-gray-400';
|
||||||
case 'done': return 'text-green-600 dark:text-green-400';
|
case 'uploading':
|
||||||
case 'error': return 'text-red-600 dark:text-red-400';
|
return 'text-blue-600 dark:text-blue-400';
|
||||||
case 'blocked': return 'text-red-600 dark:text-red-400';
|
case 'done':
|
||||||
|
return 'text-green-600 dark:text-green-400';
|
||||||
|
case 'error':
|
||||||
|
return 'text-red-600 dark:text-red-400';
|
||||||
|
case 'blocked':
|
||||||
|
return 'text-red-600 dark:text-red-400';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,8 +69,12 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if items.length > 0}
|
{#if items.length > 0}
|
||||||
<div class="mt-4 rounded-lg border border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900">
|
<div
|
||||||
<div class="flex items-center justify-between border-b border-gray-100 px-4 py-3 dark:border-gray-800">
|
class="mt-4 rounded-lg border border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-between border-b border-gray-100 px-4 py-3 dark:border-gray-800"
|
||||||
|
>
|
||||||
<h3 class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
<h3 class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||||
Upload-Warteschlange
|
Upload-Warteschlange
|
||||||
{#if $isProcessing}
|
{#if $isProcessing}
|
||||||
@@ -71,7 +92,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if $rateLimitRetryAt && countdown > 0}
|
{#if $rateLimitRetryAt && countdown > 0}
|
||||||
<div class="border-b border-amber-100 bg-amber-50 px-4 py-2 text-sm text-amber-800 dark:border-amber-900/40 dark:bg-amber-950/30 dark:text-amber-300">
|
<div
|
||||||
|
class="border-b border-amber-100 bg-amber-50 px-4 py-2 text-sm text-amber-800 dark:border-amber-900/40 dark:bg-amber-950/30 dark:text-amber-300"
|
||||||
|
>
|
||||||
Upload-Limit erreicht. Wird in {countdown} Sek. automatisch fortgesetzt.
|
Upload-Limit erreicht. Wird in {countdown} Sek. automatisch fortgesetzt.
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -81,7 +104,9 @@
|
|||||||
<li class="px-4 py-3">
|
<li class="px-4 py-3">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<p class="truncate text-sm font-medium text-gray-900 dark:text-gray-100">{item.fileName}</p>
|
<p class="truncate text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||||
|
{item.fileName}
|
||||||
|
</p>
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400">{formatSize(item.fileSize)}</p>
|
<p class="text-xs text-gray-500 dark:text-gray-400">{formatSize(item.fileSize)}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="ml-3 flex items-center gap-2">
|
<div class="ml-3 flex items-center gap-2">
|
||||||
@@ -103,7 +128,12 @@
|
|||||||
aria-label="Entfernen"
|
aria-label="Entfernen"
|
||||||
>
|
>
|
||||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M6 18L18 6M6 6l12 12"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -111,7 +141,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if item.status === 'uploading'}
|
{#if item.status === 'uploading'}
|
||||||
<div class="mt-2 h-1.5 w-full overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
|
<div
|
||||||
|
class="mt-2 h-1.5 w-full overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700"
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
class="h-full rounded-full bg-blue-500 transition-all duration-300"
|
class="h-full rounded-full bg-blue-500 transition-all duration-300"
|
||||||
style="width: {item.progress}%"
|
style="width: {item.progress}%"
|
||||||
|
|||||||
@@ -59,7 +59,11 @@
|
|||||||
window.addEventListener('keydown', onKeyDown);
|
window.addEventListener('keydown', onKeyDown);
|
||||||
return () => window.removeEventListener('keydown', onKeyDown);
|
return () => window.removeEventListener('keydown', onKeyDown);
|
||||||
} else if (returnFocus) {
|
} else if (returnFocus) {
|
||||||
try { returnFocus.focus({ preventScroll: true }); } catch { /* element gone */ }
|
try {
|
||||||
|
returnFocus.focus({ preventScroll: true });
|
||||||
|
} catch {
|
||||||
|
/* element gone */
|
||||||
|
}
|
||||||
returnFocus = null;
|
returnFocus = null;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -170,46 +174,74 @@
|
|||||||
Schließen
|
Schließen
|
||||||
</button>
|
</button>
|
||||||
{:else}
|
{:else}
|
||||||
<!-- Gallery option -->
|
<!-- Gallery option -->
|
||||||
<button
|
<button
|
||||||
onclick={openGallery}
|
onclick={openGallery}
|
||||||
class="flex w-full items-center gap-4 rounded-xl bg-gray-50 px-5 py-4 text-left transition hover:bg-gray-100 active:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 dark:active:bg-gray-600"
|
class="flex w-full items-center gap-4 rounded-xl bg-gray-50 px-5 py-4 text-left transition hover:bg-gray-100 active:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 dark:active:bg-gray-600"
|
||||||
>
|
>
|
||||||
<span class="flex h-11 w-11 items-center justify-center rounded-full bg-blue-100 text-blue-600 dark:bg-blue-900/40 dark:text-blue-300">
|
<span
|
||||||
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
class="flex h-11 w-11 items-center justify-center rounded-full bg-blue-100 text-blue-600 dark:bg-blue-900/40 dark:text-blue-300"
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Zm10.5-11.25h.008v.008h-.008V8.25Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z" />
|
>
|
||||||
</svg>
|
<svg
|
||||||
</span>
|
class="h-6 w-6"
|
||||||
<div>
|
fill="none"
|
||||||
<p class="font-semibold text-gray-900 dark:text-gray-100">Galerie</p>
|
viewBox="0 0 24 24"
|
||||||
<p class="text-sm text-gray-500 dark:text-gray-400">Foto oder Video wählen</p>
|
stroke="currentColor"
|
||||||
</div>
|
stroke-width="1.5"
|
||||||
</button>
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Zm10.5-11.25h.008v.008h-.008V8.25Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<p class="font-semibold text-gray-900 dark:text-gray-100">Galerie</p>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">Foto oder Video wählen</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
<!-- Camera option -->
|
<!-- Camera option -->
|
||||||
<button
|
<button
|
||||||
onclick={openCamera}
|
onclick={openCamera}
|
||||||
class="flex w-full items-center gap-4 rounded-xl bg-gray-50 px-5 py-4 text-left transition hover:bg-gray-100 active:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 dark:active:bg-gray-600"
|
class="flex w-full items-center gap-4 rounded-xl bg-gray-50 px-5 py-4 text-left transition hover:bg-gray-100 active:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 dark:active:bg-gray-600"
|
||||||
>
|
>
|
||||||
<span class="flex h-11 w-11 items-center justify-center rounded-full bg-purple-100 text-purple-600 dark:bg-purple-900/40 dark:text-purple-300">
|
<span
|
||||||
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
class="flex h-11 w-11 items-center justify-center rounded-full bg-purple-100 text-purple-600 dark:bg-purple-900/40 dark:text-purple-300"
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6.827 6.175A2.31 2.31 0 0 1 5.186 7.23c-.38.054-.757.112-1.134.175C2.999 7.58 2.25 8.507 2.25 9.574V18a2.25 2.25 0 0 0 2.25 2.25h15A2.25 2.25 0 0 0 21.75 18V9.574c0-1.067-.75-1.994-1.802-2.169a47.865 47.865 0 0 0-1.134-.175 2.31 2.31 0 0 1-1.64-1.055l-.822-1.316a2.192 2.192 0 0 0-1.736-1.039 48.774 48.774 0 0 0-5.232 0 2.192 2.192 0 0 0-1.736 1.039l-.821 1.316Z" />
|
>
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M16.5 12.75a4.5 4.5 0 1 1-9 0 4.5 4.5 0 0 1 9 0ZM18.75 10.5h.008v.008h-.008V10.5Z" />
|
<svg
|
||||||
</svg>
|
class="h-6 w-6"
|
||||||
</span>
|
fill="none"
|
||||||
<div>
|
viewBox="0 0 24 24"
|
||||||
<p class="font-semibold text-gray-900 dark:text-gray-100">Kamera</p>
|
stroke="currentColor"
|
||||||
<p class="text-sm text-gray-500 dark:text-gray-400">Jetzt aufnehmen</p>
|
stroke-width="1.5"
|
||||||
</div>
|
>
|
||||||
</button>
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M6.827 6.175A2.31 2.31 0 0 1 5.186 7.23c-.38.054-.757.112-1.134.175C2.999 7.58 2.25 8.507 2.25 9.574V18a2.25 2.25 0 0 0 2.25 2.25h15A2.25 2.25 0 0 0 21.75 18V9.574c0-1.067-.75-1.994-1.802-2.169a47.865 47.865 0 0 0-1.134-.175 2.31 2.31 0 0 1-1.64-1.055l-.822-1.316a2.192 2.192 0 0 0-1.736-1.039 48.774 48.774 0 0 0-5.232 0 2.192 2.192 0 0 0-1.736 1.039l-.821 1.316Z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M16.5 12.75a4.5 4.5 0 1 1-9 0 4.5 4.5 0 0 1 9 0ZM18.75 10.5h.008v.008h-.008V10.5Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<p class="font-semibold text-gray-900 dark:text-gray-100">Kamera</p>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">Jetzt aufnehmen</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
<!-- Cancel -->
|
<!-- Cancel -->
|
||||||
<button
|
<button
|
||||||
onclick={close}
|
onclick={close}
|
||||||
class="w-full rounded-xl border border-gray-200 py-3 text-sm font-medium text-gray-600 transition hover:bg-gray-50 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-800"
|
class="w-full rounded-xl border border-gray-200 py-3 text-sm font-medium text-gray-600 transition hover:bg-gray-50 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-800"
|
||||||
>
|
>
|
||||||
Abbrechen
|
Abbrechen
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -35,8 +35,7 @@
|
|||||||
oncontextmenu?: (upload: FeedUpload) => void;
|
oncontextmenu?: (upload: FeedUpload) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { uploads, mode, myUserId, onlike, oncomment, onselect, oncontextmenu }: Props =
|
let { uploads, mode, myUserId, onlike, oncomment, onselect, oncontextmenu }: Props = $props();
|
||||||
$props();
|
|
||||||
|
|
||||||
const COLS = 3;
|
const COLS = 3;
|
||||||
const GRID_GAP = 2; // px — matches the `gap-0.5` the non-virtual grid used.
|
const GRID_GAP = 2; // px — matches the `gap-0.5` the non-virtual grid used.
|
||||||
@@ -218,7 +217,11 @@
|
|||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="absolute inset-0 flex items-center justify-center">
|
<div class="absolute inset-0 flex items-center justify-center">
|
||||||
<svg class="h-10 w-10 text-white/80" fill="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
|
class="h-10 w-10 text-white/80"
|
||||||
|
fill="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
<path d="M8 5v14l11-7z" />
|
<path d="M8 5v14l11-7z" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user