chore(frontend): add ESLint + Prettier; fix real findings; format the tree
The frontend had no JS/TS linter — only svelte-check. Add flat-config ESLint (typescript-eslint
+ eslint-plugin-svelte) and Prettier (tabs/single-quote, matching the existing style).
Rules encode "catch bugs, not enforce taste":
- svelte/require-each-key KEPT — it is the exact bug class as the feed mis-tap fix. Fixed every
flagged block: keyed activeFilters, filteredUsers, stagedFiles (by previewUrl), captionTags,
admin tabs/jobs/users, the export-viewer suggestions/filters/comments, and the static skeleton
loops.
- svelte/prefer-svelte-reactivity KEPT — inline-disabled only the verified-safe sites (a local
freq Map in a $derived.by, throwaway URLSearchParams query builders), with a reason each.
- svelte/no-navigation-without-resolve OFF — wants resolve() around every goto()/href; taste, not
a bug, and pure churn.
- svelte/no-unused-svelte-ignore OFF — those comments are consumed by svelte-check, which ESLint
can't see, so it wrongly calls them unused; removing them would reintroduce a11y warnings.
- no-explicit-any OFF for *.test.ts only (partial fixtures legitimately use any).
Real code fixes beyond keys: removed a dead jobLabel(), an unused ViewerComment import and unused
catch binding, an unused scroll-lock arg, and replaced an empty interface with a type alias.
Then `prettier --write` (54 files). Formatting only. Verified: eslint clean, svelte-check 0 errors,
vitest 46 passed, vite build succeeds.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
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:
|
||||
|
||||
- `auth.ts` — JWT / PIN in `localStorage`, `isAuthenticated` writable
|
||||
- `ui-store.ts` — bottom-nav visibility, upload-sheet open state, FAB badge count
|
||||
- `data-mode-store.ts` — Saver vs Original media-loading preference
|
||||
|
||||
@@ -20,8 +20,8 @@ export interface DoubletapOptions {
|
||||
}
|
||||
|
||||
interface DoubletapAttributes {
|
||||
'ondoubletap'?: (event: CustomEvent<void>) => void;
|
||||
'onsingletap'?: (event: CustomEvent<void>) => void;
|
||||
ondoubletap?: (event: CustomEvent<void>) => void;
|
||||
onsingletap?: (event: CustomEvent<void>) => void;
|
||||
}
|
||||
|
||||
export function doubletap(
|
||||
|
||||
@@ -30,7 +30,9 @@ export function focusTrap(
|
||||
options: FocusTrapOptions = {}
|
||||
): ActionReturn<FocusTrapOptions> {
|
||||
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) {
|
||||
if (e.key === 'Escape' && opts.closeOnEscape !== false && opts.onclose) {
|
||||
@@ -85,7 +87,11 @@ export function focusTrap(
|
||||
destroy() {
|
||||
node.removeEventListener('keydown', onKeyDown);
|
||||
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 {
|
||||
'onlongpress'?: (event: CustomEvent<void>) => void;
|
||||
onlongpress?: (event: CustomEvent<void>) => void;
|
||||
}
|
||||
|
||||
export function longpress(
|
||||
|
||||
@@ -40,7 +40,7 @@ function unlock() {
|
||||
* dialog is open (e.g. inside `{#if open}` or alongside a `class:` toggle) so the
|
||||
* action's create/destroy line up with open/close.
|
||||
*/
|
||||
export function scrollLock(node: HTMLElement): ActionReturn {
|
||||
export function scrollLock(_node: HTMLElement): ActionReturn {
|
||||
lock();
|
||||
return {
|
||||
destroy() {
|
||||
|
||||
@@ -15,11 +15,7 @@ export class ApiError extends Error {
|
||||
|
||||
const TIMEOUT_MS = 20_000;
|
||||
|
||||
async function request<T>(
|
||||
method: string,
|
||||
path: string,
|
||||
body?: unknown
|
||||
): Promise<T> {
|
||||
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||
const headers: Record<string, string> = {};
|
||||
const token = getToken();
|
||||
if (token) {
|
||||
@@ -75,7 +71,11 @@ async function request<T>(
|
||||
clearAuth();
|
||||
}
|
||||
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;
|
||||
|
||||
@@ -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
|
||||
// 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. */
|
||||
function makeJwt(claims: object): string {
|
||||
const seg = (o: object) => btoa(JSON.stringify(o));
|
||||
return `${seg({ alg: 'HS256', typ: 'JWT' })}.${seg(claims)}.sig`;
|
||||
const seg = (o: object) => btoa(JSON.stringify(o));
|
||||
return `${seg({ alg: 'HS256', typ: 'JWT' })}.${seg(claims)}.sig`;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
describe('auth — token storage', () => {
|
||||
it('setAuth then getToken/getPin round-trips', () => {
|
||||
setAuth('a.b.c', '1234', 'uid-1', 'Alice');
|
||||
expect(getToken()).toBe('a.b.c');
|
||||
expect(getPin()).toBe('1234');
|
||||
});
|
||||
it('setAuth then getToken/getPin round-trips', () => {
|
||||
setAuth('a.b.c', '1234', 'uid-1', 'Alice');
|
||||
expect(getToken()).toBe('a.b.c');
|
||||
expect(getPin()).toBe('1234');
|
||||
});
|
||||
|
||||
it('setAuth without a PIN leaves the PIN unset', () => {
|
||||
setAuth('a.b.c', null, 'uid-1');
|
||||
expect(getToken()).toBe('a.b.c');
|
||||
expect(getPin()).toBeNull();
|
||||
});
|
||||
it('setAuth without a PIN leaves the PIN unset', () => {
|
||||
setAuth('a.b.c', null, 'uid-1');
|
||||
expect(getToken()).toBe('a.b.c');
|
||||
expect(getPin()).toBeNull();
|
||||
});
|
||||
|
||||
it('clearAuth removes the token but KEEPS the PIN (needed for recovery)', () => {
|
||||
setAuth('a.b.c', '1234', 'uid');
|
||||
clearAuth();
|
||||
expect(getToken()).toBeNull();
|
||||
expect(getPin()).toBe('1234');
|
||||
});
|
||||
it('clearAuth removes the token but KEEPS the PIN (needed for recovery)', () => {
|
||||
setAuth('a.b.c', '1234', 'uid');
|
||||
clearAuth();
|
||||
expect(getToken()).toBeNull();
|
||||
expect(getPin()).toBe('1234');
|
||||
});
|
||||
|
||||
it('clearPin removes the cached PIN', () => {
|
||||
setAuth('a.b.c', '1234', 'uid');
|
||||
clearPin();
|
||||
expect(getPin()).toBeNull();
|
||||
});
|
||||
it('clearPin removes the cached PIN', () => {
|
||||
setAuth('a.b.c', '1234', 'uid');
|
||||
clearPin();
|
||||
expect(getPin()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('auth — admin (sessionStorage) vs guest (localStorage) identity displacement', () => {
|
||||
// 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
|
||||
// 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)', () => {
|
||||
setAdminAuth(makeJwt({ role: 'admin' }), 'admin-uid', 'Admin');
|
||||
expect(getRole()).toBe('admin');
|
||||
// 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
|
||||
// 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)', () => {
|
||||
setAdminAuth(makeJwt({ role: 'admin' }), 'admin-uid', 'Admin');
|
||||
expect(getRole()).toBe('admin');
|
||||
|
||||
setAuth(makeJwt({ role: 'guest' }), '1234', 'guest-uid', 'Guest');
|
||||
expect(getRole()).toBe('guest'); // NOT admin
|
||||
expect(getUserId()).toBe('guest-uid');
|
||||
expect(sessionStorage.getItem('eventsnap_jwt')).toBeNull(); // admin token cleared
|
||||
});
|
||||
setAuth(makeJwt({ role: 'guest' }), '1234', 'guest-uid', 'Guest');
|
||||
expect(getRole()).toBe('guest'); // NOT admin
|
||||
expect(getUserId()).toBe('guest-uid');
|
||||
expect(sessionStorage.getItem('eventsnap_jwt')).toBeNull(); // admin token cleared
|
||||
});
|
||||
|
||||
it('an admin login displaces a resident guest session but keeps the guest PIN', () => {
|
||||
setAuth(makeJwt({ role: 'guest' }), '1234', 'guest-uid', 'Guest');
|
||||
setAdminAuth(makeJwt({ role: 'admin' }), 'admin-uid', 'Admin');
|
||||
expect(getRole()).toBe('admin');
|
||||
expect(getUserId()).toBe('admin-uid');
|
||||
expect(localStorage.getItem('eventsnap_jwt')).toBeNull(); // guest token cleared
|
||||
expect(getPin()).toBe('1234'); // PIN preserved for later recovery
|
||||
});
|
||||
it('an admin login displaces a resident guest session but keeps the guest PIN', () => {
|
||||
setAuth(makeJwt({ role: 'guest' }), '1234', 'guest-uid', 'Guest');
|
||||
setAdminAuth(makeJwt({ role: 'admin' }), 'admin-uid', 'Admin');
|
||||
expect(getRole()).toBe('admin');
|
||||
expect(getUserId()).toBe('admin-uid');
|
||||
expect(localStorage.getItem('eventsnap_jwt')).toBeNull(); // guest token cleared
|
||||
expect(getPin()).toBe('1234'); // PIN preserved for later recovery
|
||||
});
|
||||
|
||||
it('clearAuth wipes both stores', () => {
|
||||
setAdminAuth(makeJwt({ role: 'admin' }), 'admin-uid', 'Admin');
|
||||
clearAuth();
|
||||
expect(getToken()).toBeNull();
|
||||
expect(sessionStorage.getItem('eventsnap_jwt')).toBeNull();
|
||||
});
|
||||
it('clearAuth wipes both stores', () => {
|
||||
setAdminAuth(makeJwt({ role: 'admin' }), 'admin-uid', 'Admin');
|
||||
clearAuth();
|
||||
expect(getToken()).toBeNull();
|
||||
expect(sessionStorage.getItem('eventsnap_jwt')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('auth — JWT claim decode', () => {
|
||||
it('getExpiry decodes the exp claim (seconds → ms Date)', () => {
|
||||
const exp = 2_000_000_000; // far-future unix seconds
|
||||
setAuth(makeJwt({ exp }), null, 'u');
|
||||
expect(getExpiry()?.getTime()).toBe(exp * 1000);
|
||||
});
|
||||
it('getExpiry decodes the exp claim (seconds → ms Date)', () => {
|
||||
const exp = 2_000_000_000; // far-future unix seconds
|
||||
setAuth(makeJwt({ exp }), null, 'u');
|
||||
expect(getExpiry()?.getTime()).toBe(exp * 1000);
|
||||
});
|
||||
|
||||
it('getExpiry is null with no token, no exp claim, or a malformed token', () => {
|
||||
expect(getExpiry()).toBeNull(); // no token
|
||||
setAuth(makeJwt({ role: 'guest' }), null, 'u'); // token without exp
|
||||
expect(getExpiry()).toBeNull();
|
||||
localStorage.setItem('eventsnap_jwt', 'not-a-jwt'); // unparseable
|
||||
expect(getExpiry()).toBeNull();
|
||||
});
|
||||
it('getExpiry is null with no token, no exp claim, or a malformed token', () => {
|
||||
expect(getExpiry()).toBeNull(); // no token
|
||||
setAuth(makeJwt({ role: 'guest' }), null, 'u'); // token without exp
|
||||
expect(getExpiry()).toBeNull();
|
||||
localStorage.setItem('eventsnap_jwt', 'not-a-jwt'); // unparseable
|
||||
expect(getExpiry()).toBeNull();
|
||||
});
|
||||
|
||||
it('getRole extracts the role claim; null when absent or malformed', () => {
|
||||
setAuth(makeJwt({ role: 'host' }), null, 'u');
|
||||
expect(getRole()).toBe('host');
|
||||
setAuth(makeJwt({ exp: 1 }), null, 'u'); // no role claim
|
||||
expect(getRole()).toBeNull();
|
||||
localStorage.setItem('eventsnap_jwt', 'x.y.z'); // unparseable payload
|
||||
expect(getRole()).toBeNull();
|
||||
});
|
||||
it('getRole extracts the role claim; null when absent or malformed', () => {
|
||||
setAuth(makeJwt({ role: 'host' }), null, 'u');
|
||||
expect(getRole()).toBe('host');
|
||||
setAuth(makeJwt({ exp: 1 }), null, 'u'); // no role claim
|
||||
expect(getRole()).toBeNull();
|
||||
localStorage.setItem('eventsnap_jwt', 'x.y.z'); // unparseable payload
|
||||
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;
|
||||
// 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
|
||||
@@ -130,7 +135,11 @@ export function clearAuth(): void {
|
||||
// if you ever need ordering, introduce a priority field rather than relying
|
||||
// on import-load timing, which is fragile across refactors.
|
||||
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';
|
||||
|
||||
describe('avatarPalette', () => {
|
||||
it('returns the neutral palette for empty / nullish names', () => {
|
||||
expect(avatarPalette(null)).toContain('bg-gray-100');
|
||||
expect(avatarPalette(undefined)).toContain('bg-gray-100');
|
||||
expect(avatarPalette('')).toContain('bg-gray-100');
|
||||
});
|
||||
it('returns the neutral palette for empty / nullish names', () => {
|
||||
expect(avatarPalette(null)).toContain('bg-gray-100');
|
||||
expect(avatarPalette(undefined)).toContain('bg-gray-100');
|
||||
expect(avatarPalette('')).toContain('bg-gray-100');
|
||||
});
|
||||
|
||||
it('is deterministic for the same name', () => {
|
||||
// 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.
|
||||
expect(avatarPalette('Alice')).toBe(avatarPalette('Alice'));
|
||||
expect(avatarPalette('Alice')).toBe(avatarPalette('Ali' + 'ce'));
|
||||
expect(avatarPalette('Zoë Müller')).toBe(avatarPalette('Zoë Müller'));
|
||||
});
|
||||
it('is deterministic for the same name', () => {
|
||||
// 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.
|
||||
expect(avatarPalette('Alice')).toBe(avatarPalette('Alice'));
|
||||
expect(avatarPalette('Alice')).toBe(avatarPalette('Ali' + 'ce'));
|
||||
expect(avatarPalette('Zoë Müller')).toBe(avatarPalette('Zoë Müller'));
|
||||
});
|
||||
|
||||
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/);
|
||||
});
|
||||
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/);
|
||||
});
|
||||
|
||||
it('maps different names to different palette entries', () => {
|
||||
// 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
|
||||
// avatar in the app would render the same colour.
|
||||
expect(avatarPalette('Alice')).not.toBe(avatarPalette('Bob'));
|
||||
});
|
||||
it('maps different names to different palette entries', () => {
|
||||
// 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
|
||||
// avatar in the app would render the same colour.
|
||||
expect(avatarPalette('Alice')).not.toBe(avatarPalette('Bob'));
|
||||
});
|
||||
|
||||
it('spreads names across the whole palette, not just one bucket', () => {
|
||||
const names = [
|
||||
'Alice', 'Bob', 'Carol', 'Dave', 'Erin', 'Frank', 'Grace', 'Heidi',
|
||||
'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);
|
||||
});
|
||||
it('spreads names across the whole palette, not just one bucket', () => {
|
||||
const names = [
|
||||
'Alice',
|
||||
'Bob',
|
||||
'Carol',
|
||||
'Dave',
|
||||
'Erin',
|
||||
'Frank',
|
||||
'Grace',
|
||||
'Heidi',
|
||||
'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', () => {
|
||||
it('returns "?" for empty / nullish / whitespace-only names', () => {
|
||||
expect(initials(null)).toBe('?');
|
||||
expect(initials(undefined)).toBe('?');
|
||||
expect(initials('')).toBe('?');
|
||||
expect(initials(' ')).toBe('?');
|
||||
});
|
||||
it('returns "?" for empty / nullish / whitespace-only names', () => {
|
||||
expect(initials(null)).toBe('?');
|
||||
expect(initials(undefined)).toBe('?');
|
||||
expect(initials('')).toBe('?');
|
||||
expect(initials(' ')).toBe('?');
|
||||
});
|
||||
|
||||
it('uses the first letter (uppercased) for a single word', () => {
|
||||
expect(initials('alice')).toBe('A');
|
||||
});
|
||||
it('uses the first letter (uppercased) for a single word', () => {
|
||||
expect(initials('alice')).toBe('A');
|
||||
});
|
||||
|
||||
it('uses the first letters of the first two words', () => {
|
||||
expect(initials('Alice Bob Carol')).toBe('AB');
|
||||
});
|
||||
it('uses the first letters of the first two words', () => {
|
||||
expect(initials('Alice Bob Carol')).toBe('AB');
|
||||
});
|
||||
|
||||
it('collapses runs of whitespace between words', () => {
|
||||
expect(initials(' john doe ')).toBe('JD');
|
||||
});
|
||||
it('collapses runs of whitespace between words', () => {
|
||||
expect(initials(' john doe ')).toBe('JD');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,11 +30,17 @@
|
||||
<a
|
||||
href="/feed"
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
<span>Galerie</span>
|
||||
</a>
|
||||
@@ -47,9 +53,23 @@
|
||||
aria-label="Hochladen"
|
||||
>
|
||||
<!-- Camera + plus icon -->
|
||||
<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="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
|
||||
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="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>
|
||||
<!-- Badge -->
|
||||
{#if $uploadBadgeCount > 0}
|
||||
@@ -68,15 +88,28 @@
|
||||
href="/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
|
||||
{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"
|
||||
>
|
||||
<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="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
|
||||
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>
|
||||
<span>Export</span>
|
||||
{#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}
|
||||
</a>
|
||||
{/if}
|
||||
@@ -85,11 +118,17 @@
|
||||
<a
|
||||
href="/account"
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
<span>Konto</span>
|
||||
</a>
|
||||
|
||||
@@ -57,7 +57,8 @@
|
||||
}
|
||||
} catch (err) {
|
||||
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') {
|
||||
error = 'Keine Kamera gefunden.';
|
||||
} else {
|
||||
@@ -162,8 +163,18 @@
|
||||
{#if error}
|
||||
<div class="flex h-full items-center justify-center p-8">
|
||||
<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">
|
||||
<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
|
||||
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>
|
||||
<p class="text-sm text-white">{error}</p>
|
||||
<div class="mt-4 flex justify-center gap-2">
|
||||
@@ -173,10 +184,7 @@
|
||||
>
|
||||
Erneut versuchen
|
||||
</button>
|
||||
<button
|
||||
onclick={onclose}
|
||||
class="rounded-lg bg-white/20 px-4 py-2 text-sm text-white"
|
||||
>
|
||||
<button onclick={onclose} class="rounded-lg bg-white/20 px-4 py-2 text-sm text-white">
|
||||
Schließen
|
||||
</button>
|
||||
</div>
|
||||
@@ -193,7 +201,9 @@
|
||||
></video>
|
||||
|
||||
{#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>
|
||||
<span class="text-sm font-medium text-white">{formatRecordingTime(recordingTime)}</span>
|
||||
</div>
|
||||
@@ -218,7 +228,9 @@
|
||||
aria-selected={mode === 'photo'}
|
||||
onclick={() => setMode('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
|
||||
</button>
|
||||
@@ -228,7 +240,9 @@
|
||||
aria-selected={mode === 'video'}
|
||||
onclick={() => setMode('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
|
||||
</button>
|
||||
@@ -236,7 +250,10 @@
|
||||
</div>
|
||||
{/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 -->
|
||||
<button
|
||||
onclick={onclose}
|
||||
@@ -244,7 +261,12 @@
|
||||
aria-label="Schließen"
|
||||
>
|
||||
<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>
|
||||
</button>
|
||||
|
||||
@@ -252,8 +274,14 @@
|
||||
<button
|
||||
onclick={handleShutter}
|
||||
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'}"
|
||||
aria-label={mode === 'photo' ? 'Foto aufnehmen' : recording ? 'Aufnahme stoppen' : 'Video aufnehmen'}
|
||||
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'}"
|
||||
aria-label={mode === 'photo'
|
||||
? 'Foto aufnehmen'
|
||||
: recording
|
||||
? 'Aufnahme stoppen'
|
||||
: 'Video aufnehmen'}
|
||||
>
|
||||
{#if mode === 'photo'}
|
||||
<div class="h-12 w-12 rounded-full bg-white"></div>
|
||||
@@ -274,7 +302,12 @@
|
||||
aria-label="Kamera wechseln"
|
||||
>
|
||||
<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>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
@@ -80,12 +80,16 @@
|
||||
onclick={handleConfirm}
|
||||
disabled={busy}
|
||||
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-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}
|
||||
<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}
|
||||
{confirmLabel}
|
||||
</button>
|
||||
|
||||
@@ -63,7 +63,11 @@
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => window.removeEventListener('keydown', onKeyDown);
|
||||
} else if (returnFocus) {
|
||||
try { returnFocus.focus({ preventScroll: true }); } catch { /* element gone */ }
|
||||
try {
|
||||
returnFocus.focus({ preventScroll: true });
|
||||
} catch {
|
||||
/* element gone */
|
||||
}
|
||||
returnFocus = null;
|
||||
}
|
||||
});
|
||||
@@ -115,7 +119,9 @@
|
||||
</div>
|
||||
|
||||
{#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}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
@@ -18,14 +18,7 @@
|
||||
oncontextmenu?: (upload: FeedUpload) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
upload,
|
||||
isOwn = false,
|
||||
onlike,
|
||||
oncomment,
|
||||
onselect,
|
||||
oncontextmenu
|
||||
}: Props = $props();
|
||||
let { upload, isOwn = false, onlike, oncomment, onselect, oncontextmenu }: Props = $props();
|
||||
|
||||
function isVideo(mime: string): boolean {
|
||||
return mime.startsWith('video/');
|
||||
@@ -88,7 +81,9 @@
|
||||
{initials(upload.uploader_name)}
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -97,11 +92,20 @@
|
||||
<!-- Desktop kebab — same actions as the mobile long-press context sheet. -->
|
||||
<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"
|
||||
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>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -112,7 +116,9 @@
|
||||
use:doubletap
|
||||
onsingletap={() => onselect(upload)}
|
||||
ondoubletap={handleDoubleTap}
|
||||
onclick={(e) => { if (e.detail === 0) onselect(upload); }}
|
||||
onclick={(e) => {
|
||||
if (e.detail === 0) onselect(upload);
|
||||
}}
|
||||
aria-label="Bild vergrößern"
|
||||
>
|
||||
<HeartBurst active={heartBurst} />
|
||||
@@ -128,7 +134,9 @@
|
||||
/>
|
||||
{/if}
|
||||
<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">
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
@@ -149,9 +157,21 @@
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex aspect-square w-full items-center justify-center bg-gray-100 dark:bg-gray-800">
|
||||
<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" />
|
||||
<div
|
||||
class="flex aspect-square w-full items-center justify-center bg-gray-100 dark:bg-gray-800"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -160,11 +180,16 @@
|
||||
<!-- Actions row -->
|
||||
<div class="flex items-center gap-4 px-4 py-2">
|
||||
<button
|
||||
onclick={() => { vibrate(10); onlike(upload.id); }}
|
||||
onclick={() => {
|
||||
vibrate(10);
|
||||
onlike(upload.id);
|
||||
}}
|
||||
aria-pressed={upload.liked_by_me}
|
||||
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
|
||||
{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
|
||||
class="h-5 w-5 {upload.liked_by_me ? 'fill-red-500' : ''}"
|
||||
@@ -173,7 +198,11 @@
|
||||
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" />
|
||||
<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>
|
||||
{upload.like_count}
|
||||
</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"
|
||||
>
|
||||
<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>
|
||||
{upload.comment_count}
|
||||
</button>
|
||||
|
||||
@@ -18,11 +18,10 @@
|
||||
<button
|
||||
onclick={() => onselect(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 {
|
||||
selected === null
|
||||
? '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'
|
||||
}"
|
||||
class="inline-flex min-h-11 shrink-0 items-center rounded-full px-4 py-2 text-sm font-medium transition {selected ===
|
||||
null
|
||||
? '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'}"
|
||||
>
|
||||
Alle
|
||||
</button>
|
||||
@@ -30,11 +29,10 @@
|
||||
<button
|
||||
onclick={() => onselect(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 {
|
||||
selected === h.tag
|
||||
? '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'
|
||||
}"
|
||||
class="inline-flex min-h-11 shrink-0 items-center rounded-full px-4 py-2 text-sm font-medium transition {selected ===
|
||||
h.tag
|
||||
? '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'}"
|
||||
>
|
||||
#{h.tag}
|
||||
<span class="ml-1 text-xs opacity-70">{h.count}</span>
|
||||
|
||||
@@ -5,15 +5,6 @@
|
||||
let { active }: Props = $props();
|
||||
</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}
|
||||
<span
|
||||
class="pointer-events-none absolute inset-0 flex items-center justify-center"
|
||||
@@ -26,7 +17,30 @@
|
||||
fill="currentColor"
|
||||
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>
|
||||
</span>
|
||||
{/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 {
|
||||
const { comment_id } = JSON.parse(data) as { comment_id: string };
|
||||
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
|
||||
@@ -66,7 +68,9 @@
|
||||
try {
|
||||
const { upload_id } = JSON.parse(data) as { upload_id: string };
|
||||
if (upload_id === upload.id) void loadComments(upload.id);
|
||||
} catch { /* ignore */ }
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
@@ -124,7 +128,10 @@
|
||||
|
||||
function formatTime(iso: string): string {
|
||||
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>
|
||||
@@ -138,7 +145,9 @@
|
||||
use:scrollLock
|
||||
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 -->
|
||||
<div class="relative bg-black">
|
||||
<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"
|
||||
>
|
||||
<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>
|
||||
</button>
|
||||
<div
|
||||
class="relative"
|
||||
use:doubletap
|
||||
ondoubletap={triggerHeartBurst}
|
||||
>
|
||||
<div class="relative" use:doubletap ondoubletap={triggerHeartBurst}>
|
||||
{#if isVideo(upload.mime_type)}
|
||||
<video
|
||||
src={mediaSrc}
|
||||
@@ -180,19 +190,31 @@
|
||||
<div class="border-b border-gray-100 p-3 dark:border-gray-800">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<span id="lightbox-title" class="font-medium text-gray-900 dark:text-gray-100">{upload.uploader_name}</span>
|
||||
<span class="ml-2 text-xs text-gray-400 dark:text-gray-500">{formatTime(upload.created_at)}</span>
|
||||
<span id="lightbox-title" class="font-medium text-gray-900 dark:text-gray-100"
|
||||
>{upload.uploader_name}</span
|
||||
>
|
||||
<span class="ml-2 text-xs text-gray-400 dark:text-gray-500"
|
||||
>{formatTime(upload.created_at)}</span
|
||||
>
|
||||
</div>
|
||||
<button
|
||||
onclick={() => onlike(upload.id)}
|
||||
class="flex items-center gap-1 rounded-full px-2.5 py-1 text-sm transition {
|
||||
upload.liked_by_me
|
||||
? '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'
|
||||
}"
|
||||
class="flex items-center gap-1 rounded-full px-2.5 py-1 text-sm transition {upload.liked_by_me
|
||||
? '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'}"
|
||||
>
|
||||
<svg 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
|
||||
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>
|
||||
{upload.like_count}
|
||||
</button>
|
||||
@@ -211,9 +233,13 @@
|
||||
{#each comments as comment (comment.id)}
|
||||
<div class="flex items-start gap-2">
|
||||
<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>
|
||||
<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>
|
||||
{#if comment.user_id === userId}
|
||||
<button
|
||||
@@ -222,7 +248,12 @@
|
||||
aria-label="Löschen"
|
||||
>
|
||||
<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>
|
||||
</button>
|
||||
{/if}
|
||||
@@ -234,7 +265,10 @@
|
||||
|
||||
<!-- Comment input -->
|
||||
<form
|
||||
onsubmit={(e) => { e.preventDefault(); submitComment(); }}
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
submitComment();
|
||||
}}
|
||||
class="border-t border-gray-100 p-3 dark:border-gray-800"
|
||||
>
|
||||
<div class="flex gap-2">
|
||||
|
||||
@@ -19,14 +19,7 @@
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
open,
|
||||
titleId,
|
||||
ariaLabel,
|
||||
onClose,
|
||||
closeOnBackdrop = true,
|
||||
children
|
||||
}: Props = $props();
|
||||
let { open, titleId, ariaLabel, onClose, closeOnBackdrop = true, children }: Props = $props();
|
||||
|
||||
$effect(() => {
|
||||
if (open && !titleId && !ariaLabel && typeof console !== 'undefined') {
|
||||
@@ -56,7 +49,9 @@
|
||||
use:focusTrap={{ onclose: onClose }}
|
||||
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()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -63,10 +63,15 @@
|
||||
// Derived in the script so TypeScript can narrow on `.kind` inside the template.
|
||||
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: 'light', label: 'Hell', hint: 'Heller Hintergrund', icon: '☀️' },
|
||||
{ value: 'dark', label: 'Dunkel', hint: 'Dunkler Hintergrund', icon: '🌙' }
|
||||
{ value: 'light', label: 'Hell', hint: 'Heller Hintergrund', icon: '☀️' },
|
||||
{ value: 'dark', label: 'Dunkel', hint: 'Dunkler Hintergrund', icon: '🌙' }
|
||||
];
|
||||
|
||||
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
|
||||
the touch target is padded to ~44 px so it remains tappable on mobile. -->
|
||||
<div class="mb-3 flex justify-center">
|
||||
{#each steps as _, i}
|
||||
{#each steps as _, i (i)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => goToStep(i)}
|
||||
@@ -148,8 +153,8 @@
|
||||
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
|
||||
{selected
|
||||
? '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-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'}"
|
||||
>
|
||||
<span class="text-2xl leading-none">{opt.icon}</span>
|
||||
<div class="flex-1">
|
||||
@@ -159,11 +164,17 @@
|
||||
<span
|
||||
class="flex h-5 w-5 shrink-0 items-center justify-center rounded-full border-2
|
||||
{selected
|
||||
? 'border-blue-600 bg-blue-600 dark:border-blue-500 dark:bg-blue-500'
|
||||
: 'border-gray-300 dark:border-gray-600'}"
|
||||
? 'border-blue-600 bg-blue-600 dark:border-blue-500 dark:bg-blue-500'
|
||||
: 'border-gray-300 dark:border-gray-600'}"
|
||||
>
|
||||
{#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" />
|
||||
</svg>
|
||||
{/if}
|
||||
@@ -186,7 +197,7 @@
|
||||
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"
|
||||
>
|
||||
{step < steps.length - 1 ? 'Weiter' : 'Los geht\'s!'}
|
||||
{step < steps.length - 1 ? 'Weiter' : "Los geht's!"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -27,7 +27,9 @@
|
||||
<button
|
||||
type="button"
|
||||
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'}
|
||||
aria-live={t.tone === 'error' || t.tone === 'warning' ? 'assertive' : 'polite'}
|
||||
data-testid="toast"
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
<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';
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
@@ -10,21 +17,31 @@
|
||||
|
||||
function statusLabel(status: QueueItem['status']): string {
|
||||
switch (status) {
|
||||
case 'pending': return 'Wartend';
|
||||
case 'uploading': return 'Wird hochgeladen';
|
||||
case 'done': return 'Fertig';
|
||||
case 'error': return 'Fehler';
|
||||
case 'blocked': return 'Gesperrt';
|
||||
case 'pending':
|
||||
return 'Wartend';
|
||||
case 'uploading':
|
||||
return 'Wird hochgeladen';
|
||||
case 'done':
|
||||
return 'Fertig';
|
||||
case 'error':
|
||||
return 'Fehler';
|
||||
case 'blocked':
|
||||
return 'Gesperrt';
|
||||
}
|
||||
}
|
||||
|
||||
function statusColor(status: QueueItem['status']): string {
|
||||
switch (status) {
|
||||
case 'pending': return 'text-gray-500 dark:text-gray-400';
|
||||
case 'uploading': return 'text-blue-600 dark:text-blue-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';
|
||||
case 'pending':
|
||||
return 'text-gray-500 dark:text-gray-400';
|
||||
case 'uploading':
|
||||
return 'text-blue-600 dark:text-blue-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>
|
||||
|
||||
{#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 class="flex items-center justify-between border-b border-gray-100 px-4 py-3 dark:border-gray-800">
|
||||
<div
|
||||
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">
|
||||
Upload-Warteschlange
|
||||
{#if $isProcessing}
|
||||
@@ -71,7 +92,9 @@
|
||||
</div>
|
||||
|
||||
{#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.
|
||||
</div>
|
||||
{/if}
|
||||
@@ -81,7 +104,9 @@
|
||||
<li class="px-4 py-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<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>
|
||||
</div>
|
||||
<div class="ml-3 flex items-center gap-2">
|
||||
@@ -103,7 +128,12 @@
|
||||
aria-label="Entfernen"
|
||||
>
|
||||
<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>
|
||||
</button>
|
||||
{/if}
|
||||
@@ -111,7 +141,9 @@
|
||||
</div>
|
||||
|
||||
{#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
|
||||
class="h-full rounded-full bg-blue-500 transition-all duration-300"
|
||||
style="width: {item.progress}%"
|
||||
|
||||
@@ -59,7 +59,11 @@
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => window.removeEventListener('keydown', onKeyDown);
|
||||
} else if (returnFocus) {
|
||||
try { returnFocus.focus({ preventScroll: true }); } catch { /* element gone */ }
|
||||
try {
|
||||
returnFocus.focus({ preventScroll: true });
|
||||
} catch {
|
||||
/* element gone */
|
||||
}
|
||||
returnFocus = null;
|
||||
}
|
||||
});
|
||||
@@ -170,46 +174,74 @@
|
||||
Schließen
|
||||
</button>
|
||||
{:else}
|
||||
<!-- Gallery option -->
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<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">
|
||||
<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" />
|
||||
</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>
|
||||
<!-- Gallery option -->
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<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"
|
||||
/>
|
||||
</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 -->
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<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">
|
||||
<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="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>
|
||||
<!-- Camera option -->
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<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="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 -->
|
||||
<button
|
||||
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"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<!-- Cancel -->
|
||||
<button
|
||||
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"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -35,8 +35,7 @@
|
||||
oncontextmenu?: (upload: FeedUpload) => void;
|
||||
}
|
||||
|
||||
let { uploads, mode, myUserId, onlike, oncomment, onselect, oncontextmenu }: Props =
|
||||
$props();
|
||||
let { uploads, mode, myUserId, onlike, oncomment, onselect, oncontextmenu }: Props = $props();
|
||||
|
||||
const COLS = 3;
|
||||
const GRID_GAP = 2; // px — matches the `gap-0.5` the non-virtual grid used.
|
||||
@@ -218,7 +217,11 @@
|
||||
/>
|
||||
{/if}
|
||||
<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" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
@@ -2,29 +2,29 @@ import { describe, it, expect } from 'vitest';
|
||||
import { pickMediaUrl } from './data-mode-store';
|
||||
|
||||
const upload = {
|
||||
id: 'abc-123',
|
||||
preview_url: '/api/v1/upload/abc-123/preview',
|
||||
thumbnail_url: '/api/v1/upload/abc-123/thumbnail',
|
||||
id: 'abc-123',
|
||||
preview_url: '/api/v1/upload/abc-123/preview',
|
||||
thumbnail_url: '/api/v1/upload/abc-123/thumbnail'
|
||||
};
|
||||
|
||||
describe('pickMediaUrl', () => {
|
||||
it('original mode → the original API route, ignoring preview/thumbnail', () => {
|
||||
expect(pickMediaUrl('original', upload)).toBe('/api/v1/upload/abc-123/original');
|
||||
});
|
||||
it('original mode → the original API route, ignoring preview/thumbnail', () => {
|
||||
expect(pickMediaUrl('original', upload)).toBe('/api/v1/upload/abc-123/original');
|
||||
});
|
||||
|
||||
it('saver mode → preview_url when present', () => {
|
||||
expect(pickMediaUrl('saver', upload)).toBe('/api/v1/upload/abc-123/preview');
|
||||
});
|
||||
it('saver mode → preview_url when present', () => {
|
||||
expect(pickMediaUrl('saver', upload)).toBe('/api/v1/upload/abc-123/preview');
|
||||
});
|
||||
|
||||
it('saver mode → thumbnail_url when preview is null', () => {
|
||||
expect(pickMediaUrl('saver', { ...upload, preview_url: null })).toBe(
|
||||
'/api/v1/upload/abc-123/thumbnail'
|
||||
);
|
||||
});
|
||||
it('saver mode → thumbnail_url when preview is null', () => {
|
||||
expect(pickMediaUrl('saver', { ...upload, preview_url: null })).toBe(
|
||||
'/api/v1/upload/abc-123/thumbnail'
|
||||
);
|
||||
});
|
||||
|
||||
it('saver mode → original route when both preview and thumbnail are null', () => {
|
||||
expect(pickMediaUrl('saver', { id: 'x', preview_url: null, thumbnail_url: null })).toBe(
|
||||
'/api/v1/upload/x/original'
|
||||
);
|
||||
});
|
||||
it('saver mode → original route when both preview and thumbnail are null', () => {
|
||||
expect(pickMediaUrl('saver', { id: 'x', preview_url: null, thumbnail_url: null })).toBe(
|
||||
'/api/v1/upload/x/original'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -46,9 +46,7 @@ export class SlideQueue {
|
||||
// 2. Refill shuffle queue from `allKnown` minus recently shown.
|
||||
if (this.shuffleQueue.length === 0) {
|
||||
this.shuffleQueue = shuffle(
|
||||
Array.from(this.allKnown.values()).filter(
|
||||
(s) => !this.recentlyShown.includes(s.id)
|
||||
)
|
||||
Array.from(this.allKnown.values()).filter((s) => !this.recentlyShown.includes(s.id))
|
||||
);
|
||||
// If everything is recently shown (small event), fall back to the full pool.
|
||||
if (this.shuffleQueue.length === 0) {
|
||||
|
||||
@@ -20,14 +20,7 @@
|
||||
>
|
||||
{#if isVideo}
|
||||
<!-- svelte-ignore a11y_media_has_caption -->
|
||||
<video
|
||||
{src}
|
||||
autoplay
|
||||
muted
|
||||
playsinline
|
||||
{onended}
|
||||
class="h-full w-full object-contain"
|
||||
></video>
|
||||
<video {src} autoplay muted playsinline {onended} class="h-full w-full object-contain"></video>
|
||||
{:else}
|
||||
<img {src} alt="" class="h-full w-full object-contain" />
|
||||
{/if}
|
||||
@@ -35,7 +28,11 @@
|
||||
|
||||
<style>
|
||||
@keyframes crossfade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -24,14 +24,7 @@
|
||||
>
|
||||
{#if isVideo}
|
||||
<!-- svelte-ignore a11y_media_has_caption -->
|
||||
<video
|
||||
{src}
|
||||
autoplay
|
||||
muted
|
||||
playsinline
|
||||
{onended}
|
||||
class="h-full w-full object-contain"
|
||||
></video>
|
||||
<video {src} autoplay muted playsinline {onended} class="h-full w-full object-contain"></video>
|
||||
{:else}
|
||||
<img
|
||||
{src}
|
||||
@@ -44,11 +37,19 @@
|
||||
|
||||
<style>
|
||||
@keyframes kb-fade {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@keyframes kb-zoom {
|
||||
from { transform: scale(1.0); }
|
||||
to { transform: scale(1.1); }
|
||||
from {
|
||||
transform: scale(1);
|
||||
}
|
||||
to {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -13,7 +13,9 @@ let sentinel: SentinelLike | null = null;
|
||||
let visibilityHandler: (() => void) | null = null;
|
||||
|
||||
export async function acquireWakeLock(): Promise<void> {
|
||||
const wakeLock = (navigator as Navigator & { wakeLock?: { request: (t: string) => Promise<SentinelLike> } }).wakeLock;
|
||||
const wakeLock = (
|
||||
navigator as Navigator & { wakeLock?: { request: (t: string) => Promise<SentinelLike> } }
|
||||
).wakeLock;
|
||||
if (!wakeLock) return;
|
||||
try {
|
||||
sentinel = await wakeLock.request('screen');
|
||||
|
||||
@@ -41,14 +41,26 @@ export function initExportStatus(): void {
|
||||
if (hydrated) return;
|
||||
hydrated = true;
|
||||
void refreshExportStatus();
|
||||
sseUnsubs.push(onSseEvent('export-progress', () => { void refreshExportStatus(); }));
|
||||
sseUnsubs.push(onSseEvent('export-available', () => { void refreshExportStatus(); }));
|
||||
sseUnsubs.push(
|
||||
onSseEvent('export-progress', () => {
|
||||
void refreshExportStatus();
|
||||
})
|
||||
);
|
||||
sseUnsubs.push(
|
||||
onSseEvent('export-available', () => {
|
||||
void refreshExportStatus();
|
||||
})
|
||||
);
|
||||
// A reopen INVALIDATES the released keepsake server-side (it clears `export_released_at` and
|
||||
// both ready flags). Without refreshing here, the nav would keep advertising a download that
|
||||
// now 404s — the host reopens to collect more photos and every guest still sees "keepsake
|
||||
// ready" until a manual reload. `event-opened` is the only signal for this: a superseded
|
||||
// worker deliberately emits no `export-progress`.
|
||||
sseUnsubs.push(onSseEvent('event-opened', () => { void refreshExportStatus(); }));
|
||||
sseUnsubs.push(
|
||||
onSseEvent('event-opened', () => {
|
||||
void refreshExportStatus();
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export function teardownExportStatus(): void {
|
||||
|
||||
@@ -10,7 +10,7 @@ const uploads = [
|
||||
u('2', 'Bob', 'party #party'),
|
||||
u('3', 'Alice', 'more #wedding #party'),
|
||||
u('4', 'Carol', 'no tags here'),
|
||||
u('5', 'Bob', null), // no caption
|
||||
u('5', 'Bob', null) // no caption
|
||||
];
|
||||
|
||||
const ids = (r: any[]) => r.map((x) => x.id);
|
||||
|
||||
@@ -3,5 +3,9 @@
|
||||
export function vibrate(pattern: number | number[]): void {
|
||||
if (typeof navigator === 'undefined') return;
|
||||
if (typeof navigator.vibrate !== 'function') return;
|
||||
try { navigator.vibrate(pattern); } catch { /* permission denied / not supported */ }
|
||||
try {
|
||||
navigator.vibrate(pattern);
|
||||
} catch {
|
||||
/* permission denied / not supported */
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,9 +113,7 @@ export function connectSse(): void {
|
||||
};
|
||||
|
||||
for (const eventName of KNOWN_EVENTS) {
|
||||
eventSource.addEventListener(eventName, (e) =>
|
||||
dispatch(eventName, (e as MessageEvent).data)
|
||||
);
|
||||
eventSource.addEventListener(eventName, (e) => dispatch(eventName, (e as MessageEvent).data));
|
||||
}
|
||||
|
||||
// `resync` is emitted by the server when our broadcast subscription fell
|
||||
@@ -201,9 +199,7 @@ function extractCreatedAt(data: string): string | undefined {
|
||||
*/
|
||||
async function deltaFetchAndFan(since: string, attempt = 0): Promise<void> {
|
||||
try {
|
||||
const response = await api.get<DeltaResponse>(
|
||||
`/feed/delta?since=${encodeURIComponent(since)}`
|
||||
);
|
||||
const response = await api.get<DeltaResponse>(`/feed/delta?since=${encodeURIComponent(since)}`);
|
||||
// Advance the cursor to the server clock this delta was computed at, so the next
|
||||
// reconnect resumes exactly where the server left off (no browser-clock skew).
|
||||
lastEventTime = response.server_time;
|
||||
|
||||
@@ -20,14 +20,20 @@ export function toast(message: string, tone: ToastTone = 'info', ttl = 4000): nu
|
||||
const entry: Toast = { id, message, tone, ttl };
|
||||
toasts.update((list) => [...list, entry]);
|
||||
if (ttl > 0 && typeof window !== 'undefined') {
|
||||
timers.set(id, setTimeout(() => dismissToast(id), ttl));
|
||||
timers.set(
|
||||
id,
|
||||
setTimeout(() => dismissToast(id), ttl)
|
||||
);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
export function dismissToast(id: number): void {
|
||||
const t = timers.get(id);
|
||||
if (t) { clearTimeout(t); timers.delete(id); }
|
||||
if (t) {
|
||||
clearTimeout(t);
|
||||
timers.delete(id);
|
||||
}
|
||||
toasts.update((list) => list.filter((x) => x.id !== id));
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ export const showBottomNav = writable(true);
|
||||
export const uploadSheetOpen = writable(false);
|
||||
|
||||
// Count of items currently pending or uploading — shown as FAB badge.
|
||||
export const uploadBadgeCount = derived(queueItems, ($items) =>
|
||||
$items.filter((i) => i.status === 'pending' || i.status === 'uploading').length
|
||||
export const uploadBadgeCount = derived(
|
||||
queueItems,
|
||||
($items) => $items.filter((i) => i.status === 'pending' || i.status === 'uploading').length
|
||||
);
|
||||
|
||||
@@ -9,36 +9,36 @@ import { classifyUploadStatus, isReversibleLock, entryToQueueItem } from './uplo
|
||||
* fired against a dead session. 401 must be `auth` (blob kept, re-auth), NEVER `terminal`.
|
||||
*/
|
||||
describe('classifyUploadStatus', () => {
|
||||
it('2xx → success', () => {
|
||||
expect(classifyUploadStatus(200)).toBe('success');
|
||||
expect(classifyUploadStatus(201)).toBe('success');
|
||||
expect(classifyUploadStatus(299)).toBe('success');
|
||||
});
|
||||
it('2xx → success', () => {
|
||||
expect(classifyUploadStatus(200)).toBe('success');
|
||||
expect(classifyUploadStatus(201)).toBe('success');
|
||||
expect(classifyUploadStatus(299)).toBe('success');
|
||||
});
|
||||
|
||||
it('401 → auth, NOT terminal (never purge the blob on a dead session)', () => {
|
||||
expect(classifyUploadStatus(401)).toBe('auth');
|
||||
});
|
||||
it('401 → auth, NOT terminal (never purge the blob on a dead session)', () => {
|
||||
expect(classifyUploadStatus(401)).toBe('auth');
|
||||
});
|
||||
|
||||
it('429 → rate_limit (back off, auto-resume)', () => {
|
||||
expect(classifyUploadStatus(429)).toBe('rate_limit');
|
||||
});
|
||||
it('429 → rate_limit (back off, auto-resume)', () => {
|
||||
expect(classifyUploadStatus(429)).toBe('rate_limit');
|
||||
});
|
||||
|
||||
it('408 → transient (request timeout is retryable, not terminal)', () => {
|
||||
expect(classifyUploadStatus(408)).toBe('transient');
|
||||
});
|
||||
it('408 → transient (request timeout is retryable, not terminal)', () => {
|
||||
expect(classifyUploadStatus(408)).toBe('transient');
|
||||
});
|
||||
|
||||
it('genuinely permanent 4xx → terminal (locked / banned / released / quota)', () => {
|
||||
expect(classifyUploadStatus(403)).toBe('terminal'); // banned / locked / released
|
||||
expect(classifyUploadStatus(413)).toBe('terminal'); // quota exhausted
|
||||
expect(classifyUploadStatus(400)).toBe('terminal');
|
||||
expect(classifyUploadStatus(404)).toBe('terminal');
|
||||
});
|
||||
it('genuinely permanent 4xx → terminal (locked / banned / released / quota)', () => {
|
||||
expect(classifyUploadStatus(403)).toBe('terminal'); // banned / locked / released
|
||||
expect(classifyUploadStatus(413)).toBe('terminal'); // quota exhausted
|
||||
expect(classifyUploadStatus(400)).toBe('terminal');
|
||||
expect(classifyUploadStatus(404)).toBe('terminal');
|
||||
});
|
||||
|
||||
it('5xx / unexpected → transient (retryable, blob kept)', () => {
|
||||
expect(classifyUploadStatus(500)).toBe('transient');
|
||||
expect(classifyUploadStatus(502)).toBe('transient');
|
||||
expect(classifyUploadStatus(503)).toBe('transient');
|
||||
});
|
||||
it('5xx / unexpected → transient (retryable, blob kept)', () => {
|
||||
expect(classifyUploadStatus(500)).toBe('transient');
|
||||
expect(classifyUploadStatus(502)).toBe('transient');
|
||||
expect(classifyUploadStatus(503)).toBe('transient');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -48,27 +48,27 @@ describe('classifyUploadStatus', () => {
|
||||
* loses a photo the guest expected to survive a reopen, or lets a banned device retry forever.
|
||||
*/
|
||||
describe('isReversibleLock', () => {
|
||||
it('an `uploads_locked` code is reversible at any status (event closed / released)', () => {
|
||||
expect(isReversibleLock(403, 'uploads_locked')).toBe(true);
|
||||
expect(isReversibleLock(409, 'uploads_locked')).toBe(true);
|
||||
});
|
||||
it('an `uploads_locked` code is reversible at any status (event closed / released)', () => {
|
||||
expect(isReversibleLock(403, 'uploads_locked')).toBe(true);
|
||||
expect(isReversibleLock(409, 'uploads_locked')).toBe(true);
|
||||
});
|
||||
|
||||
it('a `forbidden` 403 (banned) is PERMANENT — purge, never resume', () => {
|
||||
expect(isReversibleLock(403, 'forbidden')).toBe(false);
|
||||
});
|
||||
it('a `forbidden` 403 (banned) is PERMANENT — purge, never resume', () => {
|
||||
expect(isReversibleLock(403, 'forbidden')).toBe(false);
|
||||
});
|
||||
|
||||
it('an unidentifiable 403 (unparseable proxy/WAF/captive-portal body) is treated reversible', () => {
|
||||
// Losing a photo is the worst outcome; 403 is the reversible-lock status here.
|
||||
expect(isReversibleLock(403, undefined)).toBe(true);
|
||||
expect(isReversibleLock(403, null)).toBe(true);
|
||||
expect(isReversibleLock(403, '')).toBe(true);
|
||||
});
|
||||
it('an unidentifiable 403 (unparseable proxy/WAF/captive-portal body) is treated reversible', () => {
|
||||
// Losing a photo is the worst outcome; 403 is the reversible-lock status here.
|
||||
expect(isReversibleLock(403, undefined)).toBe(true);
|
||||
expect(isReversibleLock(403, null)).toBe(true);
|
||||
expect(isReversibleLock(403, '')).toBe(true);
|
||||
});
|
||||
|
||||
it('a non-403 permanent 4xx (e.g. 413 quota) is NOT reversible unless explicitly locked', () => {
|
||||
expect(isReversibleLock(413, undefined)).toBe(false);
|
||||
expect(isReversibleLock(400, 'bad_request')).toBe(false);
|
||||
expect(isReversibleLock(413, 'uploads_locked')).toBe(true); // explicit tag still wins
|
||||
});
|
||||
it('a non-403 permanent 4xx (e.g. 413 quota) is NOT reversible unless explicitly locked', () => {
|
||||
expect(isReversibleLock(413, undefined)).toBe(false);
|
||||
expect(isReversibleLock(400, 'bad_request')).toBe(false);
|
||||
expect(isReversibleLock(413, 'uploads_locked')).toBe(true); // explicit tag still wins
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -78,32 +78,32 @@ describe('isReversibleLock', () => {
|
||||
* re-selecting the same file after a reload would MISS the duplicate and queue it twice.
|
||||
*/
|
||||
describe('entryToQueueItem', () => {
|
||||
const base = {
|
||||
id: 'e1',
|
||||
userId: 'u1',
|
||||
fileName: 'photo.jpg',
|
||||
fileSize: 1234,
|
||||
lastModified: 1_700_000_000_000,
|
||||
mimeType: 'image/jpeg',
|
||||
status: 'pending' as const
|
||||
};
|
||||
const base = {
|
||||
id: 'e1',
|
||||
userId: 'u1',
|
||||
fileName: 'photo.jpg',
|
||||
fileSize: 1234,
|
||||
lastModified: 1_700_000_000_000,
|
||||
mimeType: 'image/jpeg',
|
||||
status: 'pending' as const
|
||||
};
|
||||
|
||||
it('carries lastModified across rehydration (dedup depends on it)', () => {
|
||||
expect(entryToQueueItem(base).lastModified).toBe(1_700_000_000_000);
|
||||
});
|
||||
it('carries lastModified across rehydration (dedup depends on it)', () => {
|
||||
expect(entryToQueueItem(base).lastModified).toBe(1_700_000_000_000);
|
||||
});
|
||||
|
||||
it('downgrades an interrupted `uploading` entry to `pending` so it resumes', () => {
|
||||
expect(entryToQueueItem({ ...base, status: 'uploading' }).status).toBe('pending');
|
||||
});
|
||||
it('downgrades an interrupted `uploading` entry to `pending` so it resumes', () => {
|
||||
expect(entryToQueueItem({ ...base, status: 'uploading' }).status).toBe('pending');
|
||||
});
|
||||
|
||||
it('a `done` entry reports 100% progress; others start at 0', () => {
|
||||
expect(entryToQueueItem({ ...base, status: 'done' }).progress).toBe(100);
|
||||
expect(entryToQueueItem(base).progress).toBe(0);
|
||||
});
|
||||
it('a `done` entry reports 100% progress; others start at 0', () => {
|
||||
expect(entryToQueueItem({ ...base, status: 'done' }).progress).toBe(100);
|
||||
expect(entryToQueueItem(base).progress).toBe(0);
|
||||
});
|
||||
|
||||
it('defaults caption/hashtags to empty strings', () => {
|
||||
const item = entryToQueueItem(base);
|
||||
expect(item.caption).toBe('');
|
||||
expect(item.hashtags).toBe('');
|
||||
});
|
||||
it('defaults caption/hashtags to empty strings', () => {
|
||||
const item = entryToQueueItem(base);
|
||||
expect(item.caption).toBe('');
|
||||
expect(item.hashtags).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -97,7 +97,9 @@ async function requeueRetriable(): Promise<void> {
|
||||
}
|
||||
queueItems.update((items) =>
|
||||
items.map((item) =>
|
||||
item.status === 'error' ? { ...item, status: 'pending' as const, progress: 0, error: undefined } : item
|
||||
item.status === 'error'
|
||||
? { ...item, status: 'pending' as const, progress: 0, error: undefined }
|
||||
: item
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -637,11 +639,7 @@ async function uploadItem(id: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
function updateItemStatus(
|
||||
id: string,
|
||||
status: QueueItem['status'],
|
||||
error?: string
|
||||
): void {
|
||||
function updateItemStatus(id: string, status: QueueItem['status'], error?: string): void {
|
||||
queueItems.update((items) =>
|
||||
items.map((item) =>
|
||||
item.id === id
|
||||
|
||||
Reference in New Issue
Block a user