refactor(guest): retire legacy guest app and move shared modules
This commit is contained in:
34
resources/js/shared/guest/lib/analyticsConsent.ts
Normal file
34
resources/js/shared/guest/lib/analyticsConsent.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
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
|
||||
);
|
||||
}
|
||||
46
resources/js/shared/guest/lib/badges.ts
Normal file
46
resources/js/shared/guest/lib/badges.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
50
resources/js/shared/guest/lib/color.ts
Normal file
50
resources/js/shared/guest/lib/color.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
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;
|
||||
}
|
||||
49
resources/js/shared/guest/lib/csrf.ts
Normal file
49
resources/js/shared/guest/lib/csrf.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
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;
|
||||
}
|
||||
19
resources/js/shared/guest/lib/device.ts
Normal file
19
resources/js/shared/guest/lib/device.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
128
resources/js/shared/guest/lib/emotionTheme.ts
Normal file
128
resources/js/shared/guest/lib/emotionTheme.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
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] ?? '✨';
|
||||
}
|
||||
8
resources/js/shared/guest/lib/engagement.ts
Normal file
8
resources/js/shared/guest/lib/engagement.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
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';
|
||||
}
|
||||
62
resources/js/shared/guest/lib/haptics.ts
Normal file
62
resources/js/shared/guest/lib/haptics.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
97
resources/js/shared/guest/lib/image.ts
Normal file
97
resources/js/shared/guest/lib/image.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
// @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`;
|
||||
}
|
||||
107
resources/js/shared/guest/lib/liveShowEffects.ts
Normal file
107
resources/js/shared/guest/lib/liveShowEffects.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
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),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
51
resources/js/shared/guest/lib/localizeTaskLabel.ts
Normal file
51
resources/js/shared/guest/lib/localizeTaskLabel.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
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;
|
||||
}
|
||||
86
resources/js/shared/guest/lib/motion.ts
Normal file
86
resources/js/shared/guest/lib/motion.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
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;
|
||||
}
|
||||
94
resources/js/shared/guest/lib/uploadErrorDialog.ts
Normal file
94
resources/js/shared/guest/lib/uploadErrorDialog.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
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'),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user