Added Phase‑1 continuation work across deep links, offline moderation queue, and admin push.

resources/js/admin/mobile/lib.
  - Admin push is end‑to‑end: new backend model/migration/service/job + API endpoints, admin runtime config, push‑aware
    service worker, and a settings toggle via useAdminPushSubscription. Notifications now auto‑refresh on push.
  - New PHP/JS tests: admin push API feature test and queue/haptics unit tests
  Added admin-specific PWA icon assets and wired them into the admin manifest, service worker, and admin shell, plus a
  new “Device & permissions” card in mobile Settings with a persistent storage action and translations.
  Details: public/manifest.json, public/admin-sw.js, resources/views/admin.blade.php, new icons in public/; new hook
  resources/js/admin/mobile/hooks/useDevicePermissions.ts, helpers/tests in resources/js/admin/mobile/lib/
  devicePermissions.ts + resources/js/admin/mobile/lib/devicePermissions.test.ts, and Settings UI updates in resources/
  js/admin/mobile/SettingsPage.tsx with copy in resources/js/admin/i18n/locales/en/management.json and resources/js/
admin/i18n/locales/de/management.json.
This commit is contained in:
Codex Agent
2025-12-28 15:00:47 +01:00
parent 4ce409e918
commit b780d82d62
42 changed files with 2258 additions and 121 deletions

View File

@@ -59,6 +59,19 @@ export function DevTenantSwitcher({ bottomOffset = 16, variant = 'floating' }: D
return null;
}
async function handleLogin(key: string) {
if (!helper) {
return;
}
setLoggingIn(key);
try {
await helper.loginAs(key);
} catch (error) {
console.error('[DevAuth] Switch failed', error);
setLoggingIn(null);
}
}
if (variant === 'inline') {
if (collapsed) {
return (
@@ -133,25 +146,14 @@ export function DevTenantSwitcher({ bottomOffset = 16, variant = 'floating' }: D
icon={<PanelRightOpen size={16} />}
borderRadius={999}
position="fixed"
right="$4"
zIndex={1000}
onPress={() => setCollapsed(false)}
style={{ bottom: bottomOffset + 70 }}
>
Demo tenants
</Button>
);
}
async function handleLogin(key: string) {
if (!helper) return;
setLoggingIn(key);
try {
await helper.loginAs(key);
} catch (error) {
console.error('[DevAuth] Switch failed', error);
setLoggingIn(null);
}
right="$4"
zIndex={1000}
onPress={() => setCollapsed(false)}
style={{ bottom: bottomOffset + 70 }}
>
Demo tenants
</Button>
);
}
return (

View File

@@ -673,6 +673,15 @@ export type EventToolkitNotification = {
};
type CreatedEventResponse = { message: string; data: JsonValue; balance: number };
type PhotoResponse = { message: string; data: TenantPhoto };
type AdminPushSubscriptionPayload = {
endpoint: string;
keys: {
p256dh: string;
auth: string;
};
expirationTime?: number | null;
contentEncoding?: string | null;
};
type EventSavePayload = {
name: string;
@@ -1498,6 +1507,43 @@ export async function getEventPhotos(
};
}
export async function getEventPhoto(slug: string, id: number): Promise<TenantPhoto> {
const response = await authorizedFetch(`${eventEndpoint(slug)}/photos/${id}`);
const data = await jsonOrThrow<PhotoResponse>(response, 'Failed to load photo');
return normalizePhoto(data.data);
}
export async function registerAdminPushSubscription(subscription: PushSubscription, deviceId?: string): Promise<void> {
const json = subscription.toJSON() as AdminPushSubscriptionPayload;
const response = await authorizedFetch('/api/v1/tenant/notifications/push-subscriptions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
endpoint: json.endpoint,
keys: json.keys,
expiration_time: json.expirationTime ?? null,
content_encoding: json.contentEncoding ?? null,
device_id: deviceId ?? null,
}),
});
await jsonOrThrow<{ id: number; status: string }>(response, 'Failed to register push subscription', { suppressToast: true });
}
export async function unregisterAdminPushSubscription(endpoint: string): Promise<void> {
const response = await authorizedFetch('/api/v1/tenant/notifications/push-subscriptions', {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ endpoint }),
});
await jsonOrThrow<{ status: string }>(response, 'Failed to unregister push subscription', { suppressToast: true });
}
export async function featurePhoto(slug: string, id: number): Promise<TenantPhoto> {
const response = await authorizedFetch(`${eventEndpoint(slug)}/photos/${id}/feature`, { method: 'POST' });
const data = await jsonOrThrow<PhotoResponse>(response, 'Failed to feature photo');

View File

@@ -1955,7 +1955,14 @@
"unfeatureSuccess": "Highlight entfernt",
"featureFailed": "Highlight konnte nicht geändert werden.",
"approveSuccess": "Foto freigegeben",
"approveFailed": "Freigabe fehlgeschlagen."
"approveFailed": "Freigabe fehlgeschlagen.",
"queued": "Aktion gespeichert. Wird synchronisiert, sobald du online bist.",
"queueTitle": "Änderungen warten auf Sync",
"queueOnline": "{{count}} Aktionen bereit zur Synchronisierung.",
"queueOffline": "{{count}} Aktionen gespeichert offline.",
"queueSync": "Sync",
"queueWaiting": "Offline",
"syncFailed": "Synchronisierung fehlgeschlagen. Bitte später erneut versuchen."
},
"mobileProfile": {
"title": "Profil",
@@ -1976,6 +1983,39 @@
"tenantBadge": "Tenant #{{id}}",
"notificationsTitle": "Benachrichtigungen",
"notificationsLoading": "Lade Einstellungen ...",
"pushTitle": "App Push",
"pushUnsupported": "Push-Benachrichtigungen werden auf diesem Gerät nicht unterstützt.",
"pushDenied": "Benachrichtigungen sind im Browser blockiert.",
"pushActive": "Push aktiv",
"pushInactive": "Push deaktiviert",
"pushLoading": "Lädt ...",
"deviceTitle": "Gerät & Berechtigungen",
"deviceDescription": "Halte die Admin-App schnell, offline-bereit und für Benachrichtigungen freigeschaltet.",
"deviceLoading": "Gerätestatus wird geprüft ...",
"deviceStorageAction": "Offline-Schutz aktivieren",
"deviceStorageError": "Offline-Schutz konnte nicht aktiviert werden.",
"deviceStatusValues": {
"granted": "Erlaubt",
"denied": "Blockiert",
"prompt": "Berechtigung nötig",
"unsupported": "Nicht unterstützt",
"persisted": "Geschützt",
"available": "Nicht geschützt"
},
"deviceStatus": {
"notifications": {
"label": "Benachrichtigungen",
"description": "Erlaubt Warnungen und Admin-Updates."
},
"camera": {
"label": "Kamera",
"description": "Für QR-Scans und schnelle Aufnahmen."
},
"storage": {
"label": "Offline-Speicher",
"description": "Schützt zwischengespeicherte Daten vor Löschung."
}
},
"pref": {}
},
"events": {

View File

@@ -1975,7 +1975,14 @@
"unfeatureSuccess": "Highlight removed",
"featureFailed": "Highlight could not be changed",
"approveSuccess": "Photo approved",
"approveFailed": "Approval failed."
"approveFailed": "Approval failed.",
"queued": "Action saved. Syncs when you are back online.",
"queueTitle": "Changes waiting to sync",
"queueOnline": "{{count}} actions ready to sync.",
"queueOffline": "{{count}} actions saved offline.",
"queueSync": "Sync",
"queueWaiting": "Offline",
"syncFailed": "Sync failed. Please try again later."
},
"mobileProfile": {
"title": "Profile",
@@ -1996,6 +2003,39 @@
"tenantBadge": "Tenant #{{id}}",
"notificationsTitle": "Notifications",
"notificationsLoading": "Loading settings ...",
"pushTitle": "App Push",
"pushUnsupported": "Push notifications are not supported on this device.",
"pushDenied": "Notifications are blocked in your browser.",
"pushActive": "Push active",
"pushInactive": "Push disabled",
"pushLoading": "Loading ...",
"deviceTitle": "Device & permissions",
"deviceDescription": "Keep the admin app fast, offline-ready, and allowed to send alerts.",
"deviceLoading": "Checking device status ...",
"deviceStorageAction": "Enable offline protection",
"deviceStorageError": "Offline storage could not be enabled.",
"deviceStatusValues": {
"granted": "Allowed",
"denied": "Blocked",
"prompt": "Needs permission",
"unsupported": "Not supported",
"persisted": "Protected",
"available": "Not protected"
},
"deviceStatus": {
"notifications": {
"label": "Notifications",
"description": "Allow alerts and admin updates."
},
"camera": {
"label": "Camera",
"description": "Needed for QR scans and quick capture."
},
"storage": {
"label": "Offline storage",
"description": "Protect cached data from eviction."
}
},
"pref": {}
},
"events": {

View File

@@ -0,0 +1,17 @@
export function getAdminDeviceId(): string {
const key = 'admin-device-id';
let id = localStorage.getItem(key);
if (!id) {
id = generateId();
localStorage.setItem(key, id);
}
return id;
}
function generateId(): string {
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

@@ -0,0 +1,23 @@
type PushConfig = {
enabled: boolean;
vapidPublicKey: string | null;
};
type AdminRuntimeConfig = {
push: PushConfig;
};
export function getAdminRuntimeConfig(): AdminRuntimeConfig {
const raw = typeof window !== 'undefined' ? window.__ADMIN_RUNTIME_CONFIG__ : undefined;
return {
push: {
enabled: Boolean(raw?.push?.enabled),
vapidPublicKey: raw?.push?.vapidPublicKey ?? null,
},
};
}
export function getAdminPushConfig(): PushConfig {
return getAdminRuntimeConfig().push;
}

View File

@@ -11,6 +11,7 @@ import { MobileCard, PillBadge, CTAButton, SkeletonCard } from './components/Pri
import { MobileField, MobileInput, MobileSelect } from './components/FormControls';
import {
getEventPhotos,
getEventPhoto,
updatePhotoVisibility,
featurePhoto,
unfeaturePhoto,
@@ -33,11 +34,21 @@ import { buildLimitWarnings } from '../lib/limitWarnings';
import { adminPath } from '../constants';
import { scopeDefaults, selectAddonKeyForScope } from './addons';
import { LegalConsentSheet } from './components/LegalConsentSheet';
import { triggerHaptic } from './lib/haptics';
import { useOnlineStatus } from './hooks/useOnlineStatus';
import {
enqueuePhotoAction,
loadPhotoQueue,
removePhotoAction,
replacePhotoQueue,
type PhotoModerationAction,
} from './lib/photoModerationQueue';
import { ADMIN_EVENT_PHOTOS_PATH } from '../constants';
type FilterKey = 'all' | 'featured' | 'hidden' | 'pending';
export default function MobileEventPhotosPage() {
const { slug: slugParam } = useParams<{ slug?: string }>();
const { slug: slugParam, photoId: photoIdParam } = useParams<{ slug?: string; photoId?: string }>();
const { activeEvent, selectEvent } = useEventContext();
const slug = slugParam ?? activeEvent?.slug ?? null;
const navigate = useNavigate();
@@ -57,7 +68,9 @@ export default function MobileEventPhotosPage() {
const [uploaderFilter, setUploaderFilter] = React.useState('');
const [onlyFeatured, setOnlyFeatured] = React.useState(false);
const [onlyHidden, setOnlyHidden] = React.useState(false);
const [lightbox, setLightbox] = React.useState<TenantPhoto | null>(null);
const [lightboxId, setLightboxId] = React.useState<number | null>(null);
const [pendingPhotoId, setPendingPhotoId] = React.useState<number | null>(null);
const [syncingQueue, setSyncingQueue] = React.useState(false);
const [selectionMode, setSelectionMode] = React.useState(false);
const [selectedIds, setSelectedIds] = React.useState<number[]>([]);
const [bulkBusy, setBulkBusy] = React.useState(false);
@@ -68,6 +81,9 @@ export default function MobileEventPhotosPage() {
const [consentOpen, setConsentOpen] = React.useState(false);
const [consentTarget, setConsentTarget] = React.useState<{ scope: 'photos' | 'gallery' | 'guests'; addonKey: string } | null>(null);
const [consentBusy, setConsentBusy] = React.useState(false);
const [queuedActions, setQueuedActions] = React.useState<PhotoModerationAction[]>(() => loadPhotoQueue());
const online = useOnlineStatus();
const syncingQueueRef = React.useRef(false);
const theme = useTheme();
const text = String(theme.color?.val ?? '#111827');
const muted = String(theme.gray?.val ?? '#4b5563');
@@ -78,12 +94,47 @@ export default function MobileEventPhotosPage() {
const surface = String(theme.surface?.val ?? '#ffffff');
const backdrop = String(theme.gray12?.val ?? '#0f172a');
const lightboxIndex = React.useMemo(() => {
if (lightboxId === null) {
return -1;
}
return photos.findIndex((photo) => photo.id === lightboxId);
}, [photos, lightboxId]);
const lightbox = lightboxIndex >= 0 ? photos[lightboxIndex] : null;
const basePhotosPath = slug ? ADMIN_EVENT_PHOTOS_PATH(slug) : adminPath('/mobile/events');
const parsedPhotoId = React.useMemo(() => {
if (!photoIdParam) {
return null;
}
const parsed = Number(photoIdParam);
return Number.isFinite(parsed) ? parsed : null;
}, [photoIdParam]);
React.useEffect(() => {
if (lightbox) {
if (lightboxId !== null && lightboxIndex === -1 && !loading && pendingPhotoId !== lightboxId) {
setLightboxId(null);
}
}, [lightboxId, lightboxIndex, loading, pendingPhotoId]);
React.useEffect(() => {
if (lightboxId !== null) {
setSelectionMode(false);
setSelectedIds([]);
}
}, [lightbox]);
}, [lightboxId]);
React.useEffect(() => {
if (parsedPhotoId === null) {
if (!photoIdParam) {
setLightboxId(null);
}
setPendingPhotoId(null);
return;
}
setLightboxId(parsedPhotoId);
setPendingPhotoId(parsedPhotoId);
}, [parsedPhotoId, photoIdParam]);
React.useEffect(() => {
if (slugParam && activeEvent?.slug !== slugParam) {
@@ -150,66 +201,255 @@ export default function MobileEventPhotosPage() {
setPage(1);
}, [filter, slug]);
async function toggleVisibility(photo: TenantPhoto) {
if (!slug) return;
setBusyId(photo.id);
try {
const updated = await updatePhotoVisibility(slug, photo.id, photo.status === 'hidden');
setPhotos((prev) => prev.map((p) => (p.id === photo.id ? updated : p)));
setLightbox((prev) => (prev && prev.id === photo.id ? updated : prev));
toast.success(
updated.status === 'hidden'
? t('mobilePhotos.hideSuccess', 'Photo hidden')
: t('mobilePhotos.showSuccess', 'Photo shown'),
);
} catch (err) {
if (!isAuthError(err)) {
setError(getApiErrorMessage(err, t('mobilePhotos.visibilityFailed', 'Sichtbarkeit konnte nicht geändert werden.')));
toast.error(t('mobilePhotos.visibilityFailed', 'Sichtbarkeit konnte nicht geändert werden.'));
const updateQueueState = React.useCallback((queue: PhotoModerationAction[]) => {
replacePhotoQueue(queue);
setQueuedActions(queue);
}, []);
const applyOptimisticUpdate = React.useCallback((photoId: number, action: PhotoModerationAction['action']) => {
setPhotos((prev) =>
prev.map((photo) => {
if (photo.id !== photoId) {
return photo;
}
if (action === 'approve') {
return { ...photo, status: 'approved' };
}
if (action === 'hide') {
return { ...photo, status: 'hidden' };
}
if (action === 'show') {
return { ...photo, status: 'approved' };
}
if (action === 'feature') {
return { ...photo, is_featured: true };
}
if (action === 'unfeature') {
return { ...photo, is_featured: false };
}
return photo;
}),
);
}, []);
const enqueueModerationAction = React.useCallback(
(action: PhotoModerationAction['action'], photoId: number) => {
if (!slug) {
return;
}
} finally {
setBusyId(null);
const nextQueue = enqueuePhotoAction({ eventSlug: slug, photoId, action });
setQueuedActions(nextQueue);
applyOptimisticUpdate(photoId, action);
toast.success(t('mobilePhotos.queued', 'Aktion gespeichert. Wird synchronisiert, sobald du online bist.'));
triggerHaptic('selection');
},
[applyOptimisticUpdate, slug, t],
);
const syncQueuedActions = React.useCallback(
async (options?: { silent?: boolean }) => {
if (!online || syncingQueueRef.current) {
return;
}
const queue = loadPhotoQueue();
if (queue.length === 0) {
return;
}
syncingQueueRef.current = true;
setSyncingQueue(true);
let remaining = queue;
for (const entry of queue) {
try {
let updated: TenantPhoto | null = null;
if (entry.action === 'approve') {
updated = await updatePhotoStatus(entry.eventSlug, entry.photoId, 'approved');
} else if (entry.action === 'hide') {
updated = await updatePhotoVisibility(entry.eventSlug, entry.photoId, true);
} else if (entry.action === 'show') {
updated = await updatePhotoVisibility(entry.eventSlug, entry.photoId, false);
} else if (entry.action === 'feature') {
updated = await featurePhoto(entry.eventSlug, entry.photoId);
} else if (entry.action === 'unfeature') {
updated = await unfeaturePhoto(entry.eventSlug, entry.photoId);
}
remaining = removePhotoAction(remaining, entry.id);
if (updated && entry.eventSlug === slug) {
setPhotos((prev) => prev.map((photo) => (photo.id === updated!.id ? updated! : photo)));
}
} catch (err) {
if (!options?.silent) {
toast.error(t('mobilePhotos.syncFailed', 'Synchronisierung fehlgeschlagen. Bitte später erneut versuchen.'));
}
if (isAuthError(err)) {
break;
}
}
}
updateQueueState(remaining);
setSyncingQueue(false);
syncingQueueRef.current = false;
},
[online, slug, t, updateQueueState],
);
React.useEffect(() => {
if (online) {
void syncQueuedActions({ silent: true });
}
}, [online, syncQueuedActions]);
const setLightboxWithUrl = React.useCallback(
(photoId: number | null, options?: { replace?: boolean }) => {
setLightboxId(photoId);
if (!slug) {
return;
}
const nextPath = photoId ? `${basePhotosPath}/${photoId}` : basePhotosPath;
if (location.pathname !== nextPath) {
navigate(`${nextPath}${location.search}`, { replace: options?.replace ?? false });
}
},
[basePhotosPath, location.pathname, location.search, navigate, slug],
);
const handleModerationAction = React.useCallback(
async (action: PhotoModerationAction['action'], photo: TenantPhoto) => {
if (!slug) {
return;
}
if (!online) {
enqueueModerationAction(action, photo.id);
return;
}
setBusyId(photo.id);
const successMessage = () => {
if (action === 'approve') {
return t('mobilePhotos.approveSuccess', 'Photo approved');
}
if (action === 'hide') {
return t('mobilePhotos.hideSuccess', 'Photo hidden');
}
if (action === 'show') {
return t('mobilePhotos.showSuccess', 'Photo shown');
}
if (action === 'feature') {
return t('mobilePhotos.featureSuccess', 'Als Highlight markiert');
}
return t('mobilePhotos.unfeatureSuccess', 'Highlight entfernt');
};
const errorMessage = () => {
if (action === 'approve') {
return t('mobilePhotos.approveFailed', 'Freigabe fehlgeschlagen.');
}
if (action === 'hide' || action === 'show') {
return t('mobilePhotos.visibilityFailed', 'Sichtbarkeit konnte nicht geändert werden.');
}
return t('mobilePhotos.featureFailed', 'Feature konnte nicht geändert werden.');
};
try {
let updated: TenantPhoto;
if (action === 'approve') {
updated = await updatePhotoStatus(slug, photo.id, 'approved');
} else if (action === 'hide') {
updated = await updatePhotoVisibility(slug, photo.id, true);
} else if (action === 'show') {
updated = await updatePhotoVisibility(slug, photo.id, false);
} else if (action === 'feature') {
updated = await featurePhoto(slug, photo.id);
} else {
updated = await unfeaturePhoto(slug, photo.id);
}
setPhotos((prev) => prev.map((p) => (p.id === photo.id ? updated : p)));
toast.success(successMessage());
triggerHaptic(action === 'approve' ? 'success' : 'medium');
} catch (err) {
if (!isAuthError(err)) {
setError(getApiErrorMessage(err, errorMessage()));
toast.error(errorMessage());
}
} finally {
setBusyId(null);
}
},
[enqueueModerationAction, online, slug, t],
);
React.useEffect(() => {
if (!slug || pendingPhotoId === null) {
return;
}
if (photos.some((photo) => photo.id === pendingPhotoId)) {
setPendingPhotoId(null);
return;
}
if (loading) {
return;
}
let active = true;
void (async () => {
try {
const fetched = await getEventPhoto(slug, pendingPhotoId);
if (!active) {
return;
}
setPhotos((prev) => {
if (prev.some((photo) => photo.id === fetched.id)) {
return prev;
}
return [fetched, ...prev];
});
} catch (err) {
if (!isAuthError(err)) {
toast.error(t('mobilePhotos.loadFailed', 'Fotos konnten nicht geladen werden.'));
}
if (active) {
setLightboxWithUrl(null, { replace: true });
}
} finally {
if (active) {
setPendingPhotoId(null);
}
}
})();
return () => {
active = false;
};
}, [pendingPhotoId, slug, photos, loading, t, setLightboxWithUrl]);
async function toggleVisibility(photo: TenantPhoto) {
const action = photo.status === 'hidden' ? 'show' : 'hide';
await handleModerationAction(action, photo);
}
async function toggleFeature(photo: TenantPhoto) {
if (!slug) return;
setBusyId(photo.id);
try {
const updated = photo.is_featured ? await unfeaturePhoto(slug, photo.id) : await featurePhoto(slug, photo.id);
setPhotos((prev) => prev.map((p) => (p.id === photo.id ? updated : p)));
setLightbox((prev) => (prev && prev.id === photo.id ? updated : prev));
toast.success(
updated.is_featured
? t('mobilePhotos.featureSuccess', 'Als Highlight markiert')
: t('mobilePhotos.unfeatureSuccess', 'Highlight entfernt'),
);
} catch (err) {
if (!isAuthError(err)) {
setError(getApiErrorMessage(err, t('mobilePhotos.featureFailed', 'Feature konnte nicht geändert werden.')));
toast.error(t('mobilePhotos.featureFailed', 'Feature konnte nicht geändert werden.'));
}
} finally {
setBusyId(null);
}
const action = photo.is_featured ? 'unfeature' : 'feature';
await handleModerationAction(action, photo);
}
async function approvePhoto(photo: TenantPhoto) {
if (!slug) return;
setBusyId(photo.id);
try {
const updated = await updatePhotoStatus(slug, photo.id, 'approved');
setPhotos((prev) => prev.map((p) => (p.id === photo.id ? updated : p)));
setLightbox((prev) => (prev && prev.id === photo.id ? updated : prev));
toast.success(t('mobilePhotos.approveSuccess', 'Photo approved'));
} catch (err) {
if (!isAuthError(err)) {
setError(getApiErrorMessage(err, t('mobilePhotos.approveFailed', 'Freigabe fehlgeschlagen.')));
toast.error(t('mobilePhotos.approveFailed', 'Freigabe fehlgeschlagen.'));
}
} finally {
setBusyId(null);
}
await handleModerationAction('approve', photo);
}
const selectedPhotos = React.useMemo(
@@ -221,6 +461,12 @@ export default function MobileEventPhotosPage() {
const hasVisibleSelection = selectedPhotos.some((photo) => photo.status !== 'hidden');
const hasFeaturedSelection = selectedPhotos.some((photo) => photo.is_featured);
const hasUnfeaturedSelection = selectedPhotos.some((photo) => !photo.is_featured);
const queuedEventCount = React.useMemo(() => {
if (!slug) {
return queuedActions.length;
}
return queuedActions.filter((action) => action.eventSlug === slug).length;
}, [queuedActions, slug]);
function toggleSelection(id: number) {
setSelectedIds((prev) => (prev.includes(id) ? prev.filter((item) => item !== id) : [...prev, id]));
@@ -231,6 +477,32 @@ export default function MobileEventPhotosPage() {
setSelectionMode(false);
}
const handleLightboxDragEnd = React.useCallback(
(_event: PointerEvent, info: { offset: { x: number; y: number } }) => {
if (lightboxIndex < 0) {
return;
}
const { x, y } = info.offset;
const absX = Math.abs(x);
const absY = Math.abs(y);
const swipeThreshold = 80;
const dismissThreshold = 90;
if (absY > absX && y > dismissThreshold) {
setLightboxWithUrl(null, { replace: true });
return;
}
if (absX > swipeThreshold) {
const nextIndex = x < 0 ? lightboxIndex + 1 : lightboxIndex - 1;
if (nextIndex >= 0 && nextIndex < photos.length) {
setLightboxWithUrl(photos[nextIndex]?.id ?? null, { replace: true });
}
}
},
[lightboxIndex, photos, setLightboxWithUrl],
);
async function applyBulkAction(action: 'approve' | 'hide' | 'show' | 'feature' | 'unfeature') {
if (!slug || bulkBusy || selectedPhotos.length === 0) return;
setBulkBusy(true);
@@ -246,6 +518,19 @@ export default function MobileEventPhotosPage() {
setBulkBusy(false);
return;
}
if (!online) {
let nextQueue: PhotoModerationAction[] = [];
targets.forEach((photo) => {
nextQueue = enqueuePhotoAction({ eventSlug: slug, photoId: photo.id, action });
applyOptimisticUpdate(photo.id, action);
});
setQueuedActions(nextQueue);
toast.success(t('mobilePhotos.queued', 'Aktion gespeichert. Wird synchronisiert, sobald du online bist.'));
triggerHaptic('selection');
setBulkBusy(false);
return;
}
try {
const results = await Promise.allSettled(
targets.map(async (photo) => {
@@ -271,8 +556,8 @@ export default function MobileEventPhotosPage() {
if (updates.length) {
setPhotos((prev) => prev.map((photo) => updates.find((update) => update.id === photo.id) ?? photo));
setLightbox((prev) => (prev ? updates.find((update) => update.id === prev.id) ?? prev : prev));
toast.success(t('mobilePhotos.bulkUpdated', 'Bulk update applied'));
triggerHaptic('success');
}
} catch {
toast.error(t('mobilePhotos.bulkFailed', 'Bulk update failed'));
@@ -375,6 +660,35 @@ export default function MobileEventPhotosPage() {
</MobileCard>
) : null}
{queuedEventCount > 0 ? (
<MobileCard>
<XStack alignItems="center" justifyContent="space-between" space="$2">
<YStack space="$1" flex={1}>
<Text fontSize="$sm" fontWeight="700" color={text}>
{t('mobilePhotos.queueTitle', 'Änderungen warten auf Sync')}
</Text>
<Text fontSize="$xs" color={muted}>
{online
? t('mobilePhotos.queueOnline', '{{count}} Aktionen bereit zum Synchronisieren.', {
count: queuedEventCount,
})
: t('mobilePhotos.queueOffline', '{{count}} Aktionen gespeichert offline.', {
count: queuedEventCount,
})}
</Text>
</YStack>
<CTAButton
label={online ? t('mobilePhotos.queueSync', 'Sync') : t('mobilePhotos.queueWaiting', 'Offline')}
onPress={() => syncQueuedActions()}
tone="ghost"
fullWidth={false}
disabled={!online}
loading={syncingQueue}
/>
</XStack>
</MobileCard>
) : null}
<MobileInput
type="search"
value={search}
@@ -451,7 +765,7 @@ export default function MobileEventPhotosPage() {
return (
<Pressable
key={photo.id}
onPress={() => (selectionMode ? toggleSelection(photo.id) : setLightbox(photo))}
onPress={() => (selectionMode ? toggleSelection(photo.id) : setLightboxWithUrl(photo.id))}
>
<YStack
borderRadius={10}
@@ -605,12 +919,21 @@ export default function MobileEventPhotosPage() {
boxShadow: '0 10px 30px rgba(0,0,0,0.25)',
}}
>
<motion.img
layoutId={`photo-${lightbox.id}`}
src={lightbox.url ?? lightbox.thumbnail_url ?? undefined}
alt={lightbox.caption ?? 'Photo'}
style={{ width: '100%', maxHeight: '60vh', objectFit: 'contain', background: backdrop }}
/>
<motion.div
drag
dragElastic={0.2}
dragConstraints={{ left: -120, right: 120, top: -120, bottom: 120 }}
dragSnapToOrigin
onDragEnd={handleLightboxDragEnd}
style={{ touchAction: 'none' }}
>
<motion.img
layoutId={`photo-${lightbox.id}`}
src={lightbox.url ?? lightbox.thumbnail_url ?? undefined}
alt={lightbox.caption ?? 'Photo'}
style={{ width: '100%', maxHeight: '60vh', objectFit: 'contain', background: backdrop }}
/>
</motion.div>
<YStack padding="$3" space="$2">
<XStack space="$2" alignItems="center">
<PillBadge tone="muted">{lightbox.uploader_name || t('events.members.roles.guest', 'Guest')}</PillBadge>
@@ -657,7 +980,11 @@ export default function MobileEventPhotosPage() {
style={{ flex: 1, minWidth: 140 }}
/>
</XStack>
<CTAButton label={t('common.close', 'Close')} tone="ghost" onPress={() => setLightbox(null)} />
<CTAButton
label={t('common.close', 'Close')}
tone="ghost"
onPress={() => setLightboxWithUrl(null, { replace: true })}
/>
</YStack>
</motion.div>
</motion.div>

View File

@@ -1,10 +1,11 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { useLocation, useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Bell, RefreshCcw } from 'lucide-react';
import { Bell, Check, ChevronRight, RefreshCcw } from 'lucide-react';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Pressable } from '@tamagui/react-native-web-lite';
import { motion, useAnimationControls, type PanInfo } from 'framer-motion';
import { MobileShell, HeaderActionButton } from './components/MobileShell';
import { MobileCard, PillBadge, SkeletonCard, CTAButton } from './components/Primitives';
import { MobileSelect } from './components/FormControls';
@@ -15,6 +16,8 @@ import toast from 'react-hot-toast';
import { MobileSheet } from './components/Sheet';
import { getEvents, TenantEvent } from '../api';
import { useTheme } from '@tamagui/core';
import { triggerHaptic } from './lib/haptics';
import { adminPath } from '../constants';
type NotificationItem = {
id: string;
@@ -27,6 +30,94 @@ type NotificationItem = {
scope: 'photos' | 'guests' | 'gallery' | 'events' | 'package' | 'general';
};
type NotificationSwipeRowProps = {
item: NotificationItem;
onOpen: (item: NotificationItem) => void;
onMarkRead: (item: NotificationItem) => void;
children: React.ReactNode;
};
function NotificationSwipeRow({ item, onOpen, onMarkRead, children }: NotificationSwipeRowProps) {
const { t } = useTranslation('management');
const theme = useTheme();
const controls = useAnimationControls();
const dragged = React.useRef(false);
const markBg = String(theme.green3?.val ?? '#dcfce7');
const markText = String(theme.green10?.val ?? '#166534');
const detailBg = String(theme.blue3?.val ?? '#dbeafe');
const detailText = String(theme.blue10?.val ?? '#1d4ed8');
const handleDrag = (_event: MouseEvent | TouchEvent | PointerEvent, info: PanInfo) => {
dragged.current = Math.abs(info.offset.x) > 6;
};
const handleDragEnd = (_event: MouseEvent | TouchEvent | PointerEvent, info: PanInfo) => {
const swipeThreshold = 64;
const offsetX = info.offset.x;
if (offsetX > swipeThreshold && !item.is_read) {
void onMarkRead(item);
} else if (offsetX < -swipeThreshold) {
onOpen(item);
}
dragged.current = false;
void controls.start({ x: 0, transition: { type: 'spring', stiffness: 320, damping: 26 } });
};
const handlePress = () => {
if (dragged.current) {
dragged.current = false;
return;
}
onOpen(item);
};
return (
<div style={{ position: 'relative' }}>
<XStack
alignItems="center"
justifyContent="space-between"
paddingHorizontal="$3"
borderRadius="$4"
pointerEvents="none"
style={{ position: 'absolute', top: 0, right: 0, bottom: 0, left: 0 }}
>
<XStack alignItems="center" space="$2">
<Check size={16} color={markText} />
<Text fontSize="$xs" fontWeight="700" color={markText}>
{item.is_read ? t('notificationLogs.read', 'Read') : t('notificationLogs.markRead', 'Mark read')}
</Text>
</XStack>
<XStack alignItems="center" space="$2">
<Text fontSize="$xs" fontWeight="700" color={detailText}>
Details
</Text>
<ChevronRight size={16} color={detailText} />
</XStack>
</XStack>
<XStack
borderRadius="$4"
overflow="hidden"
pointerEvents="none"
backgroundColor={item.is_read ? detailBg : markBg}
opacity={0.5}
style={{ position: 'absolute', top: 0, right: 0, bottom: 0, left: 0 }}
/>
<motion.div
drag="x"
dragElastic={0.2}
dragConstraints={{ left: -96, right: 96 }}
onDrag={handleDrag}
onDragEnd={handleDragEnd}
animate={controls}
initial={{ x: 0 }}
style={{ touchAction: 'pan-y', position: 'relative', zIndex: 1 }}
>
<Pressable onPress={handlePress}>{children}</Pressable>
</motion.div>
</div>
);
}
function formatLog(
log: NotificationLogEntry,
t: (key: string, defaultValue?: string, options?: Record<string, unknown>) => string,
@@ -207,6 +298,8 @@ async function loadNotifications(
export default function MobileNotificationsPage() {
const navigate = useNavigate();
const location = useLocation();
const { notificationId } = useParams<{ notificationId?: string }>();
const { t } = useTranslation('management');
const search = new URLSearchParams(typeof window !== 'undefined' ? window.location.search : '');
const slug = search.get('event') ?? undefined;
@@ -251,6 +344,20 @@ export default function MobileNotificationsPage() {
void reload();
}, [reload]);
React.useEffect(() => {
const handleMessage = (event: MessageEvent) => {
if (event.data?.type === 'admin-notification-refresh') {
void reload();
}
};
navigator.serviceWorker?.addEventListener('message', handleMessage);
return () => {
navigator.serviceWorker?.removeEventListener('message', handleMessage);
};
}, [reload]);
React.useEffect(() => {
(async () => {
try {
@@ -287,19 +394,58 @@ export default function MobileNotificationsPage() {
const showFilterNotice = Boolean(slug) && filtered.length === 0 && notifications.length > 0;
const markSelectedRead = async () => {
const markNotificationRead = React.useCallback(
async (item: NotificationItem, options?: { close?: boolean }) => {
const id = Number(item.id);
if (!Number.isFinite(id)) return;
try {
await markNotificationLogs([id], 'read');
await reload();
triggerHaptic('success');
if (options?.close) {
setDetailOpen(false);
setSelectedNotification(null);
}
} catch {
toast.error(t('notificationLogs.markFailed', 'Could not update notifications.'));
}
},
[reload, t],
);
const markSelectedRead = React.useCallback(async () => {
if (!selectedNotification) return;
const id = Number(selectedNotification.id);
if (!Number.isFinite(id)) return;
try {
await markNotificationLogs([id], 'read');
await reload();
setDetailOpen(false);
setSelectedNotification(null);
} catch {
toast.error(t('notificationLogs.markFailed', 'Could not update notifications.'));
await markNotificationRead(selectedNotification, { close: true });
}, [markNotificationRead, selectedNotification]);
const notificationListPath = adminPath('/mobile/notifications');
const openNotification = React.useCallback(
(item: NotificationItem) => {
setSelectedNotification(item);
setDetailOpen(true);
if (notificationId !== String(item.id)) {
navigate(`${notificationListPath}/${item.id}${location.search}`, { replace: false });
}
triggerHaptic('light');
},
[location.search, navigate, notificationId, notificationListPath],
);
React.useEffect(() => {
if (!notificationId || loading) {
return;
}
};
const targetId = Number(notificationId);
if (!Number.isFinite(targetId)) {
return;
}
const target = notifications.find((item) => Number(item.id) === targetId);
if (target) {
setSelectedNotification(target);
setDetailOpen(true);
}
}, [notificationId, notifications, loading]);
return (
<MobileShell
@@ -327,7 +473,7 @@ export default function MobileNotificationsPage() {
</Text>
<Pressable
onPress={() => {
navigate('/admin/mobile/notifications', { replace: true });
navigate(notificationListPath, { replace: true });
}}
>
<Text fontSize="$sm" color={primary} fontWeight="700">
@@ -340,7 +486,15 @@ export default function MobileNotificationsPage() {
<XStack space="$2" marginBottom="$2">
<MobileSelect
value={statusParam}
onChange={(e) => navigate(`/admin/mobile/notifications?${new URLSearchParams({ status: e.target.value, scope: scopeParam, event: slug ?? '' }).toString()}`)}
onChange={(e) =>
navigate(
`${notificationListPath}?${new URLSearchParams({
status: e.target.value,
scope: scopeParam,
event: slug ?? '',
}).toString()}`,
)
}
compact
style={{ minWidth: 120 }}
>
@@ -350,7 +504,15 @@ export default function MobileNotificationsPage() {
</MobileSelect>
<MobileSelect
value={scopeParam}
onChange={(e) => navigate(`/admin/mobile/notifications?${new URLSearchParams({ scope: e.target.value, status: statusParam, event: slug ?? '' }).toString()}`)}
onChange={(e) =>
navigate(
`${notificationListPath}?${new URLSearchParams({
scope: e.target.value,
status: statusParam,
event: slug ?? '',
}).toString()}`,
)
}
compact
style={{ minWidth: 140 }}
>
@@ -369,6 +531,7 @@ export default function MobileNotificationsPage() {
try {
await markNotificationLogs(unreadIds, 'read');
void reload();
triggerHaptic('success');
} catch {
toast.error(t('notificationLogs.markFailed', 'Could not update notifications.'));
}
@@ -401,12 +564,11 @@ export default function MobileNotificationsPage() {
</Pressable>
) : null}
{statusFiltered.map((item) => (
<Pressable
<NotificationSwipeRow
key={item.id}
onPress={() => {
setSelectedNotification(item);
setDetailOpen(true);
}}
item={item}
onOpen={openNotification}
onMarkRead={markNotificationRead}
>
<MobileCard space="$2" borderColor={item.is_read ? border : primary}>
<XStack alignItems="center" space="$2">
@@ -432,7 +594,7 @@ export default function MobileNotificationsPage() {
<PillBadge tone={item.tone === 'warning' ? 'warning' : 'muted'}>{item.time}</PillBadge>
</XStack>
</MobileCard>
</Pressable>
</NotificationSwipeRow>
))}
</YStack>
)}
@@ -442,6 +604,9 @@ export default function MobileNotificationsPage() {
onClose={() => {
setDetailOpen(false);
setSelectedNotification(null);
if (notificationId) {
navigate(`${notificationListPath}${location.search}`, { replace: true });
}
}}
title={selectedNotification?.title ?? t('mobileNotifications.title', 'Notifications')}
footer={
@@ -489,7 +654,7 @@ export default function MobileNotificationsPage() {
onPress={() => {
setShowEventPicker(false);
if (ev.slug) {
navigate(`/admin/mobile/notifications?event=${ev.slug}`);
navigate(`${notificationListPath}?event=${ev.slug}`);
}
}}
>

View File

@@ -1,7 +1,7 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Shield, Bell, User } from 'lucide-react';
import { Shield, Bell, User, Smartphone } from 'lucide-react';
import { YStack, XStack } from '@tamagui/stacks';
import { YGroup } from '@tamagui/group';
import { ListItem } from '@tamagui/list-item';
@@ -18,6 +18,9 @@ import {
} from '../api';
import { getApiErrorMessage } from '../lib/apiError';
import { adminPath } from '../constants';
import { useAdminPushSubscription } from './hooks/useAdminPushSubscription';
import { useDevicePermissions } from './hooks/useDevicePermissions';
import { type PermissionStatus, type StorageStatus } from './lib/devicePermissions';
type PreferenceKey = keyof NotificationPreferences;
@@ -47,6 +50,48 @@ export default function MobileSettingsPage() {
const [loading, setLoading] = React.useState(true);
const [saving, setSaving] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const [storageSaving, setStorageSaving] = React.useState(false);
const [storageError, setStorageError] = React.useState<string | null>(null);
const pushState = useAdminPushSubscription();
const devicePermissions = useDevicePermissions();
const pushDescription = React.useMemo(() => {
if (!pushState.supported) {
return t('mobileSettings.pushUnsupported', 'Push wird auf diesem Gerät nicht unterstützt.');
}
if (pushState.permission === 'denied') {
return t('mobileSettings.pushDenied', 'Benachrichtigungen sind im Browser blockiert.');
}
if (pushState.subscribed) {
return t('mobileSettings.pushActive', 'Push aktiv');
}
return t('mobileSettings.pushInactive', 'Push deaktiviert');
}, [pushState.permission, pushState.subscribed, pushState.supported, t]);
const permissionTone = (status: PermissionStatus) => {
if (status === 'granted') {
return 'success';
}
if (status === 'denied' || status === 'prompt') {
return 'warning';
}
return 'muted';
};
const storageTone = (status: StorageStatus) => {
if (status === 'persisted') {
return 'success';
}
if (status === 'available') {
return 'warning';
}
return 'muted';
};
const permissionLabel = (status: PermissionStatus) =>
t(`mobileSettings.deviceStatusValues.${status}`, status);
const storageLabel = (status: StorageStatus) =>
t(`mobileSettings.deviceStatusValues.${status}`, status);
React.useEffect(() => {
(async () => {
@@ -71,6 +116,12 @@ export default function MobileSettingsPage() {
})();
}, [t]);
React.useEffect(() => {
if (devicePermissions.storage === 'persisted') {
setStorageError(null);
}
}, [devicePermissions.storage]);
const togglePref = (key: PreferenceKey) => {
setPreferences((prev) => ({
...prev,
@@ -98,6 +149,20 @@ export default function MobileSettingsPage() {
setPreferences(defaults);
};
const handleStoragePersist = async () => {
setStorageSaving(true);
const granted = await devicePermissions.requestPersistentStorage();
setStorageSaving(false);
if (granted) {
setStorageError(null);
void devicePermissions.refresh();
} else {
setStorageError(
t('mobileSettings.deviceStorageError', 'Offline-Schutz konnte nicht aktiviert werden.')
);
}
};
return (
<MobileShell activeTab="profile" title={t('mobileSettings.title', 'Settings')} onBack={() => navigate(-1)}>
{error ? (
@@ -140,6 +205,43 @@ export default function MobileSettingsPage() {
</Text>
) : (
<YGroup borderRadius="$4" borderWidth={1} borderColor={border} overflow="hidden">
<YGroup.Item bordered>
<ListItem
hoverTheme
pressTheme
paddingVertical="$2"
paddingHorizontal="$3"
title={
<Text fontSize="$sm" color={text} fontWeight="700">
{t('mobileSettings.pushTitle', 'App Push')}
</Text>
}
subTitle={
<Text fontSize="$xs" color={muted}>
{pushState.loading
? t('mobileSettings.pushLoading', 'Lädt ...')
: pushDescription}
</Text>
}
iconAfter={
<Switch
size="$4"
checked={pushState.subscribed}
onCheckedChange={(value) => {
if (value) {
void pushState.enable();
} else {
void pushState.disable();
}
}}
disabled={!pushState.supported || pushState.permission === 'denied' || pushState.loading}
aria-label={t('mobileSettings.pushTitle', 'App Push')}
>
<Switch.Thumb />
</Switch>
}
/>
</YGroup.Item>
{AVAILABLE_PREFS.map((key, index) => (
<YGroup.Item key={key} bordered={index < AVAILABLE_PREFS.length - 1}>
<ListItem
@@ -172,12 +274,88 @@ export default function MobileSettingsPage() {
))}
</YGroup>
)}
{pushState.error ? (
<Text fontSize="$xs" color="#b91c1c">
{pushState.error}
</Text>
) : null}
<XStack space="$2">
<CTAButton label={saving ? t('common.processing', '...') : t('settings.notifications.actions.save', 'Speichern')} onPress={() => handleSave()} />
<CTAButton label={t('common.reset', 'Reset')} tone="ghost" onPress={() => handleReset()} />
</XStack>
</MobileCard>
<MobileCard space="$3">
<XStack alignItems="center" space="$2">
<Smartphone size={18} color={text} />
<Text fontSize="$md" fontWeight="800" color={text}>
{t('mobileSettings.deviceTitle', 'Device & permissions')}
</Text>
</XStack>
<Text fontSize="$sm" color={muted}>
{t('mobileSettings.deviceDescription', 'Check permissions so the admin app stays fast and offline-ready.')}
</Text>
{devicePermissions.loading ? (
<Text fontSize="$sm" color={muted}>
{t('mobileSettings.deviceLoading', 'Checking device status ...')}
</Text>
) : (
<YStack space="$2">
<XStack alignItems="center" justifyContent="space-between" gap="$2">
<YStack flex={1} space="$1">
<Text fontSize="$sm" fontWeight="700" color={text}>
{t('mobileSettings.deviceStatus.notifications.label', 'Notifications')}
</Text>
<Text fontSize="$xs" color={muted}>
{t('mobileSettings.deviceStatus.notifications.description', 'Allow alerts and admin updates.')}
</Text>
</YStack>
<PillBadge tone={permissionTone(devicePermissions.notifications)}>
{permissionLabel(devicePermissions.notifications)}
</PillBadge>
</XStack>
<XStack alignItems="center" justifyContent="space-between" gap="$2">
<YStack flex={1} space="$1">
<Text fontSize="$sm" fontWeight="700" color={text}>
{t('mobileSettings.deviceStatus.camera.label', 'Camera')}
</Text>
<Text fontSize="$xs" color={muted}>
{t('mobileSettings.deviceStatus.camera.description', 'Needed for QR scans and quick capture.')}
</Text>
</YStack>
<PillBadge tone={permissionTone(devicePermissions.camera)}>
{permissionLabel(devicePermissions.camera)}
</PillBadge>
</XStack>
<XStack alignItems="center" justifyContent="space-between" gap="$2">
<YStack flex={1} space="$1">
<Text fontSize="$sm" fontWeight="700" color={text}>
{t('mobileSettings.deviceStatus.storage.label', 'Offline storage')}
</Text>
<Text fontSize="$xs" color={muted}>
{t('mobileSettings.deviceStatus.storage.description', 'Protect cached data from eviction.')}
</Text>
</YStack>
<PillBadge tone={storageTone(devicePermissions.storage)}>
{storageLabel(devicePermissions.storage)}
</PillBadge>
</XStack>
</YStack>
)}
{devicePermissions.storage === 'available' ? (
<CTAButton
label={storageSaving ? t('common.processing', '...') : t('mobileSettings.deviceStorageAction', 'Enable offline protection')}
onPress={() => handleStoragePersist()}
disabled={storageSaving}
/>
) : null}
{storageError ? (
<Text fontSize="$xs" color="#b91c1c">
{storageError}
</Text>
) : null}
</MobileCard>
<MobileCard space="$3">
<XStack alignItems="center" space="$2">
<User size={18} color={text} />

View File

@@ -0,0 +1,170 @@
import React from 'react';
import { getAdminPushConfig } from '../../lib/runtime-config';
import { registerAdminPushSubscription, unregisterAdminPushSubscription } from '../../api';
import { getAdminDeviceId } from '../../lib/device';
type PushSubscriptionState = {
supported: boolean;
permission: NotificationPermission;
subscribed: boolean;
loading: boolean;
error: string | null;
enable: () => Promise<void>;
disable: () => Promise<void>;
refresh: () => Promise<void>;
};
export function useAdminPushSubscription(): PushSubscriptionState {
const pushConfig = React.useMemo(() => getAdminPushConfig(), []);
const supported = React.useMemo(() => {
return typeof window !== 'undefined'
&& typeof navigator !== 'undefined'
&& typeof Notification !== 'undefined'
&& 'serviceWorker' in navigator
&& 'PushManager' in window
&& pushConfig.enabled;
}, [pushConfig.enabled]);
const [permission, setPermission] = React.useState<NotificationPermission>(() => {
if (typeof Notification === 'undefined') {
return 'default';
}
return Notification.permission;
});
const [subscription, setSubscription] = React.useState<PushSubscription | null>(null);
const [loading, setLoading] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const refresh = React.useCallback(async () => {
if (!supported) {
return;
}
try {
const registration = await navigator.serviceWorker.ready;
const current = await registration.pushManager.getSubscription();
setSubscription(current);
setPermission(Notification.permission);
} catch (err) {
console.warn('Unable to refresh admin push subscription', err);
setSubscription(null);
}
}, [supported]);
React.useEffect(() => {
if (!supported) {
return;
}
void refresh();
const handleMessage = (event: MessageEvent) => {
if (event.data?.type === 'push-subscription-change') {
void refresh();
}
};
navigator.serviceWorker?.addEventListener('message', handleMessage);
return () => {
navigator.serviceWorker?.removeEventListener('message', handleMessage);
};
}, [refresh, supported]);
const enable = React.useCallback(async () => {
if (!supported) {
setError('Push-Benachrichtigungen werden auf diesem Gerät nicht unterstützt.');
return;
}
setLoading(true);
setError(null);
try {
const permissionResult = await Notification.requestPermission();
setPermission(permissionResult);
if (permissionResult !== 'granted') {
throw new Error('Bitte erlaube Benachrichtigungen, um Push zu aktivieren.');
}
const registration = await navigator.serviceWorker.ready;
const existing = await registration.pushManager.getSubscription();
if (existing) {
await registerAdminPushSubscription(existing, getAdminDeviceId());
setSubscription(existing);
return;
}
if (!pushConfig.vapidPublicKey) {
throw new Error('Push-Konfiguration ist nicht vollständig.');
}
const newSubscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(pushConfig.vapidPublicKey).buffer as ArrayBuffer,
});
await registerAdminPushSubscription(newSubscription, getAdminDeviceId());
setSubscription(newSubscription);
} catch (err) {
const message = err instanceof Error ? err.message : 'Push konnte nicht aktiviert werden.';
setError(message);
console.error(err);
await refresh();
} finally {
setLoading(false);
}
}, [pushConfig.vapidPublicKey, refresh, supported]);
const disable = React.useCallback(async () => {
if (!supported || !subscription) {
return;
}
setLoading(true);
setError(null);
try {
await unregisterAdminPushSubscription(subscription.endpoint);
await subscription.unsubscribe();
setSubscription(null);
} catch (err) {
const message = err instanceof Error ? err.message : 'Push konnte nicht deaktiviert werden.';
setError(message);
console.error(err);
} finally {
setLoading(false);
}
}, [subscription, supported]);
return {
supported,
permission,
subscribed: Boolean(subscription),
loading,
error,
enable,
disable,
refresh,
};
}
function urlBase64ToUint8Array(base64String: string): Uint8Array {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const rawData = typeof window !== 'undefined'
? window.atob(base64)
: Buffer.from(base64, 'base64').toString('binary');
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; i += 1) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}

View File

@@ -0,0 +1,97 @@
import React from 'react';
import {
normalizePermissionState,
resolveStorageStatus,
type PermissionStatus,
type StorageStatus,
} from '../lib/devicePermissions';
type DevicePermissionsState = {
notifications: PermissionStatus;
camera: PermissionStatus;
storage: StorageStatus;
};
type DevicePermissionsHook = DevicePermissionsState & {
loading: boolean;
refresh: () => Promise<void>;
requestPersistentStorage: () => Promise<boolean>;
};
export function useDevicePermissions(): DevicePermissionsHook {
const [permissions, setPermissions] = React.useState<DevicePermissionsState>({
notifications: 'unsupported',
camera: 'unsupported',
storage: 'unsupported',
});
const [loading, setLoading] = React.useState(true);
const refresh = React.useCallback(async () => {
setLoading(true);
try {
if (typeof window === 'undefined' || typeof navigator === 'undefined') {
return;
}
let notificationState: PermissionStatus = 'unsupported';
if ('Notification' in window) {
notificationState = normalizePermissionState(Notification.permission);
}
let cameraState: PermissionStatus = 'unsupported';
if (navigator.permissions?.query) {
try {
const cameraPermission = await navigator.permissions.query({
name: 'camera' as PermissionName,
});
cameraState = normalizePermissionState(cameraPermission.state);
} catch {
cameraState = 'unsupported';
}
}
const storageSupported = Boolean(navigator.storage?.persisted);
let persisted: boolean | null = null;
if (storageSupported) {
persisted = await navigator.storage.persisted();
}
setPermissions({
notifications: notificationState,
camera: cameraState,
storage: resolveStorageStatus(persisted, storageSupported),
});
} finally {
setLoading(false);
}
}, []);
const requestPersistentStorage = React.useCallback(async () => {
if (typeof navigator === 'undefined' || !navigator.storage?.persist) {
return false;
}
try {
const granted = await navigator.storage.persist();
setPermissions((prev) => ({
...prev,
storage: granted ? 'persisted' : 'available',
}));
return granted;
} catch {
return false;
}
}, []);
React.useEffect(() => {
void refresh();
}, [refresh]);
return {
...permissions,
loading,
refresh,
requestPersistentStorage,
};
}

View File

@@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest';
import { normalizePermissionState, resolveStorageStatus } from './devicePermissions';
describe('normalizePermissionState', () => {
it('maps default to prompt', () => {
expect(normalizePermissionState('default')).toBe('prompt');
});
it('maps undefined to unsupported', () => {
expect(normalizePermissionState(undefined)).toBe('unsupported');
});
it('passes through granted', () => {
expect(normalizePermissionState('granted')).toBe('granted');
});
it('passes through denied', () => {
expect(normalizePermissionState('denied')).toBe('denied');
});
});
describe('resolveStorageStatus', () => {
it('returns unsupported when not supported', () => {
expect(resolveStorageStatus(null, false)).toBe('unsupported');
});
it('returns persisted when granted', () => {
expect(resolveStorageStatus(true, true)).toBe('persisted');
});
it('returns available when supported but not persisted', () => {
expect(resolveStorageStatus(false, true)).toBe('available');
});
});

View File

@@ -0,0 +1,28 @@
export type PermissionStatus = 'granted' | 'denied' | 'prompt' | 'unsupported';
export type StorageStatus = 'persisted' | 'available' | 'unsupported';
type RawPermissionState = PermissionState | 'default' | null | undefined;
export function normalizePermissionState(state: RawPermissionState): PermissionStatus {
if (!state) {
return 'unsupported';
}
if (state === 'default') {
return 'prompt';
}
return state;
}
export function resolveStorageStatus(persisted: boolean | null, supported: boolean): StorageStatus {
if (!supported) {
return 'unsupported';
}
if (persisted) {
return 'persisted';
}
return 'available';
}

View File

@@ -0,0 +1,19 @@
import { describe, expect, it, vi } from 'vitest';
import { triggerHaptic } from './haptics';
describe('triggerHaptic', () => {
it('uses navigator.vibrate when available', () => {
const vibrate = vi.fn();
Object.defineProperty(navigator, 'vibrate', { value: vibrate, configurable: true });
triggerHaptic();
expect(vibrate).toHaveBeenCalled();
});
it('does nothing when vibrate is unavailable', () => {
Object.defineProperty(navigator, 'vibrate', { value: undefined, configurable: true });
expect(() => triggerHaptic('success')).not.toThrow();
});
});

View File

@@ -0,0 +1,19 @@
export type HapticStyle = 'light' | 'medium' | 'heavy' | 'success' | 'warning' | 'error' | 'selection';
const PATTERNS: Record<HapticStyle, number | number[]> = {
light: 10,
medium: 18,
heavy: 26,
success: [10, 28, 10],
warning: [22, 30, 16],
error: [30, 30, 30],
selection: 12,
};
export function triggerHaptic(style: HapticStyle = 'selection'): void {
if (typeof navigator === 'undefined' || typeof navigator.vibrate !== 'function') {
return;
}
navigator.vibrate(PATTERNS[style] ?? PATTERNS.selection);
}

View File

@@ -0,0 +1,49 @@
import { beforeEach, describe, expect, it } from 'vitest';
import {
enqueuePhotoAction,
loadPhotoQueue,
removePhotoAction,
replacePhotoQueue,
type PhotoModerationAction,
} from './photoModerationQueue';
describe('photoModerationQueue', () => {
beforeEach(() => {
window.localStorage.clear();
});
it('enqueues and loads actions', () => {
const queue = enqueuePhotoAction({ eventSlug: 'demo-event', photoId: 12, action: 'approve' });
expect(queue).toHaveLength(1);
const loaded = loadPhotoQueue();
expect(loaded).toHaveLength(1);
expect(loaded[0]?.eventSlug).toBe('demo-event');
expect(loaded[0]?.photoId).toBe(12);
});
it('removes actions by id', () => {
const queue = enqueuePhotoAction({ eventSlug: 'demo-event', photoId: 12, action: 'approve' });
const next = removePhotoAction(queue, queue[0]!.id);
expect(next).toHaveLength(0);
expect(loadPhotoQueue()).toHaveLength(0);
});
it('replaces the queue', () => {
enqueuePhotoAction({ eventSlug: 'demo-event', photoId: 12, action: 'approve' });
const next: PhotoModerationAction[] = [
{
id: 'fixed',
eventSlug: 'another',
photoId: 99,
action: 'hide',
createdAt: new Date().toISOString(),
},
];
replacePhotoQueue(next);
expect(loadPhotoQueue()).toHaveLength(1);
expect(loadPhotoQueue()[0]?.id).toBe('fixed');
});
});

View File

@@ -0,0 +1,69 @@
export type PhotoModerationAction = {
id: string;
eventSlug: string;
photoId: number;
action: 'approve' | 'hide' | 'show' | 'feature' | 'unfeature';
createdAt: string;
};
const STORAGE_KEY = 'fotospiel-admin-photo-queue';
function buildId(): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
return `${Date.now()}-${Math.random().toString(16).slice(2, 10)}`;
}
export function loadPhotoQueue(): PhotoModerationAction[] {
if (typeof window === 'undefined') {
return [];
}
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) {
return [];
}
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? (parsed as PhotoModerationAction[]) : [];
} catch {
return [];
}
}
export function savePhotoQueue(queue: PhotoModerationAction[]): void {
if (typeof window === 'undefined') {
return;
}
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(queue));
} catch {
// Ignore persistence failures.
}
}
export function enqueuePhotoAction(action: Omit<PhotoModerationAction, 'id' | 'createdAt'>): PhotoModerationAction[] {
const queue = loadPhotoQueue();
const entry: PhotoModerationAction = {
...action,
id: buildId(),
createdAt: new Date().toISOString(),
};
const next = [...queue, entry];
savePhotoQueue(next);
return next;
}
export function removePhotoAction(queue: PhotoModerationAction[], id: string): PhotoModerationAction[] {
const next = queue.filter((item) => item.id !== id);
savePhotoQueue(next);
return next;
}
export function replacePhotoQueue(queue: PhotoModerationAction[]): PhotoModerationAction[] {
savePhotoQueue(queue);
return queue;
}

View File

@@ -132,12 +132,14 @@ export const router = createBrowserRouter([
{ path: 'mobile/events/:slug/qr', element: <RequireAdminAccess><MobileQrPrintPage /></RequireAdminAccess> },
{ path: 'mobile/events/:slug/qr/customize/:tokenId?', element: <RequireAdminAccess><MobileQrLayoutCustomizePage /></RequireAdminAccess> },
{ path: 'mobile/events/:slug/photos', element: <MobileEventPhotosPage /> },
{ path: 'mobile/events/:slug/photos/:photoId', element: <MobileEventPhotosPage /> },
{ path: 'mobile/events/:slug/recap', element: <RequireAdminAccess><MobileEventRecapPage /></RequireAdminAccess> },
{ path: 'mobile/events/:slug/members', element: <RequireAdminAccess><MobileEventMembersPage /></RequireAdminAccess> },
{ path: 'mobile/events/:slug/tasks', element: <MobileEventTasksPage /> },
{ path: 'mobile/events/:slug/photobooth', element: <RequireAdminAccess><MobileEventPhotoboothPage /></RequireAdminAccess> },
{ path: 'mobile/events/:slug/guest-notifications', element: <RequireAdminAccess><MobileEventGuestNotificationsPage /></RequireAdminAccess> },
{ path: 'mobile/notifications', element: <MobileNotificationsPage /> },
{ path: 'mobile/notifications/:notificationId', element: <MobileNotificationsPage /> },
{ path: 'mobile/profile', element: <RequireAdminAccess><MobileProfilePage /></RequireAdminAccess> },
{ path: 'mobile/billing', element: <RequireAdminAccess><MobileBillingPage /></RequireAdminAccess> },
{ path: 'mobile/settings', element: <RequireAdminAccess><MobileSettingsPage /></RequireAdminAccess> },

12
resources/js/admin/types/global.d.ts vendored Normal file
View File

@@ -0,0 +1,12 @@
export {};
declare global {
interface Window {
__ADMIN_RUNTIME_CONFIG__?: {
push?: {
enabled?: boolean;
vapidPublicKey?: string | null;
};
};
}
}