refactor(guest): retire legacy guest app and move shared modules
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled

This commit is contained in:
Codex Agent
2026-02-06 08:42:53 +01:00
parent b14435df8b
commit 0a08f2704f
191 changed files with 243 additions and 12631 deletions

View File

@@ -1,44 +0,0 @@
import { describe, expect, it } from 'vitest';
import { isUploadPath, shouldShowAnalyticsNudge } from '../analyticsConsent';
describe('isUploadPath', () => {
it('detects upload routes', () => {
expect(isUploadPath('/e/abc/upload')).toBe(true);
expect(isUploadPath('/e/abc/upload/queue')).toBe(true);
});
it('ignores non-upload routes', () => {
expect(isUploadPath('/e/abc/gallery')).toBe(false);
expect(isUploadPath('/settings')).toBe(false);
});
});
describe('shouldShowAnalyticsNudge', () => {
const baseState = {
decisionMade: false,
analyticsConsent: false,
snoozedUntil: null,
now: 1000,
activeSeconds: 60,
routeCount: 2,
thresholdSeconds: 60,
thresholdRoutes: 2,
isUpload: false,
};
it('returns true when thresholds are met', () => {
expect(shouldShowAnalyticsNudge(baseState)).toBe(true);
});
it('returns false when consent decision is made', () => {
expect(shouldShowAnalyticsNudge({ ...baseState, decisionMade: true })).toBe(false);
});
it('returns false when snoozed', () => {
expect(shouldShowAnalyticsNudge({ ...baseState, snoozedUntil: 2000 })).toBe(false);
});
it('returns false on upload routes', () => {
expect(shouldShowAnalyticsNudge({ ...baseState, isUpload: true })).toBe(false);
});
});

View File

@@ -1,52 +0,0 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { supportsBadging, updateAppBadge } from '../badges';
const originalSet = (navigator as any).setAppBadge;
const originalClear = (navigator as any).clearAppBadge;
const hadSet = 'setAppBadge' in navigator;
const hadClear = 'clearAppBadge' in navigator;
function restoreNavigator() {
if (hadSet) {
Object.defineProperty(navigator, 'setAppBadge', { configurable: true, value: originalSet });
} else {
delete (navigator as any).setAppBadge;
}
if (hadClear) {
Object.defineProperty(navigator, 'clearAppBadge', { configurable: true, value: originalClear });
} else {
delete (navigator as any).clearAppBadge;
}
}
describe('badges', () => {
afterEach(() => {
restoreNavigator();
});
it('sets the badge count when supported', async () => {
const setAppBadge = vi.fn();
Object.defineProperty(navigator, 'setAppBadge', { configurable: true, value: setAppBadge });
Object.defineProperty(navigator, 'clearAppBadge', { configurable: true, value: vi.fn() });
expect(supportsBadging()).toBe(true);
await updateAppBadge(4);
expect(setAppBadge).toHaveBeenCalledWith(4);
});
it('clears the badge when count is zero', async () => {
const clearAppBadge = vi.fn();
Object.defineProperty(navigator, 'setAppBadge', { configurable: true, value: vi.fn() });
Object.defineProperty(navigator, 'clearAppBadge', { configurable: true, value: clearAppBadge });
await updateAppBadge(0);
expect(clearAppBadge).toHaveBeenCalled();
});
it('no-ops when unsupported', async () => {
delete (navigator as any).setAppBadge;
delete (navigator as any).clearAppBadge;
expect(supportsBadging()).toBe(false);
await updateAppBadge(3);
});
});

View File

@@ -1,24 +0,0 @@
import { describe, expect, it } from 'vitest';
import { shouldCacheResponse } from '../cachePolicy';
describe('shouldCacheResponse', () => {
it('returns false when Cache-Control is no-store', () => {
const response = new Response('ok', { headers: { 'Cache-Control': 'no-store' } });
expect(shouldCacheResponse(response)).toBe(false);
});
it('returns false when Cache-Control is private', () => {
const response = new Response('ok', { headers: { 'Cache-Control': 'private, max-age=0' } });
expect(shouldCacheResponse(response)).toBe(false);
});
it('returns false when Pragma is no-cache', () => {
const response = new Response('ok', { headers: { Pragma: 'no-cache' } });
expect(shouldCacheResponse(response)).toBe(false);
});
it('returns true for cacheable responses', () => {
const response = new Response('ok', { headers: { 'Cache-Control': 'public, max-age=60' } });
expect(shouldCacheResponse(response)).toBe(true);
});
});

View File

@@ -1,36 +0,0 @@
import { describe, expect, it, beforeEach, afterEach } from 'vitest';
import { buildCsrfHeaders } from '../csrf';
describe('buildCsrfHeaders', () => {
beforeEach(() => {
localStorage.setItem('device-id', 'device-123');
});
afterEach(() => {
localStorage.clear();
document.head.querySelectorAll('meta[name="csrf-token"]').forEach((node) => node.remove());
document.cookie = 'XSRF-TOKEN=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/';
});
it('reads token from meta tag', () => {
const meta = document.createElement('meta');
meta.setAttribute('name', 'csrf-token');
meta.setAttribute('content', 'meta-token');
document.head.appendChild(meta);
const headers = buildCsrfHeaders('device-xyz');
expect(headers['X-CSRF-TOKEN']).toBe('meta-token');
expect(headers['X-XSRF-TOKEN']).toBe('meta-token');
expect(headers['X-Device-Id']).toBe('device-xyz');
});
it('falls back to cookie token', () => {
const raw = btoa('cookie-token');
document.cookie = `XSRF-TOKEN=${raw}; path=/`;
const headers = buildCsrfHeaders();
expect(headers['X-CSRF-TOKEN']).toBe('cookie-token');
expect(headers['X-XSRF-TOKEN']).toBe('cookie-token');
expect(headers['X-Device-Id']).toBe('device-123');
});
});

View File

@@ -1,13 +0,0 @@
import { shouldShowPhotoboothFilter } from '../galleryFilters';
describe('shouldShowPhotoboothFilter', () => {
it('returns true when photobooth is enabled', () => {
expect(shouldShowPhotoboothFilter({ photobooth_enabled: true } as any)).toBe(true);
});
it('returns false when photobooth is disabled or missing', () => {
expect(shouldShowPhotoboothFilter({ photobooth_enabled: false } as any)).toBe(false);
expect(shouldShowPhotoboothFilter(null)).toBe(false);
expect(shouldShowPhotoboothFilter(undefined)).toBe(false);
});
});

View File

@@ -1,35 +0,0 @@
import { describe, expect, it, afterEach } from 'vitest';
import { applyGuestTheme } from '../guestTheme';
const baseTheme = {
primary: '#ff3366',
secondary: '#ff99aa',
background: '#111111',
surface: '#222222',
mode: 'dark' as const,
};
describe('applyGuestTheme', () => {
afterEach(() => {
const root = document.documentElement;
root.classList.remove('guest-theme', 'dark');
root.style.removeProperty('color-scheme');
root.style.removeProperty('--guest-primary');
root.style.removeProperty('--guest-secondary');
root.style.removeProperty('--guest-background');
root.style.removeProperty('--guest-surface');
});
it('applies and restores guest theme settings', () => {
const cleanup = applyGuestTheme(baseTheme);
expect(document.documentElement.classList.contains('guest-theme')).toBe(true);
expect(document.documentElement.classList.contains('dark')).toBe(true);
expect(document.documentElement.style.colorScheme).toBe('dark');
expect(document.documentElement.style.getPropertyValue('--guest-background')).toBe('#111111');
cleanup();
expect(document.documentElement.classList.contains('guest-theme')).toBe(false);
});
});

View File

@@ -1,63 +0,0 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { HAPTICS_STORAGE_KEY, getHapticsPreference, isHapticsEnabled, setHapticsPreference, supportsHaptics, triggerHaptic } from '../haptics';
describe('haptics', () => {
afterEach(() => {
window.localStorage.removeItem(HAPTICS_STORAGE_KEY);
});
it('returns false when vibrate is unavailable', () => {
const original = navigator.vibrate;
Object.defineProperty(navigator, 'vibrate', { configurable: true, value: undefined });
expect(supportsHaptics()).toBe(false);
Object.defineProperty(navigator, 'vibrate', { configurable: true, value: original });
});
it('returns stored preference when set', () => {
window.localStorage.removeItem(HAPTICS_STORAGE_KEY);
expect(getHapticsPreference()).toBe(true);
setHapticsPreference(false);
expect(getHapticsPreference()).toBe(false);
});
it('reports disabled when reduced motion is enabled', () => {
const originalMatchMedia = window.matchMedia;
const vibrate = vi.fn();
Object.defineProperty(window, 'matchMedia', {
configurable: true,
value: vi.fn().mockReturnValue({ matches: true }),
});
Object.defineProperty(navigator, 'vibrate', { configurable: true, value: vibrate });
setHapticsPreference(true);
expect(isHapticsEnabled()).toBe(false);
Object.defineProperty(window, 'matchMedia', {
configurable: true,
value: originalMatchMedia,
});
});
it('triggers vibration only when enabled', () => {
const originalMatchMedia = window.matchMedia;
const vibrate = vi.fn();
Object.defineProperty(window, 'matchMedia', {
configurable: true,
value: vi.fn().mockReturnValue({ matches: false }),
});
Object.defineProperty(navigator, 'vibrate', { configurable: true, value: vibrate });
triggerHaptic('selection');
expect(vibrate).toHaveBeenCalled();
setHapticsPreference(false);
triggerHaptic('selection');
expect(vibrate).toHaveBeenCalledTimes(1);
Object.defineProperty(window, 'matchMedia', {
configurable: true,
value: originalMatchMedia,
});
});
});

View File

@@ -1,32 +0,0 @@
import { describe, expect, it } from 'vitest';
import { getHelpSlugForPathname } from '../helpRouting';
describe('getHelpSlugForPathname', () => {
it('returns a getting-started slug for home paths', () => {
expect(getHelpSlugForPathname('/')).toBe('getting-started');
expect(getHelpSlugForPathname('/e/demo')).toBe('getting-started');
});
it('returns null for help pages', () => {
expect(getHelpSlugForPathname('/help')).toBeNull();
expect(getHelpSlugForPathname('/help/gallery-and-sharing')).toBeNull();
expect(getHelpSlugForPathname('/e/demo/help/gallery-and-sharing')).toBeNull();
});
it('maps gallery related pages', () => {
expect(getHelpSlugForPathname('/e/demo/gallery')).toBe('gallery-and-sharing');
expect(getHelpSlugForPathname('/e/demo/photo/123')).toBe('gallery-and-sharing');
expect(getHelpSlugForPathname('/e/demo/slideshow')).toBe('gallery-and-sharing');
});
it('maps upload related pages', () => {
expect(getHelpSlugForPathname('/e/demo/upload')).toBe('uploading-photos');
expect(getHelpSlugForPathname('/e/demo/queue')).toBe('upload-troubleshooting');
});
it('maps tasks and achievements', () => {
expect(getHelpSlugForPathname('/e/demo/tasks')).toBe('tasks-and-missions');
expect(getHelpSlugForPathname('/e/demo/tasks/12')).toBe('tasks-and-missions');
expect(getHelpSlugForPathname('/e/demo/achievements')).toBe('achievements-and-badges');
});
});

View File

@@ -1,86 +0,0 @@
import { describe, expect, it } from 'vitest';
import type { EventPackageLimits } from '../../services/eventApi';
import { buildLimitSummaries } from '../limitSummaries';
const translations = new Map<string, string>([
['upload.limitSummary.cards.photos.title', 'Fotos'],
['upload.limitSummary.cards.photos.remaining', 'Noch {remaining} von {limit}'],
['upload.limitSummary.cards.photos.unlimited', 'Unbegrenzte Uploads'],
['upload.limitSummary.cards.guests.title', 'Gäste'],
['upload.limitSummary.cards.guests.remaining', '{remaining} Gäste frei (max. {limit})'],
['upload.limitSummary.cards.guests.unlimited', 'Unbegrenzte Gäste'],
['upload.limitSummary.badges.ok', 'OK'],
['upload.limitSummary.badges.warning', 'Warnung'],
['upload.limitSummary.badges.limit_reached', 'Limit erreicht'],
['upload.limitSummary.badges.unlimited', 'Unbegrenzt'],
]);
const t = (key: string) => translations.get(key) ?? key;
describe('buildLimitSummaries', () => {
it('builds photo summary with progress and warning tone', () => {
const limits: EventPackageLimits = {
photos: {
limit: 100,
used: 80,
remaining: 20,
percentage: 80,
state: 'warning',
threshold_reached: 80,
next_threshold: 95,
thresholds: [80, 95],
},
guests: null,
gallery: null,
can_upload_photos: true,
can_add_guests: true,
};
const cards = buildLimitSummaries(limits, t);
expect(cards).toHaveLength(1);
const card = cards[0];
expect(card.id).toBe('photos');
expect(card.tone).toBe('warning');
expect(card.progress).toBe(80);
expect(card.valueLabel).toBe('80 / 100');
expect(card.description).toBe('Noch 20 von 100');
expect(card.badgeLabel).toBe('Warnung');
});
it('builds unlimited guest summary without progress', () => {
const limits: EventPackageLimits = {
photos: null,
guests: {
limit: null,
used: 5,
remaining: null,
percentage: null,
state: 'unlimited',
threshold_reached: null,
next_threshold: null,
thresholds: [],
},
gallery: null,
can_upload_photos: true,
can_add_guests: true,
};
const cards = buildLimitSummaries(limits, t);
expect(cards).toHaveLength(1);
const card = cards[0];
expect(card.id).toBe('guests');
expect(card.progress).toBeNull();
expect(card.tone).toBe('neutral');
expect(card.valueLabel).toBe('Unbegrenzt');
expect(card.description).toBe('Unbegrenzte Gäste');
expect(card.badgeLabel).toBe('Unbegrenzt');
});
it('returns empty list when no limits are provided', () => {
expect(buildLimitSummaries(null, t)).toEqual([]);
expect(buildLimitSummaries(undefined, t)).toEqual([]);
});
});

View File

@@ -1,22 +0,0 @@
import { describe, expect, it } from 'vitest';
import { resolveLiveShowEffect } from '../liveShowEffects';
describe('resolveLiveShowEffect', () => {
it('adds flash overlay for shutter flash preset', () => {
const effect = resolveLiveShowEffect('shutter_flash', 80, false);
expect(effect.flash).toBeDefined();
expect(effect.frame.initial).toBeDefined();
expect(effect.frame.animate).toBeDefined();
});
it('keeps light effects simple without flash', () => {
const effect = resolveLiveShowEffect('light_effects', 80, false);
expect(effect.flash).toBeUndefined();
});
it('honors reduced motion with basic fade', () => {
const effect = resolveLiveShowEffect('film_cut', 80, true);
expect(effect.flash).toBeUndefined();
expect(effect.frame.initial).toEqual({ opacity: 0 });
});
});

View File

@@ -1,47 +0,0 @@
import { getMotionContainerPropsForNavigation, getMotionItemPropsForNavigation, STAGGER_FAST, FADE_UP } from '../motion';
describe('getMotionContainerPropsForNavigation', () => {
it('returns initial hidden for POP navigation', () => {
expect(getMotionContainerPropsForNavigation(true, STAGGER_FAST, 'POP')).toEqual({
variants: STAGGER_FAST,
initial: 'hidden',
animate: 'show',
});
});
it('skips initial animation for PUSH navigation', () => {
expect(getMotionContainerPropsForNavigation(true, STAGGER_FAST, 'PUSH')).toEqual({
variants: STAGGER_FAST,
initial: false,
animate: 'show',
});
});
it('disables motion when not enabled', () => {
expect(getMotionContainerPropsForNavigation(false, STAGGER_FAST, 'POP')).toEqual({
initial: false,
});
});
});
describe('getMotionItemPropsForNavigation', () => {
it('returns animate props for POP navigation', () => {
expect(getMotionItemPropsForNavigation(true, FADE_UP, 'POP')).toEqual({
variants: FADE_UP,
initial: 'hidden',
animate: 'show',
});
});
it('skips initial animation for PUSH navigation', () => {
expect(getMotionItemPropsForNavigation(true, FADE_UP, 'PUSH')).toEqual({
variants: FADE_UP,
initial: false,
animate: 'show',
});
});
it('returns empty props when motion disabled', () => {
expect(getMotionItemPropsForNavigation(false, FADE_UP, 'POP')).toEqual({});
});
});

View File

@@ -1,22 +0,0 @@
import { dedupeTasksById } from '../taskUtils';
describe('dedupeTasksById', () => {
it('returns empty array for empty input', () => {
expect(dedupeTasksById([])).toEqual([]);
});
it('keeps the first occurrence and preserves order', () => {
const tasks = [
{ id: 1, title: 'A' },
{ id: 2, title: 'B' },
{ id: 1, title: 'A-dup' },
{ id: 3, title: 'C' },
];
expect(dedupeTasksById(tasks)).toEqual([
{ id: 1, title: 'A' },
{ id: 2, title: 'B' },
{ id: 3, title: 'C' },
]);
});
});

View File

@@ -1,49 +0,0 @@
import { describe, expect, it } from 'vitest';
import { resolveUploadErrorDialog } from '../uploadErrorDialog';
const translations = new Map<string, string>([
['upload.dialogs.photoLimit.title', 'Upload-Limit erreicht'],
['upload.dialogs.photoLimit.description', 'Es wurden {used} von {limit} Fotos hochgeladen. Es bleiben {remaining}.'],
['upload.dialogs.photoLimit.hint', 'Wende dich an das Team.'],
['upload.dialogs.deviceLimit.title', 'Dieses Gerät ist voll'],
['upload.dialogs.deviceLimit.description', 'Du hast das Geräte-Limit erreicht.'],
['upload.dialogs.deviceLimit.hint', 'Nutze ein anderes Gerät oder kontaktiere das Team.'],
['upload.dialogs.packageMissing.title', 'Event nicht bereit'],
['upload.dialogs.packageMissing.description', 'Das Event akzeptiert aktuell keine Uploads.'],
['upload.dialogs.packageMissing.hint', 'Frag die Veranstalter:innen nach dem Status.'],
['upload.dialogs.galleryExpired.title', 'Galerie abgelaufen'],
['upload.dialogs.galleryExpired.description', 'Uploads sind nicht mehr möglich.'],
['upload.dialogs.galleryExpired.hint', 'Bitte wende dich an die Veranstalter:innen.'],
['upload.dialogs.csrf.title', 'Sicherheitsabgleich erforderlich'],
['upload.dialogs.csrf.description', 'Bitte lade die Seite neu und versuche es erneut.'],
['upload.dialogs.csrf.hint', 'Aktualisiere die Seite.'],
['upload.dialogs.generic.title', 'Upload fehlgeschlagen'],
['upload.dialogs.generic.description', 'Der Upload konnte nicht abgeschlossen werden.'],
['upload.dialogs.generic.hint', 'Versuche es später erneut.'],
]);
const t = (key: string) => translations.get(key) ?? key;
describe('resolveUploadErrorDialog', () => {
it('renders photo limit dialog with placeholders', () => {
const dialog = resolveUploadErrorDialog(
'photo_limit_exceeded',
{ used: 120, limit: 120, remaining: 0 },
t
);
expect(dialog.title).toBe('Upload-Limit erreicht');
expect(dialog.description).toBe('Es wurden 120 von 120 Fotos hochgeladen. Es bleiben 0.');
expect(dialog.hint).toBe('Wende dich an das Team.');
expect(dialog.tone).toBe('danger');
});
it('falls back to generic dialog when code is unknown', () => {
const dialog = resolveUploadErrorDialog('something_else', undefined, t);
expect(dialog.tone).toBe('info');
expect(dialog.title).toBe('Upload fehlgeschlagen');
expect(dialog.description).toBe('Der Upload konnte nicht abgeschlossen werden.');
});
});

View File

@@ -1,34 +0,0 @@
export type AnalyticsNudgeState = {
decisionMade: boolean;
analyticsConsent: boolean;
snoozedUntil: number | null;
now: number;
activeSeconds: number;
routeCount: number;
thresholdSeconds: number;
thresholdRoutes: number;
isUpload: boolean;
};
export function isUploadPath(pathname: string): boolean {
return /\/upload(?:\/|$)/.test(pathname);
}
export function shouldShowAnalyticsNudge(state: AnalyticsNudgeState): boolean {
if (state.decisionMade || state.analyticsConsent) {
return false;
}
if (state.isUpload) {
return false;
}
if (state.snoozedUntil && state.snoozedUntil > state.now) {
return false;
}
return (
state.activeSeconds >= state.thresholdSeconds &&
state.routeCount >= state.thresholdRoutes
);
}

View File

@@ -1,46 +0,0 @@
type BadgingNavigator = Navigator & {
setAppBadge?: (contents?: number) => Promise<void> | void;
clearAppBadge?: () => Promise<void> | void;
};
function getNavigator(): BadgingNavigator | null {
if (typeof navigator === 'undefined') {
return null;
}
return navigator as BadgingNavigator;
}
export function supportsBadging(): boolean {
const nav = getNavigator();
return Boolean(nav && (typeof nav.setAppBadge === 'function' || typeof nav.clearAppBadge === 'function'));
}
export async function updateAppBadge(count: number): Promise<void> {
const nav = getNavigator();
if (!nav) {
return;
}
const safeCount = Number.isFinite(count) ? Math.max(0, Math.floor(count)) : 0;
if (!supportsBadging()) {
return;
}
try {
if (safeCount > 0 && nav.setAppBadge) {
await nav.setAppBadge(safeCount);
return;
}
if (nav.clearAppBadge) {
await nav.clearAppBadge();
return;
}
if (nav.setAppBadge) {
await nav.setAppBadge(0);
}
} catch (error) {
console.warn('Updating app badge failed', error);
}
}

View File

@@ -1,20 +0,0 @@
export function shouldCacheResponse(response: Response | null): boolean {
if (!response) {
return false;
}
const cacheControl = response.headers.get('Cache-Control') ?? '';
const pragma = response.headers.get('Pragma') ?? '';
const normalizedCacheControl = cacheControl.toLowerCase();
const normalizedPragma = pragma.toLowerCase();
if (normalizedCacheControl.includes('no-store') || normalizedCacheControl.includes('private')) {
return false;
}
if (normalizedPragma.includes('no-cache')) {
return false;
}
return true;
}

View File

@@ -1,50 +0,0 @@
export function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
const normalized = hex.trim();
if (!/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(normalized)) {
return null;
}
let r: number;
let g: number;
let b: number;
if (normalized.length === 4) {
r = parseInt(normalized[1] + normalized[1], 16);
g = parseInt(normalized[2] + normalized[2], 16);
b = parseInt(normalized[3] + normalized[3], 16);
} else {
r = parseInt(normalized.slice(1, 3), 16);
g = parseInt(normalized.slice(3, 5), 16);
b = parseInt(normalized.slice(5, 7), 16);
}
return { r, g, b };
}
export function relativeLuminance(hex: string): number {
const rgb = hexToRgb(hex);
if (!rgb) {
return 0;
}
const normalize = (channel: number) => {
const c = channel / 255;
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
};
const r = normalize(rgb.r);
const g = normalize(rgb.g);
const b = normalize(rgb.b);
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
export function getContrastingTextColor(
backgroundHex: string,
lightColor = '#ffffff',
darkColor = '#0f172a',
): string {
const luminance = relativeLuminance(backgroundHex);
return luminance > 0.5 ? darkColor : lightColor;
}

View File

@@ -1,49 +0,0 @@
import { getDeviceId } from './device';
function getCsrfToken(): string | null {
if (typeof document === 'undefined') {
return null;
}
const metaToken = document.querySelector('meta[name="csrf-token"]');
if (metaToken instanceof HTMLMetaElement) {
return metaToken.getAttribute('content') || null;
}
const name = 'XSRF-TOKEN=';
const decodedCookie = decodeURIComponent(document.cookie ?? '');
const parts = decodedCookie.split(';');
for (const part of parts) {
const trimmed = part.trimStart();
if (!trimmed.startsWith(name)) {
continue;
}
const token = trimmed.substring(name.length);
try {
return decodeURIComponent(atob(token));
} catch {
return token;
}
}
return null;
}
export function buildCsrfHeaders(deviceId?: string): Record<string, string> {
const token = getCsrfToken();
const resolvedDeviceId = deviceId ?? (typeof window !== 'undefined' ? getDeviceId() : undefined);
const headers: Record<string, string> = {
Accept: 'application/json',
};
if (resolvedDeviceId) {
headers['X-Device-Id'] = resolvedDeviceId;
}
if (token) {
headers['X-CSRF-TOKEN'] = token;
headers['X-XSRF-TOKEN'] = token;
}
return headers;
}

View File

@@ -1,19 +0,0 @@
export function getDeviceId(): string {
const KEY = 'device-id';
let id = localStorage.getItem(KEY);
if (!id) {
id = genId();
localStorage.setItem(KEY, id);
}
return id;
}
function genId() {
// Simple UUID v4-ish generator
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (crypto.getRandomValues(new Uint8Array(1))[0] & 0xf) >> 0;
const v = c === 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}

View File

@@ -1,128 +0,0 @@
export type EmotionTheme = {
gradientClass: string;
gradientBackground: string;
suggestionGradient: string;
suggestionBorder: string;
};
export type EmotionIdentity = {
slug?: string | null;
name?: string | null;
};
const themeFreude: EmotionTheme = {
gradientClass: 'from-amber-300 via-orange-400 to-rose-500',
gradientBackground: 'linear-gradient(135deg, #fde68a, #fb923c, #fb7185)',
suggestionGradient: 'from-amber-50 via-white to-orange-50 dark:from-amber-500/20 dark:via-gray-900 dark:to-orange-500/10',
suggestionBorder: 'border-amber-200/60 dark:border-amber-400/30',
};
const themeLiebe: EmotionTheme = {
gradientClass: 'from-rose-400 via-fuchsia-500 to-purple-600',
gradientBackground: 'linear-gradient(135deg, #fb7185, #ec4899, #9333ea)',
suggestionGradient: 'from-rose-50 via-white to-fuchsia-50 dark:from-rose-500/20 dark:via-gray-900 dark:to-fuchsia-500/10',
suggestionBorder: 'border-rose-200/60 dark:border-rose-400/30',
};
const themeEkstase: EmotionTheme = {
gradientClass: 'from-fuchsia-400 via-purple-500 to-indigo-500',
gradientBackground: 'linear-gradient(135deg, #f472b6, #a855f7, #6366f1)',
suggestionGradient: 'from-fuchsia-50 via-white to-indigo-50 dark:from-fuchsia-500/20 dark:via-gray-900 dark:to-indigo-500/10',
suggestionBorder: 'border-fuchsia-200/60 dark:border-fuchsia-400/30',
};
const themeEntspannt: EmotionTheme = {
gradientClass: 'from-teal-300 via-emerald-400 to-cyan-500',
gradientBackground: 'linear-gradient(135deg, #5eead4, #34d399, #22d3ee)',
suggestionGradient: 'from-teal-50 via-white to-emerald-50 dark:from-teal-500/20 dark:via-gray-900 dark:to-emerald-500/10',
suggestionBorder: 'border-emerald-200/60 dark:border-emerald-400/30',
};
const themeBesinnlich: EmotionTheme = {
gradientClass: 'from-slate-500 via-blue-500 to-indigo-600',
gradientBackground: 'linear-gradient(135deg, #64748b, #3b82f6, #4f46e5)',
suggestionGradient: 'from-slate-50 via-white to-blue-50 dark:from-slate-600/20 dark:via-gray-900 dark:to-blue-500/10',
suggestionBorder: 'border-slate-200/60 dark:border-slate-500/30',
};
const themeUeberraschung: EmotionTheme = {
gradientClass: 'from-indigo-300 via-violet-500 to-rose-500',
gradientBackground: 'linear-gradient(135deg, #a5b4fc, #a855f7, #fb7185)',
suggestionGradient: 'from-indigo-50 via-white to-violet-50 dark:from-indigo-500/20 dark:via-gray-900 dark:to-violet-500/10',
suggestionBorder: 'border-indigo-200/60 dark:border-indigo-400/30',
};
const themeDefault: EmotionTheme = {
gradientClass: 'from-pink-500 via-purple-500 to-indigo-600',
gradientBackground: 'linear-gradient(135deg, #ec4899, #a855f7, #4f46e5)',
suggestionGradient: 'from-pink-50 via-white to-indigo-50 dark:from-pink-500/20 dark:via-gray-900 dark:to-indigo-500/10',
suggestionBorder: 'border-pink-200/60 dark:border-pink-400/30',
};
const EMOTION_THEMES: Record<string, EmotionTheme> = {
freude: themeFreude,
happy: themeFreude,
liebe: themeLiebe,
romance: themeLiebe,
romantik: themeLiebe,
nostalgie: themeEntspannt,
relaxed: themeEntspannt,
ruehrung: themeBesinnlich,
traurigkeit: themeBesinnlich,
teamgeist: themeFreude,
gemeinschaft: themeFreude,
ueberraschung: themeUeberraschung,
surprise: themeUeberraschung,
ekstase: themeEkstase,
excited: themeEkstase,
besinnlichkeit: themeBesinnlich,
sad: themeBesinnlich,
default: themeDefault,
};
const EMOTION_ICONS: Record<string, string> = {
freude: '😊',
happy: '😊',
liebe: '❤️',
romantik: '💞',
nostalgie: '📼',
ruehrung: '🥲',
teamgeist: '🤝',
ueberraschung: '😲',
surprise: '😲',
ekstase: '🤩',
besinnlichkeit: '🕯️',
};
function sluggify(value?: string | null): string {
return (value ?? '')
.toString()
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, '')
.trim();
}
function resolveEmotionKey(identity?: EmotionIdentity | null): string {
if (!identity) return 'default';
const nameKey = sluggify(identity.name);
if (nameKey && EMOTION_THEMES[nameKey]) {
return nameKey;
}
const slugKey = sluggify(identity.slug);
if (slugKey && EMOTION_THEMES[slugKey]) {
return slugKey;
}
return 'default';
}
export function getEmotionTheme(identity?: EmotionIdentity | null): EmotionTheme {
const key = resolveEmotionKey(identity);
return EMOTION_THEMES[key] ?? themeDefault;
}
export function getEmotionIcon(identity?: EmotionIdentity | null): string {
const key = resolveEmotionKey(identity);
return EMOTION_ICONS[key] ?? '✨';
}

View File

@@ -1,8 +0,0 @@
import type { EventData } from '../services/eventApi';
export function isTaskModeEnabled(event?: EventData | null): boolean {
if (!event) return true;
const mode = event.engagement_mode;
if (!mode) return true;
return mode === 'tasks';
}

View File

@@ -1,5 +0,0 @@
import type { EventData } from '../services/eventApi';
export function shouldShowPhotoboothFilter(event?: EventData | null): boolean {
return Boolean(event?.photobooth_enabled);
}

View File

@@ -1,103 +0,0 @@
export type GuestThemePayload = {
primary: string;
secondary: string;
background: string;
surface: string;
mode?: 'light' | 'dark' | 'auto';
};
type GuestThemeCleanup = () => void;
const prefersDarkScheme = (): boolean => {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
return false;
}
return window.matchMedia('(prefers-color-scheme: dark)').matches;
};
const applyColorScheme = (root: HTMLElement, theme: 'light' | 'dark') => {
if (theme === 'dark') {
root.classList.add('dark');
root.style.colorScheme = 'dark';
} else {
root.classList.remove('dark');
root.style.colorScheme = 'light';
}
};
export function applyGuestTheme(payload: GuestThemePayload): GuestThemeCleanup {
if (typeof document === 'undefined') {
return () => {};
}
const root = document.documentElement;
const hadGuestTheme = root.classList.contains('guest-theme');
const wasDark = root.classList.contains('dark');
const previousColorScheme = root.style.colorScheme;
const previousVars = {
primary: root.style.getPropertyValue('--guest-primary'),
secondary: root.style.getPropertyValue('--guest-secondary'),
background: root.style.getPropertyValue('--guest-background'),
surface: root.style.getPropertyValue('--guest-surface'),
};
root.classList.add('guest-theme');
root.style.setProperty('--guest-primary', payload.primary);
root.style.setProperty('--guest-secondary', payload.secondary);
root.style.setProperty('--guest-background', payload.background);
root.style.setProperty('--guest-surface', payload.surface);
const mode = payload.mode ?? 'auto';
if (mode === 'dark') {
applyColorScheme(root, 'dark');
} else if (mode === 'light') {
applyColorScheme(root, 'light');
} else {
applyColorScheme(root, prefersDarkScheme() ? 'dark' : 'light');
}
return () => {
if (hadGuestTheme) {
root.classList.add('guest-theme');
} else {
root.classList.remove('guest-theme');
}
if (wasDark) {
root.classList.add('dark');
} else {
root.classList.remove('dark');
}
if (previousColorScheme) {
root.style.colorScheme = previousColorScheme;
} else {
root.style.removeProperty('color-scheme');
}
if (previousVars.primary) {
root.style.setProperty('--guest-primary', previousVars.primary);
} else {
root.style.removeProperty('--guest-primary');
}
if (previousVars.secondary) {
root.style.setProperty('--guest-secondary', previousVars.secondary);
} else {
root.style.removeProperty('--guest-secondary');
}
if (previousVars.background) {
root.style.setProperty('--guest-background', previousVars.background);
} else {
root.style.removeProperty('--guest-background');
}
if (previousVars.surface) {
root.style.setProperty('--guest-surface', previousVars.surface);
} else {
root.style.removeProperty('--guest-surface');
}
};
}

View File

@@ -1,62 +0,0 @@
import { prefersReducedMotion } from './motion';
export type HapticPattern = 'selection' | 'light' | 'medium' | 'success' | 'error';
const PATTERNS: Record<HapticPattern, number | number[]> = {
selection: 10,
light: 15,
medium: 30,
success: [10, 30, 10],
error: [20, 30, 20],
};
export const HAPTICS_STORAGE_KEY = 'guestHapticsEnabled';
export function supportsHaptics(): boolean {
return typeof navigator !== 'undefined' && typeof navigator.vibrate === 'function';
}
export function getHapticsPreference(): boolean {
if (typeof window === 'undefined') {
return true;
}
try {
const raw = window.localStorage.getItem(HAPTICS_STORAGE_KEY);
if (raw === null) {
return true;
}
return raw !== '0';
} catch (error) {
console.warn('Failed to read haptics preference', error);
return true;
}
}
export function setHapticsPreference(enabled: boolean): void {
if (typeof window === 'undefined') {
return;
}
try {
window.localStorage.setItem(HAPTICS_STORAGE_KEY, enabled ? '1' : '0');
} catch (error) {
console.warn('Failed to store haptics preference', error);
}
}
export function isHapticsEnabled(): boolean {
return getHapticsPreference() && supportsHaptics() && !prefersReducedMotion();
}
export function triggerHaptic(pattern: HapticPattern): void {
if (!isHapticsEnabled()) {
return;
}
try {
navigator.vibrate(PATTERNS[pattern]);
} catch (error) {
console.warn('Haptic feedback failed', error);
}
}

View File

@@ -1,44 +0,0 @@
export function getHelpSlugForPathname(pathname: string): string | null {
if (!pathname) {
return null;
}
const normalized = pathname
.replace(/^\/e\/[^/]+/, '')
.replace(/\/+$/g, '')
.toLowerCase();
if (!normalized || normalized === '/') {
return 'getting-started';
}
if (normalized.startsWith('/help')) {
return null;
}
if (normalized.startsWith('/gallery') || normalized.startsWith('/photo') || normalized.startsWith('/slideshow')) {
return 'gallery-and-sharing';
}
if (normalized.startsWith('/upload')) {
return 'uploading-photos';
}
if (normalized.startsWith('/queue')) {
return 'upload-troubleshooting';
}
if (normalized.startsWith('/tasks')) {
return 'tasks-and-missions';
}
if (normalized.startsWith('/achievements')) {
return 'achievements-and-badges';
}
if (normalized.startsWith('/settings')) {
return 'settings-and-cache';
}
return 'how-fotospiel-works';
}

View File

@@ -1,97 +0,0 @@
// @ts-nocheck
export async function compressPhoto(
file: File,
opts: { targetBytes?: number; maxEdge?: number; qualityStart?: number } = {}
): Promise<File> {
const targetBytes = opts.targetBytes ?? 1_500_000; // 1.5 MB
const maxEdge = opts.maxEdge ?? 2560;
const qualityStart = opts.qualityStart ?? 0.85;
// If already small and jpeg, return as-is
if (file.size <= targetBytes && file.type === 'image/jpeg') return file;
const img = await loadImageBitmap(file);
const { width, height } = fitWithin(img.width, img.height, maxEdge);
const canvas = createCanvas(width, height);
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('Canvas unsupported');
ctx.drawImage(img, 0, 0, width, height);
// Iteratively lower quality to fit target size
let quality = qualityStart;
let blob: Blob | null = await toBlob(canvas, 'image/jpeg', quality);
if (!blob) throw new Error('Failed to encode image');
while (blob.size > targetBytes && quality > 0.5) {
quality -= 0.05;
const attempt = await toBlob(canvas, 'image/jpeg', quality);
if (attempt) blob = attempt;
else break;
}
// If still too large, downscale further by 0.9 until it fits or edge < 800
let currentWidth = width;
let currentHeight = height;
while (blob.size > targetBytes && Math.max(currentWidth, currentHeight) > 800) {
currentWidth = Math.round(currentWidth * 0.9);
currentHeight = Math.round(currentHeight * 0.9);
const c2 = createCanvas(currentWidth, currentHeight);
const c2ctx = c2.getContext('2d');
if (!c2ctx) break;
c2ctx.drawImage(canvas, 0, 0, currentWidth, currentHeight);
const attempt = await toBlob(c2, 'image/jpeg', quality);
if (attempt) blob = attempt;
}
const outName = ensureJpegExtension(file.name);
return new File([blob], outName, { type: 'image/jpeg', lastModified: Date.now() });
}
function fitWithin(w: number, h: number, maxEdge: number) {
const scale = Math.min(1, maxEdge / Math.max(w, h));
return { width: Math.round(w * scale), height: Math.round(h * scale) };
}
function createCanvas(w: number, h: number): HTMLCanvasElement {
const c = document.createElement('canvas');
c.width = w; c.height = h; return c;
}
function toBlob(canvas: HTMLCanvasElement, type: string, quality: number): Promise<Blob | null> {
return new Promise(resolve => canvas.toBlob(resolve, type, quality));
}
async function loadImageBitmap(file: File): Promise<CanvasImageSource> {
const canBitmap = 'createImageBitmap' in window;
if (canBitmap) {
try {
return await createImageBitmap(file);
} catch (error) {
console.warn('Falling back to HTML image decode', error);
}
}
return await loadHtmlImage(file);
}
function loadHtmlImage(file: File): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const url = URL.createObjectURL(file);
const img = new Image();
img.onload = () => { URL.revokeObjectURL(url); resolve(img); };
img.onerror = (e) => { URL.revokeObjectURL(url); reject(e); };
img.src = url;
});
}
function ensureJpegExtension(name: string) {
return name.replace(/\.(heic|heif|png|webp|jpg|jpeg)$/i, '') + '.jpg';
}
export function formatBytes(bytes: number) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
}

View File

@@ -1,107 +0,0 @@
import type { EventPackageLimits, LimitUsageSummary } from '../services/eventApi';
export type LimitTone = 'neutral' | 'warning' | 'danger';
export type LimitSummaryCard = {
id: 'photos' | 'guests';
label: string;
state: LimitUsageSummary['state'];
tone: LimitTone;
used: number;
limit: number | null;
remaining: number | null;
progress: number | null;
valueLabel: string;
description: string;
badgeLabel: string;
};
type TranslateFn = (key: string, fallback?: string) => string;
function resolveTone(state: LimitUsageSummary['state']): LimitTone {
if (state === 'limit_reached') {
return 'danger';
}
if (state === 'warning') {
return 'warning';
}
return 'neutral';
}
function buildCard(
id: 'photos' | 'guests',
summary: LimitUsageSummary,
t: TranslateFn
): LimitSummaryCard {
const labelKey = id === 'photos' ? 'upload.limitSummary.cards.photos.title' : 'upload.limitSummary.cards.guests.title';
const remainingKey = id === 'photos'
? 'upload.limitSummary.cards.photos.remaining'
: 'upload.limitSummary.cards.guests.remaining';
const unlimitedKey = id === 'photos'
? 'upload.limitSummary.cards.photos.unlimited'
: 'upload.limitSummary.cards.guests.unlimited';
const tone = resolveTone(summary.state);
const progress = typeof summary.limit === 'number' && summary.limit > 0
? Math.min(100, Math.round((summary.used / summary.limit) * 100))
: null;
const valueLabel = typeof summary.limit === 'number' && summary.limit > 0
? `${summary.used.toLocaleString()} / ${summary.limit.toLocaleString()}`
: t('upload.limitSummary.badges.unlimited');
const description = summary.state === 'unlimited'
? t(unlimitedKey)
: summary.remaining !== null && summary.limit !== null
? t(remainingKey)
.replace('{remaining}', `${Math.max(0, summary.remaining)}`)
.replace('{limit}', `${summary.limit}`)
: valueLabel;
const badgeKey = (() => {
switch (summary.state) {
case 'limit_reached':
return 'upload.limitSummary.badges.limit_reached';
case 'warning':
return 'upload.limitSummary.badges.warning';
case 'unlimited':
return 'upload.limitSummary.badges.unlimited';
default:
return 'upload.limitSummary.badges.ok';
}
})();
return {
id,
label: t(labelKey),
state: summary.state,
tone,
used: summary.used,
limit: summary.limit,
remaining: summary.remaining,
progress,
valueLabel,
description,
badgeLabel: t(badgeKey),
};
}
export function buildLimitSummaries(limits: EventPackageLimits | null | undefined, t: TranslateFn): LimitSummaryCard[] {
if (!limits) {
return [];
}
const cards: LimitSummaryCard[] = [];
if (limits.photos) {
cards.push(buildCard('photos', limits.photos, t));
}
if (limits.guests) {
cards.push(buildCard('guests', limits.guests, t));
}
return cards;
}

View File

@@ -1,107 +0,0 @@
import type { MotionProps, Transition } from 'framer-motion';
import { IOS_EASE, IOS_EASE_SOFT } from './motion';
import type { LiveShowEffectPreset } from '../services/liveShowApi';
export type LiveShowEffectSpec = {
frame: MotionProps;
flash?: MotionProps;
};
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
function resolveIntensity(intensity: number): number {
const safe = Number.isFinite(intensity) ? intensity : 70;
return clamp(safe / 100, 0, 1);
}
function buildTransition(duration: number, ease: Transition['ease']): Transition {
return {
duration,
ease,
};
}
export function resolveLiveShowEffect(
preset: LiveShowEffectPreset,
intensity: number,
reducedMotion: boolean
): LiveShowEffectSpec {
const strength = reducedMotion ? 0 : resolveIntensity(intensity);
const baseDuration = reducedMotion ? 0.2 : clamp(0.9 - strength * 0.35, 0.45, 1);
const exitDuration = reducedMotion ? 0.15 : clamp(baseDuration * 0.6, 0.25, 0.6);
if (reducedMotion) {
return {
frame: {
initial: { opacity: 0 },
animate: { opacity: 1 },
exit: { opacity: 0, transition: buildTransition(exitDuration, IOS_EASE_SOFT) },
transition: buildTransition(baseDuration, IOS_EASE_SOFT),
},
};
}
switch (preset) {
case 'shutter_flash': {
const scale = 1 + strength * 0.05;
return {
frame: {
initial: { opacity: 0, scale, y: 12 * strength },
animate: { opacity: 1, scale: 1, y: 0 },
exit: { opacity: 0, scale: 0.98, transition: buildTransition(exitDuration, IOS_EASE) },
transition: buildTransition(baseDuration, IOS_EASE),
},
flash: {
initial: { opacity: 0 },
animate: { opacity: [0, 0.85, 0], transition: { duration: 0.5, times: [0, 0.2, 1] } },
},
};
}
case 'polaroid_toss': {
const rotation = 3 + strength * 5;
return {
frame: {
initial: { opacity: 0, rotate: -rotation, scale: 0.9 },
animate: { opacity: 1, rotate: 0, scale: 1 },
exit: { opacity: 0, rotate: rotation * 0.5, scale: 0.98, transition: buildTransition(exitDuration, IOS_EASE) },
transition: buildTransition(baseDuration, IOS_EASE),
},
};
}
case 'parallax_glide': {
const scale = 1 + strength * 0.06;
return {
frame: {
initial: { opacity: 0, scale, y: 24 * strength },
animate: { opacity: 1, scale: 1, y: 0 },
exit: { opacity: 0, scale: 1.02, transition: buildTransition(exitDuration, IOS_EASE_SOFT) },
transition: buildTransition(baseDuration + 0.2, IOS_EASE_SOFT),
},
};
}
case 'light_effects': {
return {
frame: {
initial: { opacity: 0 },
animate: { opacity: 1 },
exit: { opacity: 0, transition: buildTransition(exitDuration, IOS_EASE_SOFT) },
transition: buildTransition(baseDuration * 0.8, IOS_EASE_SOFT),
},
};
}
case 'film_cut':
default: {
const scale = 1 + strength * 0.03;
return {
frame: {
initial: { opacity: 0, scale },
animate: { opacity: 1, scale: 1 },
exit: { opacity: 0, scale: 0.99, transition: buildTransition(exitDuration, IOS_EASE) },
transition: buildTransition(baseDuration, IOS_EASE),
},
};
}
}
}

View File

@@ -1,51 +0,0 @@
import type { LocaleCode } from '../i18n/messages';
type LocalizedRecord = Record<string, string | null | undefined>;
function pickLocalizedValue(record: LocalizedRecord, locale: LocaleCode): string | null {
if (typeof record[locale] === 'string' && record[locale]) {
return record[locale] as string;
}
if (typeof record.de === 'string' && record.de) {
return record.de as string;
}
if (typeof record.en === 'string' && record.en) {
return record.en as string;
}
const firstValue = Object.values(record).find((value) => typeof value === 'string' && value);
return (firstValue as string | undefined) ?? null;
}
export function localizeTaskLabel(
raw: string | LocalizedRecord | null | undefined,
locale: LocaleCode,
): string | null {
if (!raw) {
return null;
}
if (typeof raw === 'object') {
return pickLocalizedValue(raw, locale);
}
const trimmed = raw.trim();
if (!trimmed) {
return null;
}
if (trimmed.startsWith('{')) {
try {
const parsed = JSON.parse(trimmed);
if (parsed && typeof parsed === 'object') {
return pickLocalizedValue(parsed as LocalizedRecord, locale) ?? trimmed;
}
} catch {
return trimmed;
}
}
return trimmed;
}

View File

@@ -1,86 +0,0 @@
import type { Variants } from 'framer-motion';
export const IOS_EASE = [0.22, 0.61, 0.36, 1] as const;
export const IOS_EASE_SOFT = [0.25, 0.8, 0.25, 1] as const;
export const STAGGER_FAST: Variants = {
hidden: {},
show: {
transition: {
staggerChildren: 0.06,
delayChildren: 0.04,
},
},
};
export const FADE_UP: Variants = {
hidden: { opacity: 0, y: 10 },
show: {
opacity: 1,
y: 0,
transition: {
duration: 0.24,
ease: IOS_EASE,
},
},
};
export const FADE_SCALE: Variants = {
hidden: { opacity: 0, scale: 0.98 },
show: {
opacity: 1,
scale: 1,
transition: {
duration: 0.22,
ease: IOS_EASE,
},
},
};
export function prefersReducedMotion(): boolean {
if (typeof window === 'undefined') {
return false;
}
return Boolean(window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches);
}
export function getMotionContainerProps(enabled: boolean, variants: Variants) {
if (!enabled) {
return { initial: false } as const;
}
return { variants, initial: 'hidden', animate: 'show' } as const;
}
export function getMotionItemProps(enabled: boolean, variants: Variants) {
return enabled ? { variants } : {};
}
export function getMotionContainerPropsForNavigation(
enabled: boolean,
variants: Variants,
navigationType: 'POP' | 'PUSH' | 'REPLACE'
) {
if (!enabled) {
return { initial: false } as const;
}
const initial = navigationType === 'POP' ? 'hidden' : false;
return { variants, initial, animate: 'show' } as const;
}
export function getMotionItemPropsForNavigation(
enabled: boolean,
variants: Variants,
navigationType: 'POP' | 'PUSH' | 'REPLACE'
) {
if (!enabled) {
return {};
}
const initial = navigationType === 'POP' ? 'hidden' : false;
return { variants, initial, animate: 'show' } as const;
}

View File

@@ -1,24 +0,0 @@
type PushConfig = {
enabled: boolean;
vapidPublicKey: string | null;
};
type RuntimeConfig = {
push: PushConfig;
};
export function getRuntimeConfig(): RuntimeConfig {
const raw = typeof window !== 'undefined' ? window.__GUEST_RUNTIME_CONFIG__ : undefined;
return {
push: {
enabled: Boolean(raw?.push?.enabled),
vapidPublicKey: raw?.push?.vapidPublicKey ?? null,
},
};
}
export function getPushConfig(): PushConfig {
return getRuntimeConfig().push;
}

View File

@@ -1,59 +0,0 @@
import { createPhotoShareLink } from '../services/photosApi';
type ShareOptions = {
token: string;
photoId: number;
title?: string;
text?: string;
};
async function copyToClipboard(text: string): Promise<boolean> {
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text);
return true;
}
} catch {
// ignore and fallback
}
try {
const input = document.createElement('input');
input.value = text;
document.body.appendChild(input);
input.select();
document.execCommand('copy');
document.body.removeChild(input);
return true;
} catch {
return false;
}
}
export async function sharePhotoLink(options: ShareOptions): Promise<{ url: string; method: 'native' | 'clipboard' | 'manual' }>
{
const payload = await createPhotoShareLink(options.token, options.photoId);
const shareData: ShareData = {
title: options.title ?? 'Fotospiel Moment',
text: options.text ?? '',
url: payload.url,
};
if (navigator.share && (!navigator.canShare || navigator.canShare(shareData))) {
try {
await navigator.share(shareData);
return { url: payload.url, method: 'native' };
} catch (error: unknown) {
if (error && typeof error === 'object' && 'name' in error && (error as { name?: string }).name === 'AbortError') {
return { url: payload.url, method: 'native' };
}
// fall through to clipboard
}
}
if (await copyToClipboard(payload.url)) {
return { url: payload.url, method: 'clipboard' };
}
return { url: payload.url, method: 'manual' };
}

View File

@@ -1,18 +0,0 @@
export type TaskIdentity = {
id: number;
};
export function dedupeTasksById<T extends TaskIdentity>(tasks: T[]): T[] {
const seen = new Set<number>();
const unique: T[] = [];
tasks.forEach((task) => {
if (seen.has(task.id)) {
return;
}
seen.add(task.id);
unique.push(task);
});
return unique;
}

View File

@@ -1,94 +0,0 @@
import type { TranslateFn } from '../i18n/useTranslation';
export type UploadErrorDialog = {
code: string;
title: string;
description: string;
hint?: string;
tone: 'danger' | 'warning' | 'info';
};
function formatWithNumbers(template: string, values: Record<string, number | string | undefined>): string {
return Object.entries(values).reduce((acc, [key, value]) => {
if (value === undefined) {
return acc;
}
return acc.replaceAll(`{${key}}`, String(value));
}, template);
}
export function resolveUploadErrorDialog(
code: string | undefined,
meta: Record<string, unknown> | undefined,
t: TranslateFn
): UploadErrorDialog {
const normalized = (code ?? 'unknown').toLowerCase();
const getNumber = (value: unknown): number | undefined => (typeof value === 'number' ? value : undefined);
switch (normalized) {
case 'photo_limit_exceeded': {
const used = getNumber(meta?.used);
const limit = getNumber(meta?.limit);
const remaining = getNumber(meta?.remaining);
return {
code: normalized,
tone: 'danger',
title: t('upload.dialogs.photoLimit.title'),
description: formatWithNumbers(t('upload.dialogs.photoLimit.description'), {
used,
limit,
remaining,
}),
hint: t('upload.dialogs.photoLimit.hint'),
};
}
case 'upload_device_limit':
return {
code: normalized,
tone: 'warning',
title: t('upload.dialogs.deviceLimit.title'),
description: t('upload.dialogs.deviceLimit.description'),
hint: t('upload.dialogs.deviceLimit.hint'),
};
case 'event_package_missing':
case 'event_not_found':
return {
code: normalized,
tone: 'info',
title: t('upload.dialogs.packageMissing.title'),
description: t('upload.dialogs.packageMissing.description'),
hint: t('upload.dialogs.packageMissing.hint'),
};
case 'gallery_expired':
return {
code: normalized,
tone: 'danger',
title: t('upload.dialogs.galleryExpired.title'),
description: t('upload.dialogs.galleryExpired.description'),
hint: t('upload.dialogs.galleryExpired.hint'),
};
case 'csrf_mismatch':
return {
code: normalized,
tone: 'warning',
title: t('upload.dialogs.csrf.title'),
description: t('upload.dialogs.csrf.description'),
hint: t('upload.dialogs.csrf.hint'),
};
default:
return {
code: normalized,
tone: 'info',
title: t('upload.dialogs.generic.title'),
description: t('upload.dialogs.generic.description'),
hint: t('upload.dialogs.generic.hint'),
};
}
}