commit b58588cf3c8f48a53c2ef447cef9ac7bf125a73e Author: RaynisDev Date: Wed May 6 07:13:43 2026 +0200 initial commit: rebreak-monorepo (RN app + standalone Nitro backend) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a550dc2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,29 @@ +node_modules +.output +.output-staging +.nitro +.nuxt +.pnpm-store + +# RN / Expo +apps/rebreak-native/ios/Pods +apps/rebreak-native/ios/build +apps/rebreak-native/ios/DerivedData +apps/rebreak-native/android/build +apps/rebreak-native/android/app/build +apps/rebreak-native/android/.gradle +apps/rebreak-native/.expo + +# Build artefacts +*.log +*.tsbuildinfo +backend/server/generated + +# OS +.DS_Store +Thumbs.db + +# Local env +.env +.env.local +*.local diff --git a/apps/rebreak-native/.gitignore b/apps/rebreak-native/.gitignore new file mode 100644 index 0000000..1cc3d9d --- /dev/null +++ b/apps/rebreak-native/.gitignore @@ -0,0 +1,37 @@ +# dependencies +node_modules/ + +# Expo +.expo/ +dist/ +web-build/ +expo-env.d.ts + +# Native +ios/ +android/ +*.jks +*.p12 +*.key +*.mobileprovision + +# Metro +.metro-health-check* + +# debug +npm-debug.* +yarn-debug.* +yarn-error.* + +# macOS +.DS_Store +*.pem + +# local env files +.env*.local + +# typescript +*.tsbuildinfo + +# Storybook +storybook-static/ diff --git a/apps/rebreak-native/.npmrc b/apps/rebreak-native/.npmrc new file mode 100644 index 0000000..8e5a554 --- /dev/null +++ b/apps/rebreak-native/.npmrc @@ -0,0 +1,2 @@ +node-linker=hoisted +shamefully-hoist=true diff --git a/apps/rebreak-native/README.md b/apps/rebreak-native/README.md new file mode 100644 index 0000000..4d5c2da --- /dev/null +++ b/apps/rebreak-native/README.md @@ -0,0 +1,112 @@ +# Rebreak Native (React Native + Expo) + +> Mobile App für iOS + Android. Migration von Nuxt+Capacitor → React Native+Expo. +> Migration-Plan: [`apps/rebreak/docs/react-native-migration.md`](../rebreak/docs/react-native-migration.md) + +## Status + +**Phase 1 — Foundation Skeleton** (2026-05-02) + +- [x] Verzeichnisstruktur +- [x] `package.json` mit Expo SDK 53 + RN 0.76 (New Architecture) +- [x] `app.config.ts` (Expo, Bundle-ID `org.rebreak.app`) +- [x] Expo Router Skeleton +- [x] NativeWind Setup (Tailwind für RN) +- [x] Metro Config (pnpm Monorepo aware) +- [x] Supabase Client + API Wrapper +- [ ] `pnpm install` — vom User auszuführen +- [ ] `expo prebuild` — generiert `ios/` + `android/` Bare-Projekte +- [ ] Native Module Imports (NEFilter, VpnService, A11y) — Phase 5+ + +## Setup + +```bash +# vom Monorepo-Root: +pnpm install + +# Native-Projekte generieren (lokal, nicht committed) +cd apps/rebreak-native +pnpm prebuild + +# Run +pnpm ios # iOS Simulator +pnpm android # Android Emulator +``` + +## Stack + +| Bereich | Lib | +|---|---| +| Framework | React Native 0.76 (New Architecture) + Expo SDK 53 | +| Routing | Expo Router (file-based) | +| State | Zustand | +| Server State | TanStack Query (React Query) | +| Styling | NativeWind 4 (Tailwind) | +| Forms | React Hook Form + Valibot | +| i18n | react-i18next | +| Auth | @supabase/supabase-js + AsyncStorage | +| Animation | react-native-reanimated, lottie-react-native | +| Storage | react-native-mmkv (fast key-value) | + +## Verzeichnisstruktur + +``` +apps/rebreak-native/ +├── app/ # Expo Router (file-based routes) +│ ├── _layout.tsx # Root Stack +│ ├── index.tsx # Landing (auth oder app entscheiden) +│ ├── (auth)/ # Auth-Flow (signin, signup, forgot-password) +│ └── (app)/ # Authenticated App (tabs) +├── components/ # Reusable UI Components +├── hooks/ # Custom React Hooks +├── stores/ # Zustand Stores +├── lib/ # Supabase Client, API Wrapper, Utils +│ ├── supabase.ts +│ └── api.ts +├── locales/ # de.json, en.json +├── modules/ # Custom Expo Native Modules +│ ├── rebreak-ios-filter/ # NEFilter + Tunnel (Phase 5) +│ ├── rebreak-android-blocker/ # VpnService + DnsFilter (Phase 6) +│ └── rebreak-android-a11y/ # AccessibilityService (Phase 6) +├── plugins/ # Expo Config Plugins +└── assets/ # Icons, Splashscreens, Fonts +``` + +## Wichtige Konfiguration + +| Datei | Zweck | +|---|---| +| `app.config.ts` | Expo App-Config (Bundle-ID, Permissions, Plugins) | +| `metro.config.js` | Monorepo-aware Metro (Symlinks, Workspace-Folders) | +| `babel.config.js` | NativeWind Preset + Reanimated Plugin | +| `tailwind.config.js` | Tailwind Config — Brand-Colors aus apps/rebreak/ syncen | +| `global.css` | NativeWind Tailwind-Imports | + +## Native Module Strategie + +Bestehender Native-Code aus `apps/rebreak/ios/` + `apps/rebreak/android/` wird in Expo Native Modules gewrappt — **ohne neu zu schreiben**. + +### iOS — `modules/rebreak-ios-filter/` +Wrapped: +- `RebreakURLFilter` (NEFilterDataProvider) → blockt bet365 etc. +- `RebreakTunnel` (NEPacketTunnelProvider) → DNS-Filter + +### Android — `modules/rebreak-android-blocker/` +Wrapped: +- `RebreakVpnService` (Kotlin) → VpnService DNS-Filter +- `DnsFilter` + `HashList` + `DomainHasher` + +### Android — `modules/rebreak-android-a11y/` +Wrapped: +- `RebreakAccessibilityService` → Detection von Gambling-Apps + +## ⚠️ Wichtige Hinweise + +- **Schutz-Stack bleibt 1:1 erhalten** — Swift- und Kotlin-Code wandert unverändert in `modules/`. +- **Backend bleibt** in `apps/rebreak/server/` — RN ruft die gleichen Endpoints. +- **Alte Capacitor-App** bleibt deployed bis RN-App im Store ist. +- **Kein Auto-Commit** — User entscheidet wann committet wird. + +## Phasen-Tracker + +Siehe Migration-Plan für Details: [`apps/rebreak/docs/react-native-migration.md`](../rebreak/docs/react-native-migration.md) diff --git a/apps/rebreak-native/app.config.ts b/apps/rebreak-native/app.config.ts new file mode 100644 index 0000000..ee68b2c --- /dev/null +++ b/apps/rebreak-native/app.config.ts @@ -0,0 +1,105 @@ +import { ExpoConfig, ConfigContext } from "expo/config"; + +export default ({ config }: ConfigContext): ExpoConfig => ({ + ...config, + name: "ReBreak", + slug: "rebreak", + version: "0.1.0", + orientation: "portrait", + icon: "./assets/icon.png", + scheme: "rebreak", + userInterfaceStyle: "automatic", + newArchEnabled: true, + + splash: { + image: "./assets/splash.png", + resizeMode: "contain", + backgroundColor: "#0f172a", + }, + + ios: { + supportsTablet: true, + bundleIdentifier: "org.rebreak.app", + config: { + usesNonExemptEncryption: false, + }, + infoPlist: { + ITSAppUsesNonExemptEncryption: false, + NSMicrophoneUsageDescription: + "Rebreak nutzt das Mikrofon für Sprachnachrichten an Lyra.", + NSPhotoLibraryUsageDescription: + "Rebreak greift auf Fotos zu, damit du sie in deinen Posts teilen kannst.", + NSPhotoLibraryAddUsageDescription: + "Rebreak speichert Bilder in deine Foto-Mediathek.", + }, + }, + + android: { + package: "org.rebreak.app", + adaptiveIcon: { + // Chain-only foreground (ohne "ReBreak"-Schrift). Auf Android schneidet die + // Adaptive-Icon-Mask (Kreis/Squircle/etc.) den Untertitel sonst ab. + foregroundImage: "./assets/adaptive-icon-android.png", + backgroundColor: "#0a0a0a", + }, + permissions: [ + "INTERNET", + "ACCESS_NETWORK_STATE", + "BIND_VPN_SERVICE", + "FOREGROUND_SERVICE", + "POST_NOTIFICATIONS", + "BIND_ACCESSIBILITY_SERVICE", + "RECORD_AUDIO", + ], + }, + + plugins: [ + "expo-router", + "expo-localization", + [ + "expo-build-properties", + { + ios: { + deploymentTarget: "15.1", + useFrameworks: "static", + }, + android: { + minSdkVersion: 26, + compileSdkVersion: 35, + targetSdkVersion: 35, + }, + }, + ], + // Xcode 16 + RN 0.79 fmt consteval workaround + "./plugins/with-fmt-consteval-fix", + // Phase 5: NEFilter Extension + Family Controls Entitlements (iOS) + "./plugins/with-rebreak-protection-ios", + // Phase 5: VpnService + AccessibilityService (Android) + "./plugins/with-rebreak-protection-android", + // Rive-Asset (lyra-avatar.riv) als Android raw-resource bundlen + "./plugins/with-rive-asset-android", + ], + + experiments: { + typedRoutes: true, + }, + + extra: { + apiUrl: + process.env.EXPO_PUBLIC_API_URL || + process.env.API_URL || + "https://staging.rebreak.org", + // TEMP: Staging Anon-Key + URL hardcoded für lokales Dev-Testing. + // Anon-Key ist designed für Client-Ship (RLS protectiert DB). Trotzdem: + // BFF-Migration kommt in Phase 5 — dann fliegen diese 2 Zeilen wieder raus. + supabaseUrl: + process.env.EXPO_PUBLIC_SUPABASE_URL || + process.env.SUPABASE_URL || + "https://db-staging.rebreak.org", + supabaseAnonKey: + process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY || + process.env.SUPABASE_KEY || + process.env.SUPABASE_ANON_KEY || + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsImF1ZCI6ImF1dGhlbnRpY2F0ZWQiLCJyb2xlIjoiYW5vbiIsImV4cCI6MjA5MTAxODk1NSwiaWF0IjoxNzc1NjU4OTU1fQ.93d2r3pft2E-alf1JezqueD0l0n1dim7dGvhBN0l1Cs", + }, +}); diff --git a/apps/rebreak-native/app/(app)/_layout.tsx b/apps/rebreak-native/app/(app)/_layout.tsx new file mode 100644 index 0000000..9f37f27 --- /dev/null +++ b/apps/rebreak-native/app/(app)/_layout.tsx @@ -0,0 +1,217 @@ +import { useEffect, useRef, useState } from 'react'; +import { View, ActivityIndicator, AppState, Platform } from 'react-native'; +import { useRouter } from 'expo-router'; +import * as Notifications from 'expo-notifications'; +import { useTranslation } from 'react-i18next'; +import { useAuthStore } from '../../stores/auth'; +import { useNotificationStore } from '../../stores/notifications'; +import { colors } from '../../lib/theme'; +import { NativeTabs } from '../../components/NativeTabs'; +import { protection } from '../../lib/protection'; +import { preloadTabIcons, getTabIcon } from '../../lib/tabIcons'; + +export default function AppLayout() { + const router = useRouter(); + const { t } = useTranslation(); + const { session, loading } = useAuthStore(); + const loadNotifications = useNotificationStore((s) => s.load); + const startRealtime = useNotificationStore((s) => s.startRealtime); + const stopRealtime = useNotificationStore((s) => s.stopRealtime); + const resetNotifications = useNotificationStore((s) => s.reset); + const rearmInFlightRef = useRef(false); + const bypassNotifiedRef = useRef(false); + + // Android-Tab-Icons müssen async aus Ionicons-Font generiert werden (kein + // SF-Symbol-Support). preloadTabIcons() läuft schon beim Modul-Import — hier + // nur den ready-State tracken damit wir re-rendern wenn der Cache fertig ist. + const [tabIconsReady, setTabIconsReady] = useState(Platform.OS !== 'android'); + useEffect(() => { + if (Platform.OS === 'android' && !tabIconsReady) { + preloadTabIcons().then(() => setTabIconsReady(true)); + } + }, [tabIconsReady]); + + useEffect(() => { + if (!loading && !session) { + router.replace('/signin'); + } + }, [session, loading]); + + useEffect(() => { + if (!session) { + resetNotifications(); + return; + } + loadNotifications(); + startRealtime(); + return () => { + stopRealtime(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [session?.user?.id]); + + useEffect(() => { + if (!session || Platform.OS !== 'ios') return; + + let cancelled = false; + let pollTimer: ReturnType | null = null; + + async function notifyBypassDetected(): Promise { + const perms = await Notifications.getPermissionsAsync(); + let granted = perms.granted || perms.ios?.status === Notifications.IosAuthorizationStatus.PROVISIONAL; + if (!granted) { + const req = await Notifications.requestPermissionsAsync(); + granted = req.granted || req.ios?.status === Notifications.IosAuthorizationStatus.PROVISIONAL; + } + if (!granted) return false; + + await Notifications.scheduleNotificationAsync({ + content: { + title: 'ReBreak Schutz manipuliert', + body: 'Tippe hier, um den Schutz sofort wieder zu aktivieren.', + sound: 'default', + data: { type: 'protection_bypass_detected' }, + }, + trigger: null, + }); + return true; + } + + async function enforceProtection() { + if (cancelled || rearmInFlightRef.current) return; + try { + const state = await protection.getCombinedState(); + if (cancelled) return; + if (state.phase !== 'recoveringFromBypass') { + bypassNotifiedRef.current = false; + return; + } + if (bypassNotifiedRef.current) return; + + bypassNotifiedRef.current = true; + const notified = await notifyBypassDetected(); + if (!notified) { + // Fallback wenn Notifications nicht erlaubt sind. + rearmInFlightRef.current = true; + router.replace('/blocker'); + await protection.activateFamilyControls().catch(() => ({ enabled: false })); + } + } finally { + rearmInFlightRef.current = false; + } + } + + async function onBypassNotificationTap() { + if (rearmInFlightRef.current) return; + rearmInFlightRef.current = true; + try { + router.replace('/blocker'); + await protection.activateFamilyControls().catch(() => ({ enabled: false })); + } finally { + rearmInFlightRef.current = false; + } + } + + // Initial check + foreground re-check + periodisches Polling als Fallback. + enforceProtection(); + const notifTapSub = Notifications.addNotificationResponseReceivedListener((response) => { + const type = response.notification.request.content.data?.type; + if (type === 'protection_bypass_detected') { + void onBypassNotificationTap(); + } + }); + Notifications.getLastNotificationResponseAsync().then((response) => { + const type = response?.notification.request.content.data?.type; + if (type === 'protection_bypass_detected') { + void onBypassNotificationTap(); + } + }); + const appStateSub = AppState.addEventListener('change', (s) => { + if (s === 'active') { + enforceProtection(); + } + }); + pollTimer = setInterval(enforceProtection, 15000); + + return () => { + cancelled = true; + notifTapSub.remove(); + appStateSub.remove(); + if (pollTimer) clearInterval(pollTimer); + }; + }, [session, router]); + + if (loading || !session) { + return ( + + + + ); + } + + return ( + + + Platform.OS === 'ios' + ? { sfSymbol: 'house.fill' } + : (getTabIcon('home') as any), + }} + /> + + Platform.OS === 'ios' + ? { sfSymbol: 'bubble.left.and.bubble.right.fill' } + : (getTabIcon('chat') as any), + }} + /> + + Platform.OS === 'ios' + ? { sfSymbol: 'sparkles' } + : (getTabIcon('coach') as any), + }} + /> + + Platform.OS === 'ios' + ? { sfSymbol: 'checkmark.shield.fill' } + : (getTabIcon('blocker') as any), + }} + /> + + Platform.OS === 'ios' + ? { sfSymbol: 'envelope.fill' } + : (getTabIcon('mail') as any), + }} + /> + + + ); +} diff --git a/apps/rebreak-native/app/(app)/blocker.tsx b/apps/rebreak-native/app/(app)/blocker.tsx new file mode 100644 index 0000000..7d267a4 --- /dev/null +++ b/apps/rebreak-native/app/(app)/blocker.tsx @@ -0,0 +1,310 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { ScrollView, View, Alert, ActivityIndicator } from 'react-native'; +import { useRouter } from 'expo-router'; +import { useBottomTabBarHeight } from 'react-native-bottom-tabs'; +import { useTranslation } from 'react-i18next'; +import { AppHeader } from '../../components/AppHeader'; +import { LayerSwitchCard } from '../../components/blocker/LayerSwitchCard'; +import { ProtectionLockedCard } from '../../components/blocker/ProtectionLockedCard'; +import { CooldownBanner } from '../../components/blocker/CooldownBanner'; +import { DomainGrid } from '../../components/blocker/DomainGrid'; +import { AddDomainSheet } from '../../components/blocker/AddDomainSheet'; +import { ProtectionDetailsSheet } from '../../components/blocker/ProtectionDetailsSheet'; +import { DeactivationExplainerSheet } from '../../components/blocker/DeactivationExplainerSheet'; +import { useProtectionState } from '../../hooks/useProtectionState'; +import { useCustomDomains } from '../../hooks/useCustomDomains'; +import { useBlocklistSync } from '../../hooks/useBlocklistSync'; +import { useDomainSubmissionRealtime } from '../../hooks/useDomainSubmissionRealtime'; +import { protection } from '../../lib/protection'; + +export default function BlockerScreen() { + const router = useRouter(); + const { t } = useTranslation(); + // react-native-bottom-tabs Tab-Bar ist iOS-nativ + translucent → unsere Content-View + // erstreckt sich UNTER den Tab-Bar. Ohne diese Höhe würden FAB + Bottom-Padding + // hinterm Tab-Bar verschwinden. + const tabBarHeight = useBottomTabBarHeight(); + const { + state, + loading, + cooldownRemainingFormatted, + refresh, + activateUrlFilter, + activateFamilyControls, + requestDeactivation, + cancelDeactivation, + } = useProtectionState(); + + const plan = state?.plan ?? 'free'; + const { + domains, + tier, + addDomain, + submitDomain, + refresh: refreshDomains, + } = useCustomDomains(plan); + const { sync: syncBlocklist } = useBlocklistSync(); + + // Realtime: Domain-Submission-Status (approved/rejected/in_review) live patchen. + // Bei domain_rejected wird die Row backend-seitig hard-deleted → refetch + // entfernt sie aus der Liste. Zusätzlich blocklist.bin neu syncen damit + // die lokale Hash-Liste nicht aus dem Tritt gerät. + const onDomainChange = useCallback(async () => { + await refreshDomains(); + if (urlFilterActiveRef.current) { + const sync = await syncBlocklist(); + console.log('[blocker] resync after domain change:', sync); + await refresh(); + } + }, [refreshDomains, syncBlocklist, refresh]); + useDomainSubmissionRealtime(onDomainChange, true); + + // Sheet-States + const [addSheetOpen, setAddSheetOpen] = useState(false); + const [detailsOpen, setDetailsOpen] = useState(false); + const [explainerOpen, setExplainerOpen] = useState(false); + + // Layer-Status (auf iOS): urlFilter + familyControls. + // AppDeletionLock=true bedeutet "locked in" → keine Switches mehr, nur Cooldown-Pfad. + const urlFilterActive = state?.layers.urlFilter === true; + const familyControlsActive = state?.layers.familyControls === true; + const appDeletionLockActive = (state?.layers.appDeletionLock ?? familyControlsActive) === true; + const lockedIn = appDeletionLockActive; + + // Ref damit onDomainChange nicht neu rendert bei jedem urlFilter-Toggle + const urlFilterActiveRef = useRef(urlFilterActive); + useEffect(() => { urlFilterActiveRef.current = urlFilterActive; }, [urlFilterActive]); + + // Auto-Sync wenn URL-Filter beim Page-Mount/-Resume schon aktiv ist und + // blocklist.bin leer/stale sein könnte. Dedupe via Ref damit wir nicht + // bei jedem Re-Render neu syncen. + const syncedOnceRef = useRef(false); + useEffect(() => { + if (!urlFilterActive) return; + if (syncedOnceRef.current) return; + syncedOnceRef.current = true; + syncBlocklist().then((res) => { + console.log('[blocker] auto-sync on mount:', res); + if (res.ok) refresh(); // Stats-Card neu rendern mit aktuellem Count + }); + }, [urlFilterActive, syncBlocklist, refresh]); + + // ─── Activate-Handler pro Layer ────────────────────────────────────── + + async function handleActivateUrlFilter() { + try { + const result = await activateUrlFilter(); + console.log('[blocker] activateUrlFilter:', result); + if (!result.enabled) { + Alert.alert( + t('blocker.activate_url_failed_title'), + result.error ?? t('blocker.activate_url_failed_msg'), + [ + { text: t('common.ok') }, + { text: t('blocker.activate_settings_btn'), onPress: () => protection.openSystemSettings() }, + ], + ); + } else { + // Filter ist aktiv aber blocklist.bin ist initial leer — sofort syncen! + // Sonst zeigt iOS "Läuft" aber blockt nichts. + const sync = await syncBlocklist(); + console.log('[blocker] post-activate sync:', sync); + if (sync.ok) { + // Stats-Card neu rendern mit dem frisch geschriebenen Count + await refresh(); + } else { + Alert.alert( + t('blocker.sync_list_failed_title'), + sync.error ?? t('blocker.sync_list_failed_msg'), + ); + } + } + return result; + } catch (e: any) { + console.error('[blocker] activateUrlFilter threw:', e); + Alert.alert(t('blocker.activation_failed_title'), e?.message ?? t('common.unknown_error')); + return { enabled: false }; + } + } + + async function handleActivateFamilyControls() { + try { + const result = await activateFamilyControls(); + console.log('[blocker] activateFamilyControls:', result); + if (!result.enabled) { + Alert.alert( + t('blocker.activate_app_lock_failed_title'), + result.error ?? t('blocker.activate_app_lock_failed_msg'), + ); + } + } catch (e: any) { + console.error('[blocker] activateFamilyControls threw:', e); + Alert.alert(t('blocker.activation_failed_title'), e?.message ?? t('common.unknown_error')); + } + return { enabled: false }; + } + + // ─── 3-Click Cooldown-Trigger ──────────────────────────────────────── + + function openDetails() { + setDetailsOpen(true); + } + + function fromDetailsToExplainer() { + setDetailsOpen(false); + setTimeout(() => setExplainerOpen(true), 250); + } + + function deflectToLyra() { + setDetailsOpen(false); + setTimeout(() => router.push('/lyra' as any), 250); + } + + function deflectToBreathe() { + setExplainerOpen(false); + setTimeout(() => router.push('/urge' as any), 250); + } + + async function handleStartCooldown(reason: string) { + await requestDeactivation(reason); + } + + async function handleCancelCooldown() { + try { + await cancelDeactivation(); + } catch (e: any) { + Alert.alert(t('common.error'), e?.message ?? t('blocker.deactivation_cancel_failed')); + } + } + + const bypassAlertShownRef = useRef(false); + useEffect(() => { + if (state?.phase !== 'recoveringFromBypass') { + bypassAlertShownRef.current = false; + return; + } + if (bypassAlertShownRef.current) return; + bypassAlertShownRef.current = true; + Alert.alert( + t('blocker.activate_app_lock_failed_title'), + t('blocker.layers_app_lock_warning'), + [{ + text: t('common.ok'), + onPress: () => { + void handleActivateFamilyControls(); + }, + }], + { cancelable: false }, + ); + }, [state?.phase, t]); + + // ─── Render ────────────────────────────────────────────────────────── + + return ( + + + + {loading && !state ? ( + + + + ) : state ? ( + <> + + {/* Locked-In Mode (FC aktiv) → NUR Schutz-Status + Cooldown-Pfad */} + {lockedIn ? ( + + ) : ( + // FC nicht aktiv → User kann pro Layer einzeln togglen + + + + + )} + + {/* CooldownBanner — nur wenn Cooldown läuft */} + {state.cooldown.active && ( + + )} + + {/* Domain Grid mit inline + Button neben SlotPill */} + + setAddSheetOpen(true)} + onSubmit={submitDomain} + onUpgradePro={() => Alert.alert(t('blocker.upgrade_alert_title'), t('blocker.upgrade_alert_desc'))} + /> + + + + {/* Sheets */} + { + setAddSheetOpen(false); + refreshDomains(); + }} + onAdd={async (d) => { + const result = await addDomain(d); + if (result.ok) { + // Neue Custom-Domain → Filter muss aktualisierten Hash-Set kriegen + const sync = await syncBlocklist(); + if (sync.ok) refresh(); // Stats-Card mit neuem Count refreshen + } + return result; + }} + /> + + setDetailsOpen(false)} + onRequestDeactivation={fromDetailsToExplainer} + onTalkToLyra={deflectToLyra} + /> + + setExplainerOpen(false)} + onBreathe={deflectToBreathe} + onStartCooldown={handleStartCooldown} + /> + + ) : null} + + ); +} diff --git a/apps/rebreak-native/app/(app)/chat.tsx b/apps/rebreak-native/app/(app)/chat.tsx new file mode 100644 index 0000000..2d713c0 --- /dev/null +++ b/apps/rebreak-native/app/(app)/chat.tsx @@ -0,0 +1,410 @@ +import { useState, useCallback } from 'react'; +import { + View, + Text, + FlatList, + Pressable, + ActivityIndicator, + Image, + RefreshControl, + StyleSheet, +} from 'react-native'; +import { useRouter } from 'expo-router'; +import { Ionicons } from '@expo/vector-icons'; +import { useQuery } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; +import { apiFetch } from '../../lib/api'; +import { AppHeader } from '../../components/AppHeader'; +import { RoomCard, type Room } from '../../components/chat/RoomCard'; +import { CreateRoomSheet } from '../../components/chat/CreateRoomSheet'; +import { colors } from '../../lib/theme'; + +type DmConversation = { + partnerId: string; + partnerName: string; + partnerAvatar: string | null; + lastMessage: string; + lastMessageAt: string; + unreadCount: number; + isOwn: boolean; +}; + +function formatTime(ts: string, justNowLabel: string): string { + const diff = Date.now() - new Date(ts).getTime(); + if (diff < 60_000) return justNowLabel; + if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m`; + if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h`; + return new Date(ts).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' }); +} + +function DmItem({ conv, onPress }: { conv: DmConversation; onPress: () => void }) { + const { t } = useTranslation(); + const hasUnread = conv.unreadCount > 0; + + return ( + + {({ pressed }) => ( + + + {conv.partnerAvatar ? ( + + ) : ( + + {conv.partnerName.slice(0, 2).toUpperCase()} + + )} + + + + + {conv.partnerName} + + + {formatTime(conv.lastMessageAt, t('chat.just_now'))} + + + + + {conv.isOwn ? t('chat.you') : ''} + {conv.lastMessage} + + {hasUnread && ( + + {conv.unreadCount} + + )} + + + + )} + + ); +} + +export default function ChatScreen() { + const { t } = useTranslation(); + const router = useRouter(); + const [tab, setTab] = useState<'groups' | 'direct'>('groups'); + const [createOpen, setCreateOpen] = useState(false); + + const { + data: rooms = [], + isLoading: loadingRooms, + isRefetching: refetchingRooms, + refetch: refetchRooms, + } = useQuery({ + queryKey: ['chat-rooms'], + queryFn: () => apiFetch('/api/chat/rooms'), + staleTime: 30_000, + }); + + const { + data: convs = [], + isLoading: loadingDms, + isRefetching: refetchingDms, + refetch: refetchDms, + } = useQuery({ + queryKey: ['dm-conversations'], + queryFn: () => apiFetch('/api/chat/dm-conversations'), + staleTime: 30_000, + enabled: tab === 'direct', + }); + + const unreadDms = convs.reduce((s, c) => s + (c.unreadCount ?? 0), 0); + + const openRoom = useCallback( + (roomId: string) => { + router.push(`/room?roomId=${roomId}`); + }, + [router], + ); + + const openDm = useCallback( + (userId: string) => { + router.push(`/dm?userId=${userId}`); + }, + [router], + ); + + return ( + + + + {/* Header */} + + + {t('chat.title')} + {tab === 'groups' && ( + setCreateOpen(true)} + style={({ pressed }) => [styles.createBtn, { opacity: pressed ? 0.7 : 1 }]} + > + + + )} + + + {/* Tabs */} + + setTab('groups')} + style={[styles.tab, tab === 'groups' && styles.tabActive]} + > + + + {t('chat.groups')} + + + setTab('direct')} + style={[styles.tab, tab === 'direct' && styles.tabActive]} + > + + + {t('chat.direct')} + + {unreadDms > 0 && ( + + {unreadDms} + + )} + + + + + {tab === 'groups' ? ( + item.id} + refreshControl={ + + } + ListEmptyComponent={ + loadingRooms ? ( + + + + ) : ( + + + {t('chat.no_rooms')} + + ) + } + renderItem={({ item }) => openRoom(item.id)} />} + contentContainerStyle={{ paddingBottom: 100 }} + /> + ) : ( + item.partnerId} + refreshControl={ + + } + ListEmptyComponent={ + loadingDms ? ( + + + + ) : ( + + + {t('chat.no_chats')} + + ) + } + renderItem={({ item }) => openDm(item.partnerId)} />} + contentContainerStyle={{ paddingBottom: 100 }} + /> + )} + + setCreateOpen(false)} + onCreated={(room) => { + refetchRooms(); + openRoom(room.id); + }} + /> + + ); +} + +const styles = StyleSheet.create({ + container: { flex: 1, backgroundColor: '#fafafa' }, + headerSection: { + paddingHorizontal: 16, + paddingTop: 14, + paddingBottom: 10, + backgroundColor: '#fff', + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: '#e5e5e5', + }, + titleRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + title: { + fontSize: 22, + fontFamily: 'Nunito_800ExtraBold', + color: '#171717', + }, + createBtn: { + width: 34, + height: 34, + borderRadius: 17, + backgroundColor: '#007AFF', + alignItems: 'center', + justifyContent: 'center', + }, + tabs: { + flexDirection: 'row', + marginTop: 12, + backgroundColor: '#f5f5f5', + borderRadius: 10, + padding: 3, + }, + tab: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + paddingVertical: 7, + borderRadius: 8, + }, + tabActive: { + backgroundColor: '#fff', + shadowColor: '#000', + shadowOpacity: 0.05, + shadowRadius: 2, + shadowOffset: { width: 0, height: 1 }, + }, + tabText: { + fontSize: 12, + fontFamily: 'Nunito_600SemiBold', + color: '#737373', + marginLeft: 5, + }, + tabTextActive: { + color: '#007AFF', + fontFamily: 'Nunito_700Bold', + }, + tabBadge: { + minWidth: 16, + height: 16, + borderRadius: 8, + backgroundColor: '#007AFF', + paddingHorizontal: 4, + alignItems: 'center', + justifyContent: 'center', + marginLeft: 5, + }, + tabBadgeText: { + fontSize: 9, + fontFamily: 'Nunito_700Bold', + color: '#fff', + }, + emptyBox: { + alignItems: 'center', + justifyContent: 'center', + paddingVertical: 60, + paddingHorizontal: 32, + }, + emptyText: { + fontSize: 13, + fontFamily: 'Nunito_600SemiBold', + color: '#a3a3a3', + marginTop: 12, + }, + // DM row styles + dmRow: { + width: '100%', + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 14, + paddingVertical: 11, + backgroundColor: '#fff', + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: '#f5f5f5', + }, + dmAvatar: { + width: 42, + height: 42, + borderRadius: 21, + backgroundColor: '#e5e5e5', + alignItems: 'center', + justifyContent: 'center', + overflow: 'hidden', + marginRight: 10, + }, + dmAvatarImg: { width: 42, height: 42 }, + dmAvatarInitials: { + fontSize: 13, + fontFamily: 'Nunito_700Bold', + color: '#525252', + }, + dmInfo: { flex: 1, minWidth: 0 }, + dmHeaderRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + dmName: { + fontSize: 14, + fontFamily: 'Nunito_700Bold', + color: '#171717', + flexShrink: 1, + marginRight: 6, + }, + dmTime: { fontSize: 11, fontFamily: 'Nunito_600SemiBold' }, + dmBottomRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + marginTop: 2, + }, + dmLast: { fontSize: 12, flex: 1 }, + unreadBadge: { + minWidth: 20, + height: 20, + paddingHorizontal: 6, + borderRadius: 10, + backgroundColor: '#007AFF', + alignItems: 'center', + justifyContent: 'center', + marginLeft: 8, + }, + unreadBadgeText: { + fontSize: 10, + fontFamily: 'Nunito_700Bold', + color: '#fff', + }, +}); diff --git a/apps/rebreak-native/app/(app)/coach.tsx b/apps/rebreak-native/app/(app)/coach.tsx new file mode 100644 index 0000000..d2b9bdb --- /dev/null +++ b/apps/rebreak-native/app/(app)/coach.tsx @@ -0,0 +1,24 @@ +import { useCallback } from 'react'; +import { View } from 'react-native'; +import { useRouter, useFocusEffect } from 'expo-router'; + +/** + * Placeholder-Screen für den Coach-Tab. + * Beim Fokussieren wird sofort auf den Root-Stack `/coach` umgeleitet. + * - Coach-Tab bleibt in der Tab-Bar sichtbar + * - Tap auf Coach-Tab → Root-Coach öffnet sich (über den Tabs) + * - Tab-Bar ist auf der Coach-Page automatisch versteckt + */ +export default function CoachTabRedirect() { + const router = useRouter(); + + useFocusEffect( + useCallback(() => { + // replace statt push: Back von /lyra geht damit zurück zum vorherigen Tab, + // nicht zurück auf diesen Placeholder (würde sonst infinite loop bilden) + router.replace('/lyra'); + }, [router]), + ); + + return ; +} diff --git a/apps/rebreak-native/app/(app)/index.tsx b/apps/rebreak-native/app/(app)/index.tsx new file mode 100644 index 0000000..68ba2cd --- /dev/null +++ b/apps/rebreak-native/app/(app)/index.tsx @@ -0,0 +1,198 @@ +import { useCallback, useState } from 'react'; +import { + View, + Text, + ScrollView, + FlatList, + Pressable, + RefreshControl, + ActivityIndicator, +} from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; +import { apiFetch } from '../../lib/api'; +import { AppHeader } from '../../components/AppHeader'; +import { ComposeCard } from '../../components/ComposeCard'; +import { PostCard } from '../../components/PostCard'; +import { PostCardSkeleton } from '../../components/PostCardSkeleton'; +import { PostCommentsSheet } from '../../components/PostCommentsSheet'; +import { useCommunityStore, type CommunityCategory, type CommunityPost } from '../../stores/community'; +import { useCommunityRealtime } from '../../hooks/useCommunityRealtime'; +import { colors } from '../../lib/theme'; + +type FilterChip = { + value: CommunityCategory; + label: string; + icon: React.ComponentProps['name']; +}; + +export default function HomeScreen() { + const { t } = useTranslation(); + const queryClient = useQueryClient(); + // Granular selectors: subscribing to the whole store (incl. optimisticLikes) + // would re-render the screen — and thus the FlatList — on every like. + const activeCategory = useCommunityStore((s) => s.activeCategory); + const setCategory = useCommunityStore((s) => s.setCategory); + + const FILTERS: FilterChip[] = [ + { value: 'all', label: t('community.cat_all'), icon: 'grid-outline' }, + { value: 'games', label: t('community.cat_games'), icon: 'trophy-outline' }, + { value: 'domain_vote', label: t('community.cat_domain'), icon: 'shield-outline' }, + { value: 'lyra', label: t('community.cat_lyra'), icon: 'sparkles-outline' }, + { value: 'rebreak', label: t('community.cat_rebreak'), icon: 'megaphone-outline' }, + ]; + const [filterOpen, setFilterOpen] = useState(false); + const [activeCommentsPostId, setActiveCommentsPostId] = useState(null); + + const { data: posts = [], isLoading, isRefetching, refetch } = useQuery({ + queryKey: ['community-posts', activeCategory], + queryFn: () => apiFetch(`/api/community/posts?category=${activeCategory}&limit=30`), + staleTime: 60_000, + }); + + // Realtime: live updates für Posts (likes/comments/neue Posts/domain-vote-Status) + useCommunityRealtime(true); + + const toggleFilter = (value: CommunityCategory) => { + const next = activeCategory === value ? 'all' : value; + setCategory(next); + setFilterOpen(false); + }; + + // Stable callbacks — passed to memoized PostCards. Inline arrows would + // bust React.memo on every parent render (which also happens on every + // realtime patch since the posts array gets a new reference). + const openComments = useCallback((postId: string) => { + setActiveCommentsPostId(postId); + }, []); + + const closeComments = useCallback(() => setActiveCommentsPostId(null), []); + + const keyExtractor = useCallback((item: CommunityPost) => item.id, []); + + const renderItem = useCallback( + ({ item }: { item: CommunityPost }) => ( + + ), + [openComments], + ); + + return ( + + + + + } + ListHeaderComponent={ + + refetch()} /> + + {/* Filter toggle */} + + setFilterOpen((o) => !o)} + className="flex-row items-center gap-1.5 self-start" + style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })} + > + + {activeCategory !== 'all' && ( + + {FILTERS.find((f) => f.value === activeCategory)?.label} + + )} + + + {filterOpen && ( + + {FILTERS.map((f) => { + const active = activeCategory === f.value; + return ( + toggleFilter(f.value)} + className={`flex-row items-center gap-1.5 h-8 px-3 rounded-full border ${ + active + ? 'bg-rebreak-500 border-rebreak-500' + : 'bg-white border-neutral-200' + }`} + style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })} + > + + + {f.label} + + + ); + })} + + )} + + + {/* Skeleton */} + {isLoading && ( + + + + + + )} + + } + ListEmptyComponent={ + isLoading ? null : ( + + + + {t('community.no_posts')} + + + ) + } + renderItem={renderItem} + /> + + + + ); +} diff --git a/apps/rebreak-native/app/(app)/mail.tsx b/apps/rebreak-native/app/(app)/mail.tsx new file mode 100644 index 0000000..6b5ba24 --- /dev/null +++ b/apps/rebreak-native/app/(app)/mail.tsx @@ -0,0 +1,235 @@ +import { useState } from 'react'; +import { + ActivityIndicator, + Alert, + Pressable, + ScrollView, + Text, + View, +} from 'react-native'; +import { useBottomTabBarHeight } from 'react-native-bottom-tabs'; +import { useTranslation } from 'react-i18next'; +import { Ionicons } from '@expo/vector-icons'; +import { AppHeader } from '../../components/AppHeader'; +import { MailStatsRow } from '../../components/mail/MailStatsRow'; +import { MailAccountCard } from '../../components/mail/MailAccountCard'; +import { MailEmptyState } from '../../components/mail/MailEmptyState'; +import { MailActivityLog } from '../../components/mail/MailActivityLog'; +import { ConnectMailSheet } from '../../components/mail/ConnectMailSheet'; +import { SuccessAlert } from '../../components/SuccessAlert'; +import { useMailStatus } from '../../hooks/useMailStatus'; +import { useMailDisconnect } from '../../hooks/useMailDisconnect'; +import { useUserPlan } from '../../hooks/useUserPlan'; + +export default function MailScreen() { + const { t } = useTranslation(); + const tabBarHeight = useBottomTabBarHeight(); + + const { plan } = useUserPlan(); + + const { connected, accounts, totalBlocked, maxAccounts, loading, refresh } = + useMailStatus(plan); + const { disconnect, disconnecting } = useMailDisconnect(); + + const [sheetVisible, setSheetVisible] = useState(false); + const [successVisible, setSuccessVisible] = useState(false); + const [disconnectingId, setDisconnectingId] = useState(null); + const [expandedAccount, setExpandedAccount] = useState(null); + const [activityLogExpanded, setActivityLogExpanded] = useState(false); + + const nextScanAt = + accounts + .map((a) => a.nextScanAt) + .filter((v): v is string => v !== null) + .sort()[0] ?? null; + + const limitReached = accounts.length >= maxAccounts; + + function handleAddPress() { + if (limitReached) { + Alert.alert(t('mail.upgrade_alert_title'), t('mail.upgrade_alert_desc')); + return; + } + setSheetVisible(true); + } + + async function handleDisconnect(id: string) { + setDisconnectingId(id); + await disconnect(id); + setDisconnectingId(null); + if (expandedAccount === id) setExpandedAccount(null); + refresh(); + } + + function handleConnectSuccess() { + setSuccessVisible(true); + refresh(); + } + + function toggleAccount(id: string) { + setExpandedAccount((prev) => (prev === id ? null : id)); + } + + if (loading) { + return ( + + + + + + + ); + } + + return ( + + + + + {/* Stats card */} + {accounts.length > 0 && ( + + + + )} + + {/* Section header with prominent + button */} + + + + {t('mail.section_accounts')} + + + {maxAccounts === Infinity + ? t('mail.section_accounts_count_unlimited', { used: accounts.length }) + : t('mail.section_accounts_count', { + used: accounts.length, + max: maxAccounts, + })} + + + + + + + + {t('mail.add_account')} + + + + + + {/* Account cards or empty */} + {accounts.length === 0 ? ( + + ) : ( + + {accounts.map((account, idx) => ( + + toggleAccount(account.id)} + onDisconnect={handleDisconnect} + onIntervalChanged={refresh} + onEditSuccess={handleConnectSuccess} + disconnecting={disconnectingId === account.id && disconnecting} + /> + + ))} + + )} + + {/* Activity log */} + {accounts.length > 0 && ( + + setActivityLogExpanded((p) => !p)} + /> + + )} + + + setSheetVisible(false)} + onSuccess={handleConnectSuccess} + /> + + setSuccessVisible(false)} + /> + + ); +} diff --git a/apps/rebreak-native/app/(app)/notifications.tsx b/apps/rebreak-native/app/(app)/notifications.tsx new file mode 100644 index 0000000..0a6b153 --- /dev/null +++ b/apps/rebreak-native/app/(app)/notifications.tsx @@ -0,0 +1,153 @@ +import { useEffect } from 'react'; +import { View, Text, FlatList, Pressable, RefreshControl } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { useRouter } from 'expo-router'; +import { Ionicons } from '@expo/vector-icons'; +import { HeroShieldCheck } from '../../components/HeroShieldCheck'; +import { useTranslation } from 'react-i18next'; +import { EmptyState } from '../../components/EmptyState'; +import { useNotificationStore, type AppNotification } from '../../stores/notifications'; +import { colors } from '../../lib/theme'; + +export default function NotificationsScreen() { + const router = useRouter(); + const { t } = useTranslation(); + const items = useNotificationStore((s) => s.items); + const loaded = useNotificationStore((s) => s.loaded); + const load = useNotificationStore((s) => s.load); + const markRead = useNotificationStore((s) => s.markRead); + const remove = useNotificationStore((s) => s.remove); + + useEffect(() => { + load(); + const tm = setTimeout(() => { + markRead(); + }, 400); + return () => clearTimeout(tm); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + + + router.back()} + className="w-9 h-9 rounded-full bg-neutral-100 border border-neutral-200 items-center justify-center" + > + + + + {t('notifications.title')} + + + + {items.length === 0 ? ( + + ) : ( + n.id} + contentContainerStyle={{ paddingVertical: 8 }} + refreshControl={ + + } + renderItem={({ item }) => ( + { + if (item.postId) { + router.push(`/?postId=${item.postId}` as never); + } + }} + onDelete={() => remove(item.id)} + /> + )} + /> + )} + + ); +} + +function NotificationRow({ + notif, + onPress, + onDelete, +}: { + notif: AppNotification; + onPress: () => void; + onDelete: () => void; +}) { + const isUnread = !notif.readAt; + return ( + ({ + opacity: pressed ? 0.7 : 1, + backgroundColor: isUnread ? '#fff7ed' : '#fff', + })} + > + + {/* Pure-Icon — KEIN bg-Circle (User-Wunsch: kein extra Rand). */} + + {notif.type === 'domain_accepted' ? ( + + ) : ( + + )} + + + + {notif.actorName} + + {notif.preview && ( + + {notif.preview} + + )} + + + + + + + ); +} + +function iconForType(type: string): React.ComponentProps['name'] { + if (type.includes('like')) return 'heart'; + if (type.includes('comment')) return 'chatbubble'; + if (type.includes('follow')) return 'person-add'; + if (type.includes('domain')) return 'shield-checkmark'; + return 'notifications'; +} diff --git a/apps/rebreak-native/app/(auth)/_layout.tsx b/apps/rebreak-native/app/(auth)/_layout.tsx new file mode 100644 index 0000000..c1ca28e --- /dev/null +++ b/apps/rebreak-native/app/(auth)/_layout.tsx @@ -0,0 +1,12 @@ +import { Stack } from 'expo-router'; + +export default function AuthLayout() { + return ( + + ); +} diff --git a/apps/rebreak-native/app/(auth)/confirm-otp.tsx b/apps/rebreak-native/app/(auth)/confirm-otp.tsx new file mode 100644 index 0000000..604a6ad --- /dev/null +++ b/apps/rebreak-native/app/(auth)/confirm-otp.tsx @@ -0,0 +1,207 @@ +import { useState, useEffect, useRef } from 'react'; +import { + View, + Text, + TextInput, + Pressable, + KeyboardAvoidingView, + Platform, + ActivityIndicator, +} from 'react-native'; +import { useRouter, useLocalSearchParams } from 'expo-router'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { useTranslation } from 'react-i18next'; +import { useAuthStore } from '../../stores/auth'; + +const OTP_LENGTH = 6; + +const OTP_INPUT_STYLE = { + fontSize: 20, + fontFamily: 'Nunito_700Bold', + color: '#0a0a0a', + textAlign: 'center' as const, + width: 48, + height: 56, + borderRadius: 12, +}; + +export default function ConfirmOtpScreen() { + const router = useRouter(); + const { t } = useTranslation(); + const params = useLocalSearchParams<{ email: string }>(); + const email = decodeURIComponent(params.email ?? ''); + + const { verifyOtp, resendConfirmation } = useAuthStore(); + + const [digits, setDigits] = useState(Array(OTP_LENGTH).fill('')); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(false); + const [loading, setLoading] = useState(false); + const [resendCooldown, setResendCooldown] = useState(0); + + const inputs = useRef>([]); + + useEffect(() => { + if (!email) { + router.replace('/signup'); + } + }, [email]); + + useEffect(() => { + if (resendCooldown <= 0) return; + const t = setInterval(() => { + setResendCooldown((c) => { + if (c <= 1) { clearInterval(t); return 0; } + return c - 1; + }); + }, 1000); + return () => clearInterval(t); + }, [resendCooldown]); + + const otp = digits.join(''); + + const handleDigit = (value: string, index: number) => { + if (value.length === OTP_LENGTH && /^\d+$/.test(value)) { + const next = value.split(''); + setDigits(next); + inputs.current[OTP_LENGTH - 1]?.focus(); + return; + } + const digit = value.replace(/\D/g, '').slice(-1); + const next = [...digits]; + next[index] = digit; + setDigits(next); + if (digit && index < OTP_LENGTH - 1) { + inputs.current[index + 1]?.focus(); + } + }; + + const handleKeyPress = (key: string, index: number) => { + if (key === 'Backspace' && !digits[index] && index > 0) { + inputs.current[index - 1]?.focus(); + } + }; + + const verify = async () => { + if (otp.length < OTP_LENGTH || loading || success) return; + setError(null); + setLoading(true); + const res = await verifyOtp(email, otp); + setLoading(false); + if (res.error) { + setError(res.error); + setDigits(Array(OTP_LENGTH).fill('')); + inputs.current[0]?.focus(); + return; + } + setSuccess(true); + router.replace('/(app)'); + }; + + const resend = async () => { + if (resendCooldown > 0) return; + setError(null); + const res = await resendConfirmation(email); + if (res.error) { + setError(res.error); + return; + } + setResendCooldown(60); + }; + + return ( + + + + {/* Header */} + + + + + + {t('auth.confirmEmailTitle')} + + + {t('auth.confirmEmailLine1')}{'\n'} + {email} + {t('auth.confirmEmailLine2') ? `\n${t('auth.confirmEmailLine2')}` : ''} + + + + {/* OTP Input */} + + {digits.map((digit, index) => ( + { inputs.current[index] = ref; }} + style={[ + OTP_INPUT_STYLE, + { + backgroundColor: '#f5f5f5', + borderWidth: 2, + borderColor: digit ? '#f59e0b' : '#e5e5e5', + }, + ]} + value={digit} + onChangeText={(val) => handleDigit(val, index)} + onKeyPress={({ nativeEvent }) => handleKeyPress(nativeEvent.key, index)} + keyboardType="number-pad" + maxLength={OTP_LENGTH} + editable={!loading && !success} + selectTextOnFocus + /> + ))} + + + {error && ( + + {error} + + )} + + {success && ( + + {t('auth.confirmed')} + + )} + + + {loading ? ( + + ) : ( + {t('auth.confirmBtn')} + )} + + + 0 || loading} + className="py-3 items-center" + > + + {t('auth.noCode')}{' '} + 0 ? 'text-neutral-400' : 'text-rebreak-500'} style={{ fontFamily: 'Nunito_600SemiBold' }}> + {resendCooldown > 0 ? t('auth.resendCooldown', { seconds: resendCooldown }) : t('auth.resend')} + + + + + router.back()} + className="py-3 items-center mt-2" + > + {t('auth.backToSignup')} + + + + + ); +} diff --git a/apps/rebreak-native/app/(auth)/confirm.tsx b/apps/rebreak-native/app/(auth)/confirm.tsx new file mode 100644 index 0000000..a0075cf --- /dev/null +++ b/apps/rebreak-native/app/(auth)/confirm.tsx @@ -0,0 +1,96 @@ +import { useEffect, useState } from 'react'; +import { View, Text, Pressable, ActivityIndicator } from 'react-native'; +import { useRouter, useLocalSearchParams } from 'expo-router'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { useTranslation } from 'react-i18next'; +import { supabase } from '../../lib/supabase'; +import { useAuthStore } from '../../stores/auth'; + +/** + * Deep-link landing screen for email-confirmation OAuth callbacks. + * Invoked when the system opens rebreak://auth/confirm?access_token=...&refresh_token=... + * + * Strategy: extract tokens from URL params (Expo Router surfaces them), + * call setSession, then route to app. If params are missing, poll getSession() + * (covers the case where supabase-js processed the hash before we got here). + */ +export default function ConfirmScreen() { + const router = useRouter(); + const { t } = useTranslation(); + const params = useLocalSearchParams<{ + access_token?: string; + refresh_token?: string; + error?: string; + error_description?: string; + }>(); + + const [statusMsg, setStatusMsg] = useState(t('auth.confirming')); + const [errorMsg, setErrorMsg] = useState(null); + + useEffect(() => { + handleConfirm(); + }, []); + + async function handleConfirm() { + if (params.error) { + setErrorMsg(params.error_description ?? params.error ?? t('auth.confirmFailed')); + return; + } + + if (params.access_token && params.refresh_token) { + const { data, error } = await supabase.auth.setSession({ + access_token: params.access_token, + refresh_token: params.refresh_token, + }); + if (error) { + setErrorMsg(error.message); + return; + } + useAuthStore.setState({ session: data.session, user: data.session?.user ?? null }); + setStatusMsg(t('auth.confirmSuccess')); + router.replace('/(app)'); + return; + } + + // Fallback: poll up to 20s for a session (supabase-js may have processed hash already) + const maxWait = 20_000; + const interval = 500; + const start = Date.now(); + + while (Date.now() - start < maxWait) { + const { data } = await supabase.auth.getSession(); + if (data.session) { + useAuthStore.setState({ session: data.session, user: data.session.user }); + setStatusMsg(t('auth.confirmSuccess')); + router.replace('/(app)'); + return; + } + await new Promise((r) => setTimeout(r, interval)); + } + + setErrorMsg(t('auth.confirmTimeout')); + } + + return ( + + + {!errorMsg ? ( + <> + + {statusMsg} + + ) : ( + <> + {errorMsg} + router.replace('/signin')} + className="bg-neutral-100 border border-neutral-200 px-6 py-3 rounded-xl" + > + {t('auth.backToLoginPlain')} + + + )} + + + ); +} diff --git a/apps/rebreak-native/app/(auth)/device-limit.tsx b/apps/rebreak-native/app/(auth)/device-limit.tsx new file mode 100644 index 0000000..7c91a5b --- /dev/null +++ b/apps/rebreak-native/app/(auth)/device-limit.tsx @@ -0,0 +1,44 @@ +import { View, Text, Pressable } from 'react-native'; +import { useRouter } from 'expo-router'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { useTranslation } from 'react-i18next'; + +/** + * Shown when the backend returns a device-limit error (HTTP 403 with device_limit_reached). + * Phase 2: static info + redirect to sign-in. + * Phase 4+: integrate with device-management store to show active devices + revoke option. + */ +export default function DeviceLimitScreen() { + const router = useRouter(); + const { t } = useTranslation(); + + return ( + + + 📱 + + {t('auth.deviceLimitTitle')} + + + {t('auth.deviceLimitDesc')} + + + {/* TODO Phase 4: device management — list active devices + revoke button */} + + router.replace('/signin')} + className="bg-rebreak-500 px-8 py-4 rounded-xl active:opacity-80" + > + {t('auth.toLogin')} + + + router.push('/signin')} + className="py-3 mt-2" + > + {t('auth.deviceLimitUpgrade')} + + + + ); +} diff --git a/apps/rebreak-native/app/(auth)/forgot-password.tsx b/apps/rebreak-native/app/(auth)/forgot-password.tsx new file mode 100644 index 0000000..866be24 --- /dev/null +++ b/apps/rebreak-native/app/(auth)/forgot-password.tsx @@ -0,0 +1,113 @@ +import { useState } from 'react'; +import { + View, + Text, + TextInput, + Pressable, + KeyboardAvoidingView, + Platform, + ActivityIndicator, +} from 'react-native'; +import { useRouter } from 'expo-router'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { useTranslation } from 'react-i18next'; +import { useAuthStore } from '../../stores/auth'; + +const INPUT_STYLE = { + fontSize: 16, + lineHeight: 22, + paddingVertical: 14, + paddingHorizontal: 16, + color: '#0a0a0a', + fontFamily: 'Nunito_400Regular', +} as const; + +export default function ForgotPasswordScreen() { + const router = useRouter(); + const { t } = useTranslation(); + const { resetPasswordForEmail } = useAuthStore(); + + const [email, setEmail] = useState(''); + const [loading, setLoading] = useState(false); + const [sent, setSent] = useState(false); + const [error, setError] = useState(null); + + const onSubmit = async () => { + if (!email.trim()) return; + setError(null); + setLoading(true); + const res = await resetPasswordForEmail(email.trim()); + setLoading(false); + if (res.error) { + setError(res.error); + return; + } + setSent(true); + }; + + return ( + + + + {t('auth.resetPasswordTitle')} + + {t('auth.resetPasswordSubtitle')} + + + {!sent ? ( + <> + + + {error && ( + + {error} + + )} + + + {loading ? ( + + ) : ( + {t('auth.resetPasswordSend')} + )} + + + ) : ( + + {t('auth.resetPasswordSent')} + + {t('auth.resetPasswordSentDescPrefix')}{email}{t('auth.resetPasswordSentDescSuffix')} + + + )} + + router.back()} + className="py-4 items-center mt-2" + > + {t('auth.backToLogin')} + + + + + ); +} diff --git a/apps/rebreak-native/app/(auth)/signin.tsx b/apps/rebreak-native/app/(auth)/signin.tsx new file mode 100644 index 0000000..5534de6 --- /dev/null +++ b/apps/rebreak-native/app/(auth)/signin.tsx @@ -0,0 +1,199 @@ +import { useState } from 'react'; +import { + View, + Text, + TextInput, + Pressable, + KeyboardAvoidingView, + Platform, + ScrollView, + ActivityIndicator, +} from 'react-native'; +import { useRouter } from 'expo-router'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import Svg, { Path } from 'react-native-svg'; +import { useTranslation } from 'react-i18next'; +import { useAuthStore } from '../../stores/auth'; + +function GoogleIcon() { + return ( + + + + + + + ); +} + +function AppleIcon() { + return ( + + + + ); +} + +type OAuthProvider = 'google' | 'apple' | null; + +const INPUT_STYLE = { + fontSize: 16, + lineHeight: 22, + paddingVertical: 14, + paddingHorizontal: 16, + color: '#0a0a0a', + fontFamily: 'Nunito_400Regular', +} as const; + +export default function SignInScreen() { + const router = useRouter(); + const { t } = useTranslation(); + const { signInWithPassword, signInWithOAuth } = useAuthStore(); + + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(null); + const [submitting, setSubmitting] = useState(false); + const [oauthLoading, setOauthLoading] = useState(null); + + const onSubmit = async () => { + if (!email.trim() || !password) return; + setError(null); + setSubmitting(true); + const res = await signInWithPassword(email.trim(), password); + setSubmitting(false); + if (res.error) { + setError(res.error); + return; + } + router.replace('/(app)'); + }; + + const onOAuth = async (provider: 'google' | 'apple') => { + setError(null); + setOauthLoading(provider); + const res = await signInWithOAuth(provider); + setOauthLoading(null); + if (res.error) { + setError(res.error); + return; + } + router.replace('/(app)'); + }; + + const isLoading = submitting || oauthLoading !== null; + + return ( + + + + {t('auth.welcomeBack')} + + {t('auth.signinSubtitle')} + + + {/* OAuth Buttons */} + onOAuth('google')} + disabled={isLoading} + className="flex-row items-center justify-center gap-3 bg-white border border-neutral-200 rounded-xl mb-3 active:opacity-80 disabled:opacity-40" + style={{ paddingVertical: 14 }} + > + {oauthLoading === 'google' ? ( + + ) : ( + + )} + {t('auth.googleSignin')} + + + onOAuth('apple')} + disabled={isLoading} + className="flex-row items-center justify-center gap-3 bg-neutral-900 rounded-xl mb-6 active:opacity-80 disabled:opacity-40" + style={{ paddingVertical: 14 }} + > + {oauthLoading === 'apple' ? ( + + ) : ( + + )} + {t('auth.appleSignin')} + + + {/* Divider */} + + + {t('auth.orWithEmail')} + + + + {/* Email + Password */} + + + + + router.push('/forgot-password')} + className="self-end py-2 mb-4" + > + {t('auth.forgotPassword')} + + + {error && ( + {error} + )} + + + {submitting ? ( + + ) : ( + {t('auth.signin')} + )} + + + router.push('/signup')} + className="py-4 items-center mt-2" + > + + {t('auth.noAccount')}{' '} + {t('auth.signup')} + + + + + + ); +} diff --git a/apps/rebreak-native/app/(auth)/signup.tsx b/apps/rebreak-native/app/(auth)/signup.tsx new file mode 100644 index 0000000..4b80b64 --- /dev/null +++ b/apps/rebreak-native/app/(auth)/signup.tsx @@ -0,0 +1,301 @@ +import { useState } from 'react'; +import { + View, + Text, + TextInput, + Pressable, + KeyboardAvoidingView, + Platform, + ScrollView, + Image, + ActivityIndicator, +} from 'react-native'; +import { useRouter } from 'expo-router'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import Svg, { Path } from 'react-native-svg'; +import { useTranslation } from 'react-i18next'; +import { useAuthStore } from '../../stores/auth'; +import { HERO_AVATARS, getAvatarUrl } from '../../lib/avatars'; + +function GoogleIcon() { + return ( + + + + + + + ); +} + +function AppleIcon() { + return ( + + + + ); +} + +type OAuthProvider = 'google' | 'apple' | null; + +const INPUT_STYLE = { + fontSize: 16, + lineHeight: 22, + paddingVertical: 14, + paddingHorizontal: 16, + color: '#0a0a0a', + fontFamily: 'Nunito_400Regular', +} as const; + +export default function SignUpScreen() { + const router = useRouter(); + const { t } = useTranslation(); + const { signUp, signInWithOAuth } = useAuthStore(); + + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [firstName, setFirstName] = useState(''); + const [lastName, setLastName] = useState(''); + const [nickname, setNickname] = useState(''); + const [avatarId, setAvatarId] = useState('spider'); + const [termsAccepted, setTermsAccepted] = useState(false); + const [error, setError] = useState(null); + const [submitting, setSubmitting] = useState(false); + const [oauthLoading, setOauthLoading] = useState(null); + + const isLoading = submitting || oauthLoading !== null; + + const onOAuth = async (provider: 'google' | 'apple') => { + setError(null); + setOauthLoading(provider); + const res = await signInWithOAuth(provider); + setOauthLoading(null); + if (res.error) { + setError(res.error); + return; + } + router.replace('/(app)'); + }; + + const onSubmit = async () => { + if (!email.trim() || !password || !nickname.trim()) { + setError(t('auth.fillRequired')); + return; + } + if (password.length < 8) { + setError(t('auth.passwordMin8')); + return; + } + if (!termsAccepted) { + setError(t('auth.pleaseAcceptTerms')); + return; + } + setError(null); + setSubmitting(true); + const res = await signUp(email.trim(), password, { + username: nickname.trim(), + firstName: firstName.trim() || undefined, + lastName: lastName.trim() || undefined, + avatarId, + avatarUrl: getAvatarUrl(avatarId), + }); + setSubmitting(false); + if (res.error) { + setError(res.error); + return; + } + router.push({ pathname: '/confirm-otp', params: { email: email.trim() } }); + }; + + return ( + + + + {t('auth.signupTitle')} + + {t('auth.signupSubtitle')} + + + {/* OAuth Buttons */} + onOAuth('google')} + disabled={isLoading} + className="flex-row items-center justify-center gap-3 bg-white border border-neutral-200 rounded-xl mb-3 active:opacity-80 disabled:opacity-40" + style={{ paddingVertical: 14 }} + > + {oauthLoading === 'google' ? ( + + ) : ( + + )} + {t('auth.googleSignup')} + + + onOAuth('apple')} + disabled={isLoading} + className="flex-row items-center justify-center gap-3 bg-neutral-900 rounded-xl mb-6 active:opacity-80 disabled:opacity-40" + style={{ paddingVertical: 14 }} + > + {oauthLoading === 'apple' ? ( + + ) : ( + + )} + {t('auth.appleSignup')} + + + {/* Divider */} + + + {t('auth.orWithEmail')} + + + + {error && ( + + {error} + + )} + + + + + + + + + + + + + {/* Avatar Picker */} + {t('auth.chooseAvatar')} + + {HERO_AVATARS.map((avatar) => { + const selected = avatar.id === avatarId; + return ( + setAvatarId(avatar.id)} + disabled={isLoading} + className={`rounded-full ${selected ? 'opacity-100' : 'opacity-40'}`} + > + + + ); + })} + + + {/* Privacy notice */} + + 🛡 + + {t('auth.privacyNotice')} + + + + {/* Terms Checkbox */} + setTermsAccepted(!termsAccepted)} + disabled={isLoading} + className="flex-row items-start gap-3 mb-6" + > + + {termsAccepted && ( + + )} + + + {t('auth.acceptTerms')}{' '} + {t('auth.termsLink')} + {t('auth.acceptTermsSuffix')} + + + + + {submitting ? ( + + ) : ( + {t('auth.signupTitle')} + )} + + + router.push('/signin')} + className="py-4 items-center mt-2" + > + + {t('auth.alreadyRegistered')}{' '} + {t('auth.signin')} + + + + + + ); +} diff --git a/apps/rebreak-native/app/_layout.tsx b/apps/rebreak-native/app/_layout.tsx new file mode 100644 index 0000000..55802a1 --- /dev/null +++ b/apps/rebreak-native/app/_layout.tsx @@ -0,0 +1,124 @@ +import { useEffect } from 'react'; +import { Stack } from 'expo-router'; +import { StatusBar } from 'expo-status-bar'; +import * as Notifications from 'expo-notifications'; +import { GestureHandlerRootView } from 'react-native-gesture-handler'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { SafeAreaProvider } from 'react-native-safe-area-context'; +import * as SplashScreen from 'expo-splash-screen'; +import { + useFonts, + Nunito_400Regular, + Nunito_600SemiBold, + Nunito_700Bold, + Nunito_800ExtraBold, +} from '@expo-google-fonts/nunito'; +import { useAuthStore } from '../stores/auth'; +import { BrandSplash } from '../components/BrandSplash'; +import '../lib/i18n'; // i18next-Init via Side-Effect +import '../global.css'; + +SplashScreen.preventAutoHideAsync(); + +Notifications.setNotificationHandler({ + handleNotification: async () => ({ + shouldShowBanner: true, + shouldShowList: true, + shouldPlaySound: true, + shouldSetBadge: false, + }), +}); + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: 2, + staleTime: 1000 * 60, + }, + }, +}); + +function RootLayoutInner() { + const { loading, init } = useAuthStore(); + const [fontsLoaded] = useFonts({ + Nunito_400Regular, + Nunito_600SemiBold, + Nunito_700Bold, + Nunito_800ExtraBold, + }); + + useEffect(() => { + init(); + }, []); + + useEffect(() => { + if (fontsLoaded && !loading) { + SplashScreen.hideAsync(); + } + }, [fontsLoaded, loading]); + + if (!fontsLoaded || loading) { + return ; + } + + return ( + <> + + + + + + + + + + + + ); +} + +export default function RootLayout() { + return ( + + + + + + + + ); +} diff --git a/apps/rebreak-native/app/auth/callback.tsx b/apps/rebreak-native/app/auth/callback.tsx new file mode 100644 index 0000000..5f2caf0 --- /dev/null +++ b/apps/rebreak-native/app/auth/callback.tsx @@ -0,0 +1,57 @@ +// Deep-Link Bridge für OAuth-Callback (Google/Apple). +// +// Hintergrund: nach erfolgreichem OAuth-Login redirected Supabase zu +// `rebreak://auth/callback#access_token=...`. Auf iOS schluckt +// `WebBrowser.openAuthSessionAsync` den Deep-Link bevor expo-router ihn sieht. +// Auf Android öffnet das System die App via Deep-Link → expo-router routet +// `/auth/callback` BEVOR openAuthSessionAsync's Listener feuert → 404. +// +// Diese Bridge-Page fängt das ab: zeigt einen Loader, extrahiert Tokens als +// Fallback (falls openAuthSessionAsync den Hash nicht selbst parst), und +// navigiert nach (app). signin.tsx macht zusätzlich router.replace('/(app)') +// nach openAuthSessionAsync resolve — diese Bridge ist nur für den Android- +// 404-Flash da. +import { useEffect } from 'react'; +import { View, ActivityIndicator } from 'react-native'; +import { useRouter, useLocalSearchParams } from 'expo-router'; +import { supabase } from '../../lib/supabase'; +import { colors } from '../../lib/theme'; + +export default function AuthCallback() { + const router = useRouter(); + const params = useLocalSearchParams<{ access_token?: string; refresh_token?: string }>(); + + useEffect(() => { + let cancelled = false; + (async () => { + const accessToken = typeof params.access_token === 'string' ? params.access_token : undefined; + const refreshToken = typeof params.refresh_token === 'string' ? params.refresh_token : undefined; + if (accessToken && refreshToken) { + try { + await supabase.auth.setSession({ + access_token: accessToken, + refresh_token: refreshToken, + }); + } catch (err) { + console.warn('[auth-callback] setSession failed:', err); + } + } + // Kurzer Delay → onAuthStateChange propagiert die Session in den Store. + if (!cancelled) { + setTimeout(() => { + router.replace('/(app)' as never); + }, 60); + } + })(); + return () => { + cancelled = true; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + + + + ); +} diff --git a/apps/rebreak-native/app/dm.tsx b/apps/rebreak-native/app/dm.tsx new file mode 100644 index 0000000..003e6a5 --- /dev/null +++ b/apps/rebreak-native/app/dm.tsx @@ -0,0 +1,365 @@ +import { useState, useRef, useEffect, useCallback } from 'react'; +import { + View, + Text, + FlatList, + Pressable, + KeyboardAvoidingView, + Platform, + ActivityIndicator, + Image, + StyleSheet, +} from 'react-native'; +import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; +import { useRouter, useLocalSearchParams } from 'expo-router'; +import { Ionicons } from '@expo/vector-icons'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; +import { apiFetch } from '../lib/api'; +import { supabase } from '../lib/supabase'; +import { ChatBubble, type ChatMsg } from '../components/chat/ChatBubble'; +import { ChatInput, type SendPayload } from '../components/chat/ChatInput'; +import { useDmRealtime } from '../hooks/useChatRealtime'; +import { colors } from '../lib/theme'; + +type DmHistoryResponse = { + partner: { + id: string; + nickname: string; + username?: string; + avatar?: string | null; + }; + messages: Array<{ + id: string; + content: string; + createdAt: string; + isOwn: boolean; + readAt: string | null; + senderId?: string; + receiverId?: string; + likesCount?: number; + likedByMe?: boolean; + attachmentUrl?: string | null; + attachmentType?: string | null; + attachmentName?: string | null; + replyTo?: any; + }>; +}; + +const GROUP_GAP_MS = 5 * 60 * 1000; + +export default function DmScreen() { + const { t } = useTranslation(); + const router = useRouter(); + const insets = useSafeAreaInsets(); + const queryClient = useQueryClient(); + const flatRef = useRef(null); + const [myUserId, setMyUserId] = useState(undefined); + + const { userId } = useLocalSearchParams<{ userId: string }>(); + + const [messages, setMessages] = useState([]); + const [partner, setPartner] = useState(null); + const [replyTo, setReplyTo] = useState<{ id: string; nickname: string; content: string } | null>( + null, + ); + const [sending, setSending] = useState(false); + + // Lade meine User-ID + useEffect(() => { + supabase.auth.getSession().then(({ data }) => { + setMyUserId(data.session?.user.id); + }); + }, []); + + // Lade DM-History + const { isLoading } = useQuery({ + queryKey: ['dm-history', userId], + queryFn: async () => { + console.log('[dm] fetching history for partner', userId, 'me', myUserId); + try { + const data = await apiFetch(`/api/chat/dm/${userId}`); + console.log('[dm] partner:', data.partner?.nickname, 'msgs:', data.messages?.length); + setPartner(data.partner); + const msgs: ChatMsg[] = data.messages.map((m: any) => ({ + id: m.id, + userId: m.senderId ?? (m.isOwn ? myUserId ?? '' : userId), + nickname: m.isOwn ? 'Du' : data.partner?.nickname ?? '?', + avatar: m.isOwn ? null : data.partner?.avatar ?? null, + content: m.content, + replyTo: m.replyTo + ? { + id: m.replyTo.id, + userId: m.replyTo.senderId, + nickname: + m.replyTo.senderId === myUserId ? 'Du' : data.partner?.nickname ?? '?', + content: m.replyTo.content?.slice(0, 100) ?? '', + attachmentType: m.replyTo.attachmentType ?? null, + } + : null, + attachmentUrl: m.attachmentUrl ?? null, + attachmentType: m.attachmentType ?? null, + attachmentName: m.attachmentName ?? null, + likesCount: m.likesCount ?? 0, + likedByMe: m.likedByMe ?? false, + createdAt: m.createdAt, + isOwn: m.isOwn, + readAt: m.readAt, + })); + setMessages(msgs); + return data; + } catch (err: any) { + console.error('[dm] history fetch failed:', err?.message ?? err); + throw err; + } + }, + enabled: !!userId && !!myUserId, + }); + + // Realtime: neue DMs vom Partner + const onDmInsert = useCallback( + (row: any) => { + if (row.receiver_id !== myUserId) return; + setMessages((prev) => { + if (prev.some((m) => m.id === row.id)) return prev; + return [ + ...prev, + { + id: row.id, + userId: row.sender_id, + nickname: partner?.nickname ?? '?', + avatar: partner?.avatar ?? null, + content: row.content ?? '', + replyTo: null, + attachmentUrl: row.attachment_url ?? null, + attachmentType: row.attachment_type ?? null, + attachmentName: row.attachment_name ?? null, + likesCount: row.likes_count ?? 0, + likedByMe: false, + createdAt: row.created_at, + isOwn: false, + readAt: null, + }, + ]; + }); + }, + [myUserId, partner], + ); + useDmRealtime(userId, onDmInsert, !!myUserId); + + // Auto-Scroll bei neuen Messages + useEffect(() => { + if (messages.length > 0) { + requestAnimationFrame(() => flatRef.current?.scrollToEnd({ animated: true })); + } + }, [messages.length]); + + async function handleSend(payload: SendPayload) { + if (sending) return; + setSending(true); + try { + const newMsg = await apiFetch('/api/chat/dm', { + method: 'POST', + body: { receiverId: userId, ...payload }, + }); + setMessages((prev) => [ + ...prev, + { + id: newMsg.id, + userId: myUserId ?? '', + nickname: 'Du', + avatar: null, + content: newMsg.content, + replyTo: newMsg.replyTo + ? { + id: newMsg.replyTo.id, + userId: newMsg.replyTo.senderId, + nickname: + newMsg.replyTo.senderId === myUserId ? 'Du' : partner?.nickname ?? '?', + content: newMsg.replyTo.content?.slice(0, 100) ?? '', + attachmentType: newMsg.replyTo.attachmentType ?? null, + } + : null, + attachmentUrl: newMsg.attachmentUrl, + attachmentType: newMsg.attachmentType, + attachmentName: newMsg.attachmentName, + likesCount: newMsg.likesCount ?? 0, + likedByMe: false, + createdAt: newMsg.createdAt, + isOwn: true, + readAt: null, + }, + ]); + setReplyTo(null); + queryClient.invalidateQueries({ queryKey: ['dm-conversations'] }); + } catch (err) { + console.error('DM send failed:', err); + } finally { + setSending(false); + } + } + + async function toggleLike(msg: ChatMsg) { + try { + const { liked } = await apiFetch<{ liked: boolean }>('/api/chat/like', { + method: 'POST', + body: { messageId: msg.id, type: 'dm' }, + }); + setMessages((prev) => + prev.map((m) => + m.id === msg.id + ? { ...m, likedByMe: liked, likesCount: m.likesCount + (liked ? 1 : -1) } + : m, + ), + ); + } catch {} + } + + function startReply(msg: ChatMsg) { + setReplyTo({ + id: msg.id, + nickname: msg.nickname ?? '?', + content: msg.content?.slice(0, 100) || (msg.attachmentType === 'image' ? 'Bild' : ''), + }); + } + + function sameAuthor(a: ChatMsg | undefined, b: ChatMsg | undefined): boolean { + if (!a || !b) return false; + if (a.userId !== b.userId) return false; + return Math.abs(new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) <= GROUP_GAP_MS; + } + + return ( + + {/* Header */} + + router.back()} hitSlop={8}> + + + + + {partner?.avatar ? ( + + ) : ( + + {(partner?.nickname ?? '?').slice(0, 2).toUpperCase()} + + )} + + + {partner?.nickname ?? '…'} + + + + + + + {isLoading && messages.length === 0 ? ( + + + + ) : messages.length === 0 ? ( + + + {t('chat.no_chats')} + + ) : ( + ( + {}} + /> + )} + keyExtractor={(m) => m.id} + contentContainerStyle={{ paddingTop: 12, paddingBottom: 8 }} + showsVerticalScrollIndicator={false} + onContentSizeChange={() => flatRef.current?.scrollToEnd({ animated: false })} + /> + )} + + + setReplyTo(null)} + /> + + + + ); +} + +const styles = StyleSheet.create({ + container: { flex: 1, backgroundColor: '#fafafa' }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 12, + paddingVertical: 10, + backgroundColor: '#fff', + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: '#e5e5e5', + }, + backBtn: { + width: 36, + height: 36, + borderRadius: 12, + backgroundColor: '#f5f5f5', + alignItems: 'center', + justifyContent: 'center', + }, + headerCenter: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + marginHorizontal: 8, + }, + headerAvatar: { + width: 32, + height: 32, + borderRadius: 16, + backgroundColor: '#e5e5e5', + alignItems: 'center', + justifyContent: 'center', + overflow: 'hidden', + marginRight: 8, + }, + headerAvatarImg: { width: 32, height: 32 }, + headerAvatarInitials: { + fontSize: 11, + fontFamily: 'Nunito_700Bold', + color: '#737373', + }, + headerName: { + fontSize: 15, + fontFamily: 'Nunito_700Bold', + color: '#171717', + flexShrink: 1, + }, + loadingBox: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + }, + emptyText: { + fontSize: 13, + fontFamily: 'Nunito_600SemiBold', + color: '#a3a3a3', + marginTop: 12, + }, +}); diff --git a/apps/rebreak-native/app/index.tsx b/apps/rebreak-native/app/index.tsx new file mode 100644 index 0000000..eb3402e --- /dev/null +++ b/apps/rebreak-native/app/index.tsx @@ -0,0 +1,31 @@ +import { View, Text, Pressable } from 'react-native'; +import { useRouter } from 'expo-router'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { useTranslation } from 'react-i18next'; + +export default function HomeScreen() { + const router = useRouter(); + const { t } = useTranslation(); + + return ( + + + {t('landing.appName')} + + {t('landing.tagline')} + + + router.push('/signin')} + className="bg-rebreak-500 px-8 py-4 rounded-full active:opacity-80" + > + {t('landing.start')} + + + + {t('landing.version')} + + + + ); +} diff --git a/apps/rebreak-native/app/lyra.tsx b/apps/rebreak-native/app/lyra.tsx new file mode 100644 index 0000000..2af859c --- /dev/null +++ b/apps/rebreak-native/app/lyra.tsx @@ -0,0 +1,976 @@ +import { + useRef, + useState, + useEffect, + useCallback, +} from 'react'; +import { + View, + Text, + TextInput, + FlatList, + Pressable, + Platform, + Animated, + Keyboard, + KeyboardAvoidingView, + StyleSheet, + ActivityIndicator, + NativeSyntheticEvent, + NativeScrollEvent, +} from 'react-native'; +import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; +import { useRouter } from 'expo-router'; +import { Ionicons } from '@expo/vector-icons'; +import { Audio } from 'expo-av'; +import * as FileSystem from 'expo-file-system'; +import Constants from 'expo-constants'; +import { useTranslation } from 'react-i18next'; +import { RiveAvatar, type Emotion } from '../components/RiveAvatar'; +import { useCoachStore, type Message } from '../stores/coach'; +import { apiFetch } from '../lib/api'; +import { supabase } from '../lib/supabase'; +import { colors } from '../lib/theme'; + +const EMPATHY_RE = /schwer|rückfall|traurig|schlimm|hoffnungslos|hilf|verloren|scham|schuld|verzweifelt/i; +const HAPPY_RE = /toll|super|geschafft|stark|glückwunsch|stolz|fantastisch|weiter so|prima|gut gemacht/i; + +function detectEmotion(text: string): Emotion { + if (HAPPY_RE.test(text)) return 'happy'; + if (EMPATHY_RE.test(text)) return 'empathy'; + return 'idle'; +} + +function formatDuration(s: number): string { + const m = Math.floor(s / 60); + const sec = (s % 60).toString().padStart(2, '0'); + return `${m}:${sec}`; +} + +function formatTimestamp(date: Date): string { + return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); +} + +// ── Loading skeleton ────────────────────────────────────────────────────────── +// Standard-Spinner — kein zweiter Rive-Avatar (der ist bereits im topBar oben). + +function LoadingPulse() { + return ( + + + + ); +} + +// ── Thinking dots ───────────────────────────────────────────────────────────── + +function ThinkingDots() { + const anim = useRef([new Animated.Value(0), new Animated.Value(0), new Animated.Value(0)]).current; + + useEffect(() => { + const animations = anim.map((a, i) => + Animated.loop( + Animated.sequence([ + Animated.delay(i * 160), + Animated.timing(a, { toValue: 1, duration: 300, useNativeDriver: true }), + Animated.timing(a, { toValue: 0, duration: 300, useNativeDriver: true }), + ]) + ) + ); + animations.forEach((a) => a.start()); + return () => animations.forEach((a) => a.stop()); + }, []); + + return ( + + {anim.map((a, i) => ( + + ))} + + ); +} + +// ── Voice bars ──────────────────────────────────────────────────────────────── + +function VoiceBars({ count, baseColor }: { count: number; baseColor: string }) { + const anims = useRef(Array.from({ length: count }, () => new Animated.Value(4))).current; + + useEffect(() => { + const animations = anims.map((a, i) => + Animated.loop( + Animated.sequence([ + Animated.timing(a, { toValue: 4 + Math.random() * 14, duration: 450 + (i % 5) * 80, useNativeDriver: false }), + Animated.timing(a, { toValue: 4, duration: 450 + (i % 5) * 80, useNativeDriver: false }), + ]) + ) + ); + animations.forEach((a) => a.start()); + return () => animations.forEach((a) => a.stop()); + }, []); + + return ( + + {anims.map((a, i) => ( + + ))} + + ); +} + +// ── Message row (Insta-DM style) ────────────────────────────────────────────── + +type MessageWithMeta = Message & { timestamp: Date }; + +function MessageRow({ + item, + t, +}: { + item: MessageWithMeta; + t: (key: string) => string; +}) { + const isUser = item.role === 'user'; + + return ( + + + + + {item.content} + + + + + {item.feedbackSaved && ( + <> + + {t('coach.feedback_saved')} + + )} + {formatTimestamp(item.timestamp)} + + + + ); +} + +// ── Main screen ─────────────────────────────────────────────────────────────── + +export default function CoachScreen() { + const { t, i18n } = useTranslation(); + const router = useRouter(); + const insets = useSafeAreaInsets(); + const flatRef = useRef(null); + + // Reaktive Slices — nur was UI-relevant ist (Re-Render bei diesen). + const messages = useCoachStore((s) => s.messages); + const thinking = useCoachStore((s) => s.thinking); + // Actions sind stable — keine Re-Renders bei Bezug. + const loadHistory = useCoachStore((s) => s.loadHistory); + const clearHistory = useCoachStore((s) => s.clearHistory); + const sendMessage = useCoachStore((s) => s.sendMessage); + const pushMessage = useCoachStore((s) => s.pushMessage); + const markFeedbackSaved = useCoachStore((s) => s.markFeedbackSaved); + const setThinking = useCoachStore((s) => s.setThinking); + const setWelcomeBackShown = useCoachStore((s) => s.setWelcomeBackShown); + + const [input, setInput] = useState(''); + const [emotion, setEmotion] = useState('idle'); + const [isSpeaking, setIsSpeaking] = useState(false); + const [isRecording, setIsRecording] = useState(false); + const [isTranscribing, setIsTranscribing] = useState(false); + const [recordingDuration, setRecordingDuration] = useState(0); + const [showScrollBtn, setShowScrollBtn] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [keyboardHeight, setKeyboardHeight] = useState(0); + + const recordingRef = useRef(null); + const soundRef = useRef(null); + const micHeld = useRef(false); + const recordingTimer = useRef | null>(null); + const typingTimer = useRef | null>(null); + const emotionTimer = useRef | null>(null); + const isNearBottomRef = useRef(true); + + // Load history + welcome-back. Beide Side-Effects sind store-cached: + // - historyLoaded → kein Re-Fetch + kein Spinner-Blink bei Tab-Wechsel + // - welcomeBackShownThisSession → keine doppelte Lyra-Begrüßung + useEffect(() => { + let cancelled = false; + + // Aktuelle Werte aus dem Store lesen (statt Closure-Stale beim ersten Render). + const snap = useCoachStore.getState(); + const needsHistory = !snap.historyLoaded; + const needsWelcomeBack = !snap.welcomeBackShownThisSession; + + if (!needsHistory && !needsWelcomeBack) { + // Coach war diese Session schon offen → instant rendern, kein Spinner. + requestAnimationFrame(() => flatRef.current?.scrollToEnd({ animated: false })); + return; + } + + async function init() { + // Spinner nur wenn wir wirklich History fetchen müssen (erster Coach-Open). + if (needsHistory) setIsLoading(true); + + if (needsHistory) { + try { + await loadHistory(); + } catch { + // non-fatal + } + } + + if (cancelled) return; + + if (needsWelcomeBack) { + try { + const res = await apiFetch<{ message?: string }>('/api/lyra/welcome-back'); + if (!cancelled && res?.message) { + pushMessage({ id: 'wb-' + Date.now(), role: 'assistant', content: res.message }); + } + } catch { + // no welcome-back — silent + } finally { + if (!cancelled) setWelcomeBackShown(true); + } + } + + if (!cancelled) { + if (needsHistory) setIsLoading(false); + requestAnimationFrame(() => flatRef.current?.scrollToEnd({ animated: false })); + } + } + + init(); + return () => { + cancelled = true; + }; + }, []); + + useEffect(() => { + // iOS: Will*-Events feuern VOR der Keyboard-Animation → paddingBottom + // ändert sich synchron mit KeyboardAvoidingView. Sonst springt der + // Input erst hoch, dann nach unten ("Zucken"). + // Android: Will*-Events feuern unzuverlässig → Did* ist der stabile Pfad. + const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow'; + const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide'; + const show = Keyboard.addListener(showEvent, (e) => setKeyboardHeight(e.endCoordinates.height)); + const hide = Keyboard.addListener(hideEvent, () => setKeyboardHeight(0)); + return () => { show.remove(); hide.remove(); }; + }, []); + + // Enrich messages with timestamp + const enrichedMessages: MessageWithMeta[] = messages.map((msg) => ({ + ...msg, + timestamp: new Date(), + })); + + // Scroll to bottom when messages change (only when near bottom) + useEffect(() => { + if (messages.length === 0) return; + if (isNearBottomRef.current) { + requestAnimationFrame(() => flatRef.current?.scrollToEnd({ animated: true })); + } else { + setShowScrollBtn(true); + } + }, [messages.length, thinking]); + + function handleScroll(e: NativeSyntheticEvent) { + const { layoutMeasurement, contentOffset, contentSize } = e.nativeEvent; + const distFromBottom = contentSize.height - contentOffset.y - layoutMeasurement.height; + isNearBottomRef.current = distFromBottom < 80; + if (isNearBottomRef.current) setShowScrollBtn(false); + } + + function scrollToBottom() { + flatRef.current?.scrollToEnd({ animated: true }); + setShowScrollBtn(false); + } + + function handleInputChange(text: string) { + setInput(text); + if (text.length > 0) { + setEmotion((e) => (e === 'thinking' ? e : 'happy')); + if (typingTimer.current) clearTimeout(typingTimer.current); + typingTimer.current = setTimeout(() => { + setEmotion((e) => (e === 'happy' ? 'idle' : e)); + }, 2500); + } else { + if (typingTimer.current) clearTimeout(typingTimer.current); + setEmotion((e) => (e === 'happy' ? 'idle' : e)); + } + } + + function scheduleEmotionReset(delay = 4000) { + if (emotionTimer.current) clearTimeout(emotionTimer.current); + emotionTimer.current = setTimeout(() => setEmotion('idle'), delay); + } + + async function handleSend() { + const content = input.trim(); + if (!content || thinking) return; + + const userMsgId = Date.now().toString(); + pushMessage({ id: userMsgId, role: 'user', content }); + setInput(''); + setThinking(true); + setEmotion('thinking'); + + try { + const res = await sendMessage(content, i18n.language); + if (res.feedbackSaved) markFeedbackSaved(userMsgId); + pushMessage({ id: (Date.now() + 1).toString(), role: 'assistant', content: res.message }); + const e = detectEmotion(res.message); + setEmotion(e); + scheduleEmotionReset(e === 'empathy' ? 6000 : 4000); + } catch { + pushMessage({ id: (Date.now() + 1).toString(), role: 'assistant', content: t('coach.error'), isError: true }); + setEmotion('idle'); + } finally { + setThinking(false); + } + } + + async function handleVoiceSend(text: string) { + if (!text.trim() || thinking) return; + const userMsgId = Date.now().toString(); + pushMessage({ id: userMsgId, role: 'user', content: text }); + setThinking(true); + setEmotion('thinking'); + + try { + const res = await sendMessage(text, i18n.language); + if (res.feedbackSaved) markFeedbackSaved(userMsgId); + pushMessage({ id: (Date.now() + 1).toString(), role: 'assistant', content: res.message }); + const e = detectEmotion(res.message); + setEmotion(e); + + try { + const apiBase = Constants.expoConfig?.extra?.apiUrl as string; + const session = (await supabase.auth.getSession()).data.session; + const ttsRes = await fetch(`${apiBase}/api/coach/speak-openai`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(session?.access_token ? { Authorization: `Bearer ${session.access_token}` } : {}), + }, + body: JSON.stringify({ text: res.message, locale: i18n.language }), + }); + if (ttsRes.ok) { + // Backend streamt audio/mpeg direkt — als ArrayBuffer holen, base64-encoden, in tmp-File schreiben. + const buffer = await ttsRes.arrayBuffer(); + if (buffer.byteLength === 0) { + console.warn('[tts] empty audio buffer'); + } else { + const bytes = new Uint8Array(buffer); + // Chunked base64-Encoding (RN btoa kann mit großen Strings überlaufen) + let binary = ''; + const chunkSize = 0x8000; + for (let i = 0; i < bytes.length; i += chunkSize) { + binary += String.fromCharCode( + ...bytes.subarray(i, Math.min(i + chunkSize, bytes.length)) + ); + } + // eslint-disable-next-line no-undef + const base64 = global.btoa ? global.btoa(binary) : Buffer.from(binary, 'binary').toString('base64'); + const tmpPath = `${FileSystem.cacheDirectory}lyra-tts-${Date.now()}.mp3`; + await FileSystem.writeAsStringAsync(tmpPath, base64, { + encoding: FileSystem.EncodingType.Base64, + }); + + const { sound } = await Audio.Sound.createAsync({ uri: tmpPath }); + soundRef.current = sound; + setIsSpeaking(true); + sound.setOnPlaybackStatusUpdate((status) => { + if (status.isLoaded && status.didJustFinish) { + setIsSpeaking(false); + scheduleEmotionReset(0); + sound.unloadAsync(); + } + }); + await sound.playAsync(); + } + } else { + console.warn('[tts] backend error:', ttsRes.status, await ttsRes.text()); + } + } catch (err) { + console.warn('[tts] exception:', err); + scheduleEmotionReset(e === 'empathy' ? 6000 : 4000); + } + } catch { + pushMessage({ id: (Date.now() + 1).toString(), role: 'assistant', content: t('coach.error'), isError: true }); + setEmotion('idle'); + } finally { + setThinking(false); + } + } + + function stopSpeaking() { + soundRef.current?.stopAsync(); + soundRef.current?.unloadAsync(); + soundRef.current = null; + setIsSpeaking(false); + setEmotion('idle'); + } + + function startRecordingTimer() { + setRecordingDuration(0); + recordingTimer.current = setInterval(() => setRecordingDuration((d) => d + 1), 1000); + } + function stopRecordingTimer() { + if (recordingTimer.current) clearInterval(recordingTimer.current); + recordingTimer.current = null; + setRecordingDuration(0); + } + + async function onMicDown() { + if (thinking || isTranscribing || isRecording || micHeld.current) return; + if (isSpeaking) stopSpeaking(); + + const { status } = await Audio.requestPermissionsAsync(); + if (status !== 'granted') return; + + micHeld.current = true; + await Audio.setAudioModeAsync({ allowsRecordingIOS: true, playsInSilentModeIOS: true }); + const rec = new Audio.Recording(); + try { + await rec.prepareToRecordAsync(Audio.RecordingOptionsPresets.HIGH_QUALITY); + await rec.startAsync(); + recordingRef.current = rec; + setIsRecording(true); + startRecordingTimer(); + } catch { + micHeld.current = false; + await Audio.setAudioModeAsync({ allowsRecordingIOS: false }); + } + } + + async function cancelRecording() { + if (!isRecording) return; + micHeld.current = false; + stopRecordingTimer(); + setIsRecording(false); + try { + await recordingRef.current?.stopAndUnloadAsync(); + } catch { /* already stopped */ } + recordingRef.current = null; + await Audio.setAudioModeAsync({ allowsRecordingIOS: false }); + } + + async function onMicUp() { + if (!micHeld.current || !isRecording) return; + micHeld.current = false; + stopRecordingTimer(); + setIsRecording(false); + + const rec = recordingRef.current; + recordingRef.current = null; + if (!rec) return; + + try { + await rec.stopAndUnloadAsync(); + } catch { /* already stopped */ } + await Audio.setAudioModeAsync({ allowsRecordingIOS: false }); + + const uri = rec.getURI(); + console.log('[voice] URI after stop:', uri); + if (!uri) return; + + setIsTranscribing(true); + setEmotion('happy'); + + try { + // Backend erwartet base64-Audio in JSON-Body (NICHT FormData): + // { audio: base64String, mimeType: 'audio/m4a', language: 'de' } + const base64 = await FileSystem.readAsStringAsync(uri, { + encoding: FileSystem.EncodingType.Base64, + }); + + const session = (await supabase.auth.getSession()).data.session; + const apiUrl = Constants.expoConfig?.extra?.apiUrl as string; + console.log('[voice] POST', `${apiUrl}/api/coach/transcribe`, 'language:', i18n.language, 'base64-bytes:', base64.length); + + const res = await fetch(`${apiUrl}/api/coach/transcribe`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(session?.access_token ? { Authorization: `Bearer ${session.access_token}` } : {}), + }, + body: JSON.stringify({ + audio: base64, + mimeType: 'audio/m4a', + language: i18n.language, + }), + }); + + const json = await res.json(); + console.log('[voice] transcribe response:', JSON.stringify(json).slice(0, 200)); + const text: string = json?.data?.text ?? json?.text ?? ''; + console.log('[voice] extracted text:', JSON.stringify(text)); + if (text.trim()) { + await handleVoiceSend(text); + } + } catch (err) { + console.warn('[voice] transcribe error:', err); + } finally { + setIsTranscribing(false); + setEmotion((e) => (e === 'happy' ? 'idle' : e)); + } + } + + const handleNewChat = useCallback(async () => { + await clearHistory(); + setEmotion('idle'); + }, [clearHistory]); + + const renderMessage = useCallback( + ({ item }: { item: MessageWithMeta }) => , + [t] + ); + + return ( + + {/* Backdrop hinter topBar — verhindert dass Chat-Texte hinter dem Avatar */} + {/* sichtbar werden ("kollidieren mit Lyra schriftzug"). */} + + + {/* Floating header — no bar, avatar + 2 icon buttons hover over chat */} + + router.replace('/(app)' as never)} hitSlop={12}> + + + + + + + + + {t('coach.lyra')} + {isSpeaking && ( + + + {t('coach.speaking')} + + + + + )} + + + + + + + + + {/* Content area */} + + {isLoading ? ( + + ) : ( + + m.id} + contentContainerStyle={[styles.listContent, { paddingTop: insets.top + 180 }]} + showsVerticalScrollIndicator={false} + onScroll={handleScroll} + scrollEventThrottle={100} + onContentSizeChange={() => { + if (isNearBottomRef.current) { + flatRef.current?.scrollToEnd({ animated: false }); + } + }} + ListFooterComponent={ + thinking ? ( + + + + + + ) : null + } + /> + + {/* Scroll-to-bottom button */} + {showScrollBtn && ( + + + + )} + + )} + + {/* Input bar */} + 0 ? 8 : Math.max(12, insets.bottom) }]}> + {isRecording ? ( + + + + + + {formatDuration(recordingDuration)} + + + + + ) : isTranscribing ? ( + + + {t('coach.transcribing')} + + ) : ( + + )} + + {!isTranscribing && ( + + + + )} + + {!isRecording && !isTranscribing && input.trim() !== '' && ( + + + + )} + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#ffffff', + }, + topBar: { + // Floating-Overlay: schwebt über Chat, kein eigener Header-Block + // top wird inline gesetzt (insets.top + 6) damit Avatar UNTER der Notch sitzt + position: 'absolute', + left: 0, + right: 0, + zIndex: 10, + flexDirection: 'row', + alignItems: 'flex-start', + justifyContent: 'space-between', + paddingHorizontal: 12, + pointerEvents: 'box-none', + }, + topBarBackdrop: { + // Solider Hintergrund unter dem Floating-Avatar — Chat-Messages + // scrollen darunter durch, werden aber nicht mehr sichtbar mit Lyra + // Avatar/Name kollidieren. + position: 'absolute', + top: 0, + left: 0, + right: 0, + zIndex: 9, + backgroundColor: '#ffffff', + }, + backBtn: { + width: 40, + height: 40, + borderRadius: 20, + backgroundColor: 'rgba(255,255,255,0.92)', + alignItems: 'center', + justifyContent: 'center', + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.08, + shadowRadius: 6, + elevation: 4, + }, + avatarCenter: { + flex: 1, + alignItems: 'center', + gap: 4, + }, + avatarMeta: { + alignItems: 'center', + gap: 2, + }, + avatarName: { + fontSize: 15, + fontFamily: 'Nunito_700Bold', + color: '#0a0a0a', + }, + statusLabel: { + fontSize: 11, + fontFamily: 'Nunito_400Regular', + color: colors.textMuted, + }, + speakingRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + }, + speakingLabel: { + fontSize: 11, + fontFamily: 'Nunito_600SemiBold', + }, + stopBtn: { + width: 18, + height: 18, + borderRadius: 9, + backgroundColor: '#f5f5f5', + alignItems: 'center', + justifyContent: 'center', + }, + newChatBtn: { + width: 40, + height: 40, + borderRadius: 20, + backgroundColor: 'rgba(255,255,255,0.92)', + alignItems: 'center', + justifyContent: 'center', + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.08, + shadowRadius: 6, + elevation: 4, + }, + listContent: { + paddingHorizontal: 12, + // Reserve Platz für den Floating-Avatar oben (Avatar 112px + Name + Status + Gaps + Margin) + paddingTop: 210, + paddingBottom: 12, + }, + // Insta-DM row + msgRow: { + flexDirection: 'row', + marginBottom: 4, + alignItems: 'flex-end', + }, + msgRowUser: { + justifyContent: 'flex-end', + }, + msgRowAssistant: { + justifyContent: 'flex-start', + }, + assistantAvatarSlot: { + width: 28, + marginRight: 6, + alignItems: 'center', + justifyContent: 'flex-end', + }, + bubbleCol: { + maxWidth: '75%', + gap: 2, + }, + bubbleColUser: { + alignItems: 'flex-end', + }, + bubbleColAssistant: { + alignItems: 'flex-start', + }, + bubble: { + borderRadius: 20, + paddingHorizontal: 14, + paddingVertical: 9, + }, + bubbleUser: { + backgroundColor: '#007AFF', + borderBottomRightRadius: 4, + }, + bubbleAssistant: { + backgroundColor: '#f0f0f0', + borderBottomLeftRadius: 4, + }, + bubbleText: { + fontSize: 15, + lineHeight: 21, + fontFamily: 'Nunito_400Regular', + }, + bubbleTextUser: { + color: '#ffffff', + }, + bubbleTextAssistant: { + color: '#0a0a0a', + }, + metaRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + paddingHorizontal: 4, + marginBottom: 4, + }, + metaRowUser: { + justifyContent: 'flex-end', + }, + metaRowAssistant: { + justifyContent: 'flex-start', + }, + feedbackText: { + fontSize: 10, + color: '#16a34a', + fontFamily: 'Nunito_400Regular', + }, + timestampText: { + fontSize: 10, + color: '#a3a3a3', + fontFamily: 'Nunito_400Regular', + }, + thinkingRow: { + flexDirection: 'row', + gap: 4, + alignItems: 'center', + paddingVertical: 4, + }, + thinkingDot: { + width: 8, + height: 8, + borderRadius: 4, + backgroundColor: '#d4d4d4', + }, + scrollDownBtn: { + position: 'absolute', + bottom: 12, + right: 16, + width: 36, + height: 36, + borderRadius: 18, + backgroundColor: '#007AFF', + alignItems: 'center', + justifyContent: 'center', + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.2, + shadowRadius: 4, + elevation: 4, + }, + inputBar: { + flexDirection: 'row', + alignItems: 'flex-end', + gap: 8, + paddingHorizontal: 12, + paddingTop: 8, + borderTopWidth: StyleSheet.hairlineWidth, + borderTopColor: '#e5e5e5', + backgroundColor: 'rgba(255,255,255,0.97)', + }, + textInput: { + flex: 1, + backgroundColor: '#f5f5f5', + borderRadius: 22, + paddingVertical: 9, + paddingHorizontal: 16, + fontSize: 15, + fontFamily: 'Nunito_400Regular', + color: '#0a0a0a', + maxHeight: 120, + }, + micBtn: { + width: 38, + height: 38, + borderRadius: 19, + backgroundColor: '#f5f5f5', + alignItems: 'center', + justifyContent: 'center', + }, + micBtnActive: { + backgroundColor: '#dc2626', + transform: [{ scale: 1.1 }], + }, + micBtnDisabled: { + opacity: 0.4, + }, + sendBtn: { + width: 38, + height: 38, + borderRadius: 19, + backgroundColor: '#007AFF', + alignItems: 'center', + justifyContent: 'center', + }, + sendBtnDisabled: { + opacity: 0.4, + }, + recordingContainer: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + gap: 8, + backgroundColor: 'rgba(220,38,38,0.08)', + borderWidth: 1, + borderColor: 'rgba(220,38,38,0.2)', + borderRadius: 22, + paddingHorizontal: 12, + paddingVertical: 8, + }, + cancelBtn: { + width: 32, + height: 32, + borderRadius: 16, + backgroundColor: 'rgba(220,38,38,0.15)', + alignItems: 'center', + justifyContent: 'center', + }, + pulseDot: { + width: 8, + height: 8, + borderRadius: 4, + backgroundColor: '#dc2626', + }, + recordingTimer: { + fontSize: 13, + fontFamily: 'Nunito_600SemiBold', + color: '#f87171', + fontVariant: ['tabular-nums'], + }, + transcribingRow: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 8, + paddingVertical: 10, + }, + transcribingText: { + fontSize: 14, + color: '#737373', + fontFamily: 'Nunito_400Regular', + }, +}); diff --git a/apps/rebreak-native/app/room.tsx b/apps/rebreak-native/app/room.tsx new file mode 100644 index 0000000..612dc33 --- /dev/null +++ b/apps/rebreak-native/app/room.tsx @@ -0,0 +1,856 @@ +import { useState, useEffect, useRef, useCallback } from 'react'; +import { + View, + Text, + FlatList, + Pressable, + Image, + Modal, + TextInput, + ActivityIndicator, + KeyboardAvoidingView, + Platform, + Alert, + StyleSheet, + ScrollView, +} from 'react-native'; +import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; +import { useRouter, useLocalSearchParams } from 'expo-router'; +import { Ionicons } from '@expo/vector-icons'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; +import * as ImagePicker from 'expo-image-picker'; +import * as FileSystem from 'expo-file-system'; +import { apiFetch } from '../lib/api'; +import { supabase } from '../lib/supabase'; +import { ChatBubble, type ChatMsg } from '../components/chat/ChatBubble'; +import { ChatInput, type SendPayload } from '../components/chat/ChatInput'; +import { useRoomRealtime } from '../hooks/useChatRealtime'; +import { colors } from '../lib/theme'; + +const GROUP_GAP_MS = 5 * 60 * 1000; + +type RoomDetail = { + room: { + id: string; + name: string; + description: string | null; + isPublic: boolean; + isDefault: boolean; + joinMode: 'approval' | 'invite_only' | 'open'; + avatarUrl: string | null; + inviteCode: string | null; + memberCount: number; + createdBy: string; + myRole: 'owner' | 'admin' | 'member' | null; + isMember: boolean; + }; + members: Array<{ userId: string; role: string; nickname: string; avatar: string | null }>; + messages: Array; + hasMore: boolean; +}; + +function decodeBase64(b64: string): Uint8Array { + const binary = (globalThis as any).atob + ? (globalThis as any).atob(b64) + : Buffer.from(b64, 'base64').toString('binary'); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + return bytes; +} + +export default function RoomScreen() { + const { t } = useTranslation(); + const router = useRouter(); + const insets = useSafeAreaInsets(); + const queryClient = useQueryClient(); + const flatRef = useRef(null); + const [myUserId, setMyUserId] = useState(); + + const { roomId } = useLocalSearchParams<{ roomId: string }>(); + + const [messages, setMessages] = useState([]); + const [replyTo, setReplyTo] = useState<{ id: string; nickname: string; content: string } | null>( + null, + ); + const [sending, setSending] = useState(false); + const [settingsOpen, setSettingsOpen] = useState(false); + const [joining, setJoining] = useState(false); + const [joinStatus, setJoinStatus] = useState<'joined' | 'pending' | null>(null); + + useEffect(() => { + supabase.auth.getSession().then(({ data }) => setMyUserId(data.session?.user.id)); + }, []); + + const { data, isLoading, refetch } = useQuery({ + queryKey: ['chat-room', roomId], + queryFn: async () => { + const d = await apiFetch(`/api/chat/rooms/${roomId}`); + const msgs: ChatMsg[] = d.messages.map((m: any) => ({ + id: m.id, + userId: m.userId, + nickname: m.nickname, + avatar: m.avatar, + content: m.content, + replyTo: m.replyTo + ? { + id: m.replyTo.id, + userId: m.replyTo.userId, + nickname: m.replyTo.nickname, + content: m.replyTo.content, + attachmentType: m.replyTo.attachmentType ?? null, + } + : null, + attachmentUrl: m.attachmentUrl, + attachmentType: m.attachmentType, + attachmentName: m.attachmentName, + likesCount: m.likesCount ?? 0, + likedByMe: false, + createdAt: m.createdAt, + isOwn: m.isOwn, + })); + setMessages(msgs); + return d; + }, + enabled: !!roomId, + }); + + const room = data?.room; + const members = data?.members ?? []; + const isAdmin = room?.myRole === 'owner' || room?.myRole === 'admin'; + + // Realtime: neue Messages anderer User + const onRoomInsert = useCallback( + (row: any) => { + const sender = members.find((m) => m.userId === row.user_id); + setMessages((prev) => { + if (prev.some((m) => m.id === row.id)) return prev; + return [ + ...prev, + { + id: row.id, + userId: row.user_id, + nickname: sender?.nickname ?? 'Anonym', + avatar: sender?.avatar ?? null, + content: row.content ?? '', + replyTo: null, + attachmentUrl: row.attachment_url ?? null, + attachmentType: row.attachment_type ?? null, + attachmentName: row.attachment_name ?? null, + likesCount: 0, + likedByMe: false, + createdAt: row.created_at, + isOwn: false, + }, + ]; + }); + }, + [members], + ); + useRoomRealtime(roomId, myUserId, onRoomInsert, !!myUserId && !!room?.isMember); + + useEffect(() => { + if (messages.length > 0) { + requestAnimationFrame(() => flatRef.current?.scrollToEnd({ animated: true })); + } + }, [messages.length]); + + async function handleSend(payload: SendPayload) { + if (sending || !room?.isMember) return; + setSending(true); + try { + const newMsg = await apiFetch(`/api/chat/rooms/${roomId}/messages`, { + method: 'POST', + body: payload, + }); + setMessages((prev) => [ + ...prev, + { + id: newMsg.id, + userId: myUserId ?? '', + nickname: 'Du', + avatar: null, + content: newMsg.content, + replyTo: newMsg.replyTo + ? { + id: newMsg.replyTo.id, + userId: newMsg.replyTo.userId, + nickname: newMsg.replyTo.nickname, + content: newMsg.replyTo.content, + attachmentType: newMsg.replyTo.attachmentType ?? null, + } + : null, + attachmentUrl: newMsg.attachmentUrl, + attachmentType: newMsg.attachmentType, + attachmentName: newMsg.attachmentName, + likesCount: 0, + likedByMe: false, + createdAt: newMsg.createdAt, + isOwn: true, + }, + ]); + setReplyTo(null); + } catch (err) { + console.error('Room send failed:', err); + } finally { + setSending(false); + } + } + + async function toggleLike(msg: ChatMsg) { + try { + const { liked } = await apiFetch<{ liked: boolean }>('/api/chat/like', { + method: 'POST', + body: { messageId: msg.id, type: 'chat' }, + }); + setMessages((prev) => + prev.map((m) => + m.id === msg.id + ? { ...m, likedByMe: liked, likesCount: m.likesCount + (liked ? 1 : -1) } + : m, + ), + ); + } catch {} + } + + function startReply(msg: ChatMsg) { + setReplyTo({ + id: msg.id, + nickname: msg.nickname ?? '?', + content: msg.content?.slice(0, 100) || (msg.attachmentType === 'image' ? 'Bild' : ''), + }); + } + + function sameAuthor(a: ChatMsg | undefined, b: ChatMsg | undefined): boolean { + if (!a || !b) return false; + if (a.userId !== b.userId) return false; + return Math.abs(new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) <= GROUP_GAP_MS; + } + + async function handleJoin() { + if (joining) return; + setJoining(true); + try { + const res = await apiFetch<{ status: 'joined' | 'pending' | 'already_member' }>( + `/api/chat/rooms/${roomId}/join`, + { method: 'POST' }, + ); + if (res.status === 'pending') { + setJoinStatus('pending'); + } else { + setJoinStatus('joined'); + await refetch(); + queryClient.invalidateQueries({ queryKey: ['chat-rooms'] }); + } + } catch (err: any) { + Alert.alert('Fehler', err?.message ?? 'Beitritt fehlgeschlagen'); + } finally { + setJoining(false); + } + } + + async function handleAvatarUpload() { + const perm = await ImagePicker.requestMediaLibraryPermissionsAsync(); + if (!perm.granted) return; + const result = await ImagePicker.launchImageLibraryAsync({ + mediaTypes: ['images'], + quality: 0.8, + allowsEditing: true, + aspect: [1, 1], + }); + if (result.canceled || !result.assets[0]) return; + const asset = result.assets[0]; + try { + const ext = asset.uri.split('.').pop()?.toLowerCase() || 'jpg'; + const path = `room-avatars/${roomId}-${Date.now()}.${ext}`; + const b64 = await FileSystem.readAsStringAsync(asset.uri, { + encoding: FileSystem.EncodingType.Base64, + }); + const bytes = decodeBase64(b64); + const { error: upErr } = await supabase.storage + .from('rebreak-public') + .upload(path, bytes, { + contentType: asset.mimeType ?? `image/${ext}`, + upsert: true, + }); + if (upErr) throw upErr; + const { data: pub } = supabase.storage.from('rebreak-public').getPublicUrl(path); + await apiFetch(`/api/chat/rooms/${roomId}`, { + method: 'PATCH', + body: { avatarUrl: pub.publicUrl }, + }); + await refetch(); + queryClient.invalidateQueries({ queryKey: ['chat-rooms'] }); + } catch (err: any) { + Alert.alert('Fehler', err?.message ?? t('chat.upload_failed')); + } + } + + const initials = (room?.name ?? '?') + .split(' ') + .slice(0, 2) + .map((w) => w[0]?.toUpperCase() ?? '') + .join(''); + + return ( + + {/* Header */} + + router.back()} hitSlop={8}> + + + + + {room?.avatarUrl ? ( + + ) : ( + {initials} + )} + + + + {room?.name ?? '…'} + + {room && ( + + {t('chat.member_count', { n: room.memberCount })} + + )} + + + setSettingsOpen(true)} hitSlop={8}> + + + + + {isLoading || !room ? ( + + + + ) : !room.isMember ? ( + + + {room.name} + {room.description && {room.description}} + {t('chat.join_required')} + {joinStatus === 'pending' ? ( + + + {t('chat.join_pending')} + + ) : ( + [ + styles.joinBtn, + { opacity: pressed || joining ? 0.7 : 1 }, + ]} + > + {joining ? ( + + ) : ( + {t('chat.join')} + )} + + )} + + ) : ( + + ( + {}} + /> + )} + keyExtractor={(m) => m.id} + contentContainerStyle={{ paddingTop: 12, paddingBottom: 8 }} + showsVerticalScrollIndicator={false} + onContentSizeChange={() => flatRef.current?.scrollToEnd({ animated: false })} + /> + + setReplyTo(null)} + /> + + + )} + + {/* Settings Modal */} + setSettingsOpen(false)} + room={room} + members={members} + isAdmin={isAdmin} + onAvatarChange={handleAvatarUpload} + onRefetch={refetch} + roomId={roomId} + /> + + ); +} + +// ----- Settings Modal ----- + +function RoomSettingsModal({ + visible, + onClose, + room, + members, + isAdmin, + onAvatarChange, + onRefetch, + roomId, +}: { + visible: boolean; + onClose: () => void; + room: RoomDetail['room'] | undefined; + members: RoomDetail['members']; + isAdmin: boolean; + onAvatarChange: () => void; + onRefetch: () => void; + roomId: string; +}) { + const { t } = useTranslation(); + const [pendingRequests, setPendingRequests] = useState([]); + const [loadingReqs, setLoadingReqs] = useState(false); + + useEffect(() => { + if (!visible || !isAdmin) return; + setLoadingReqs(true); + apiFetch<{ requests: any[] }>(`/api/chat/rooms/${roomId}`, { + method: 'PATCH', + body: { action: 'list_requests' }, + }) + .then((d: any) => setPendingRequests(d?.requests ?? d ?? [])) + .catch(() => setPendingRequests([])) + .finally(() => setLoadingReqs(false)); + }, [visible, isAdmin, roomId]); + + async function handleRequest(userId: string, action: 'approve' | 'reject') { + try { + await apiFetch(`/api/chat/rooms/${roomId}`, { + method: 'PATCH', + body: { action, userId }, + }); + setPendingRequests((prev) => prev.filter((r) => r.userId !== userId)); + onRefetch(); + } catch (err: any) { + Alert.alert('Fehler', err?.message ?? 'Aktion fehlgeschlagen'); + } + } + + async function handlePromote(userId: string) { + try { + await apiFetch(`/api/chat/rooms/${roomId}`, { + method: 'PATCH', + body: { action: 'promote_admin', userId }, + }); + onRefetch(); + } catch (err: any) { + Alert.alert('Fehler', err?.message ?? 'Aktion fehlgeschlagen'); + } + } + + async function handleBan(userId: string) { + Alert.alert('Bannen?', 'User wird aus dem Raum entfernt.', [ + { text: 'Abbrechen', style: 'cancel' }, + { + text: 'Bannen', + style: 'destructive', + onPress: async () => { + try { + await apiFetch(`/api/chat/rooms/${roomId}`, { + method: 'PATCH', + body: { action: 'ban', userId }, + }); + onRefetch(); + } catch (err: any) { + Alert.alert('Fehler', err?.message ?? 'Aktion fehlgeschlagen'); + } + }, + }, + ]); + } + + async function handleLeave() { + Alert.alert(t('chat.leave_room'), '', [ + { text: 'Abbrechen', style: 'cancel' }, + { + text: t('chat.leave_room'), + style: 'destructive', + onPress: async () => { + try { + await apiFetch(`/api/chat/rooms/${roomId}/leave`, { method: 'POST' }); + onClose(); + } catch (err: any) { + Alert.alert('Fehler', err?.message ?? 'Verlassen fehlgeschlagen'); + } + }, + }, + ]); + } + + if (!room) return null; + + return ( + + + + + + + {t('chat.settings')} + + + + + {/* Avatar + Name */} + + + {room.avatarUrl ? ( + + ) : ( + + + + )} + {isAdmin && ( + + + + )} + + {room.name} + {room.description && {room.description}} + + + {/* Pending Requests */} + {isAdmin && ( + + {t('chat.pending_request')} + {loadingReqs ? ( + + ) : pendingRequests.length === 0 ? ( + + ) : ( + pendingRequests.map((req) => ( + + + {req.nickname ?? 'Anonym'} + + handleRequest(req.userId, 'approve')} + > + + {t('chat.approve')} + + + handleRequest(req.userId, 'reject')} + > + + {t('chat.reject')} + + + + )) + )} + + )} + + {/* Members */} + + + {t('chat.members')} ({members.length}) + + {members.map((m) => ( + + + {m.avatar ? ( + + ) : ( + + {m.nickname.slice(0, 2).toUpperCase()} + + )} + + + {m.nickname} + {m.role !== 'member' && ( + {m.role} + )} + + {isAdmin && m.role === 'member' && ( + <> + handlePromote(m.userId)} + > + Admin + + handleBan(m.userId)} + > + Ban + + + )} + + ))} + + + {/* Leave */} + {!room.isDefault && ( + + + {t('chat.leave_room')} + + )} + + + + ); +} + +const styles = StyleSheet.create({ + container: { flex: 1, backgroundColor: '#fafafa' }, + header: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 12, + paddingVertical: 10, + backgroundColor: '#fff', + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: '#e5e5e5', + }, + iconBtn: { + width: 36, + height: 36, + borderRadius: 12, + backgroundColor: '#f5f5f5', + alignItems: 'center', + justifyContent: 'center', + }, + headerCenter: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + marginHorizontal: 8, + }, + headerAvatar: { + width: 36, + height: 36, + borderRadius: 18, + backgroundColor: '#e5e5e5', + alignItems: 'center', + justifyContent: 'center', + overflow: 'hidden', + marginRight: 8, + }, + headerAvatarImg: { width: 36, height: 36 }, + headerAvatarInitials: { + fontSize: 12, + fontFamily: 'Nunito_700Bold', + color: '#737373', + }, + headerName: { + fontSize: 15, + fontFamily: 'Nunito_700Bold', + color: '#171717', + }, + headerSub: { + fontSize: 11, + fontFamily: 'Nunito_500Medium', + color: '#737373', + marginTop: 1, + }, + loadingBox: { flex: 1, alignItems: 'center', justifyContent: 'center' }, + joinBox: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + paddingHorizontal: 32, + }, + joinTitle: { + fontSize: 20, + fontFamily: 'Nunito_700Bold', + color: '#171717', + marginTop: 14, + }, + joinDesc: { + fontSize: 13, + fontFamily: 'Nunito_500Medium', + color: '#737373', + marginTop: 6, + textAlign: 'center', + }, + joinHint: { + fontSize: 12, + fontFamily: 'Nunito_500Medium', + color: '#a3a3a3', + marginTop: 18, + textAlign: 'center', + }, + joinBtn: { + marginTop: 16, + backgroundColor: '#007AFF', + paddingHorizontal: 32, + paddingVertical: 12, + borderRadius: 12, + minWidth: 140, + alignItems: 'center', + }, + joinBtnText: { + color: '#fff', + fontSize: 14, + fontFamily: 'Nunito_700Bold', + }, + pendingBadge: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: '#fef3c7', + paddingHorizontal: 14, + paddingVertical: 8, + borderRadius: 20, + marginTop: 16, + }, + pendingText: { + color: '#92400e', + fontSize: 12, + fontFamily: 'Nunito_700Bold', + marginLeft: 6, + }, +}); + +const modal = StyleSheet.create({ + container: { flex: 1, backgroundColor: '#fafafa' }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 16, + paddingVertical: 12, + backgroundColor: '#fff', + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: '#e5e5e5', + }, + title: { fontSize: 16, fontFamily: 'Nunito_700Bold', color: '#171717' }, + section: { + backgroundColor: '#fff', + borderRadius: 12, + padding: 14, + marginBottom: 12, + }, + sectionTitle: { + fontSize: 12, + fontFamily: 'Nunito_700Bold', + color: '#737373', + textTransform: 'uppercase', + marginBottom: 10, + letterSpacing: 0.5, + }, + avatarWrap: { alignSelf: 'center', marginBottom: 10 }, + avatar: { width: 80, height: 80, borderRadius: 40 }, + avatarPlaceholder: { + backgroundColor: '#e5e5e5', + alignItems: 'center', + justifyContent: 'center', + }, + avatarEdit: { + position: 'absolute', + right: -2, + bottom: -2, + width: 28, + height: 28, + borderRadius: 14, + backgroundColor: '#007AFF', + borderWidth: 3, + borderColor: '#fff', + alignItems: 'center', + justifyContent: 'center', + }, + roomName: { + fontSize: 17, + fontFamily: 'Nunito_700Bold', + color: '#171717', + textAlign: 'center', + }, + roomDesc: { + fontSize: 12, + fontFamily: 'Nunito_500Medium', + color: '#737373', + textAlign: 'center', + marginTop: 4, + }, + memberRow: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 8, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: '#f5f5f5', + }, + memberAvatar: { + width: 32, + height: 32, + borderRadius: 16, + backgroundColor: '#e5e5e5', + alignItems: 'center', + justifyContent: 'center', + overflow: 'hidden', + marginRight: 10, + }, + memberAvatarImg: { width: 32, height: 32 }, + memberInitials: { + fontSize: 11, + fontFamily: 'Nunito_700Bold', + color: '#737373', + }, + memberName: { fontSize: 13, fontFamily: 'Nunito_600SemiBold', color: '#171717' }, + memberRole: { fontSize: 11, color: '#a3a3a3', marginTop: 1, textTransform: 'capitalize' }, + actionBtn: { + paddingHorizontal: 10, + paddingVertical: 5, + borderRadius: 6, + }, + actionText: { fontSize: 11, fontFamily: 'Nunito_700Bold' }, + emptyText: { fontSize: 12, color: '#a3a3a3' }, + leaveBtn: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + backgroundColor: '#fee2e2', + paddingVertical: 12, + borderRadius: 10, + marginTop: 8, + }, + leaveText: { + color: '#991b1b', + fontSize: 13, + fontFamily: 'Nunito_700Bold', + marginLeft: 6, + }, +}); diff --git a/apps/rebreak-native/app/settings.tsx b/apps/rebreak-native/app/settings.tsx new file mode 100644 index 0000000..6dd9642 --- /dev/null +++ b/apps/rebreak-native/app/settings.tsx @@ -0,0 +1,222 @@ +import { ScrollView, View, Text, Pressable, Switch } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { useRouter } from 'expo-router'; +import { Ionicons } from '@expo/vector-icons'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useAuthStore } from '../stores/auth'; +import { Card } from '../components/Card'; +import { Button } from '../components/Button'; +import { colors } from '../lib/theme'; + +type SettingRow = { + label: string; + sublabel?: string; + icon: React.ComponentProps['name']; + iconColor: string; + onPress?: () => void; + right?: React.ReactNode; +}; + +export default function SettingsScreen() { + const router = useRouter(); + const { t } = useTranslation(); + const { user, signOut } = useAuthStore(); + const [notifPush, setNotifPush] = useState(true); + const [notifStreak, setNotifStreak] = useState(true); + + const email = user?.email ?? ''; + const initials = email.slice(0, 2).toUpperCase(); + + async function handleSignOut() { + await signOut(); + router.replace('/'); + } + + const accountRows: SettingRow[] = [ + { + label: t('settings.edit_profile'), + icon: 'pencil-outline', + iconColor: '#6366f1', + onPress: () => {}, + }, + { + label: t('settings.devices'), + sublabel: t('settings.devices_desc'), + icon: 'phone-portrait-outline', + iconColor: '#16a34a', + onPress: () => {}, + }, + { + label: t('settings.subscription'), + sublabel: t('settings.plan_free'), + icon: 'star-outline', + iconColor: colors.brandOrange, + onPress: () => {}, + }, + ]; + + const prefRows: SettingRow[] = [ + { + label: t('settings.push_notifications'), + icon: 'notifications-outline', + iconColor: '#2563eb', + right: ( + + ), + }, + { + label: t('settings.streak_reminders'), + icon: 'flame-outline', + iconColor: '#f97316', + right: ( + + ), + }, + { + label: t('settings.language'), + sublabel: t('settings.language_current'), + icon: 'language-outline', + iconColor: '#a78bfa', + onPress: () => {}, + }, + ]; + + return ( + + + router.replace('/(app)' as never)} + hitSlop={8} + className="w-10 h-10 items-center justify-center" + style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })} + > + + + {t('settings.title')} + + + + {/* Account Card */} + + + + {initials} + + + + {email} + + + + {t('settings.plan_free')} + + + + + + + + + {/* Account Section */} + + {t('settings.account_section')} + + + {accountRows.map((row, i) => ( + ({ opacity: pressed ? 0.7 : 1 })} + > + + + + + {row.label} + {row.sublabel ? ( + {row.sublabel} + ) : null} + + {row.right ?? ( + + )} + + ))} + + + {/* Preferences Section */} + + {t('settings.prefs_section')} + + + {prefRows.map((row, i) => ( + + + + + + {row.label} + {row.sublabel ? ( + {row.sublabel} + ) : null} + + {row.right ?? ( + + + + )} + + ))} + + + {/* Danger Zone */} + + {t('settings.danger_section')} + + + + + {t('settings.delete_desc')} + + + + + + + ); +} diff --git a/apps/rebreak-native/app/urge.tsx b/apps/rebreak-native/app/urge.tsx new file mode 100644 index 0000000..0c64f88 --- /dev/null +++ b/apps/rebreak-native/app/urge.tsx @@ -0,0 +1,1261 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { + View, Text, TextInput, FlatList, Pressable, Platform, Animated, + Keyboard, KeyboardAvoidingView, StyleSheet, NativeSyntheticEvent, + NativeScrollEvent, ActivityIndicator, AppState, +} from 'react-native'; +import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; +import { useRouter } from 'expo-router'; +import { Ionicons } from '@expo/vector-icons'; +import { Audio, InterruptionModeAndroid, InterruptionModeIOS } from 'expo-av'; +import * as FileSystem from 'expo-file-system'; +import Constants from 'expo-constants'; +import { useTranslation } from 'react-i18next'; +import { RiveAvatar } from '../components/RiveAvatar'; +import { apiFetch } from '../lib/api'; +import { supabase } from '../lib/supabase'; +import { colors } from '../lib/theme'; +import { + type GameType, GAME_META, MemoryGame, TicTacToeGame, SnakeGame, TetrisGame, +} from '../components/urge/UrgeGames'; +import { SosFeedbackModal, type SosFeedback } from '../components/urge/SosFeedbackModal'; +import { ShareSuccessDrawer } from '../components/urge/ShareSuccessDrawer'; +import { InlineRatingDrawer } from '../components/urge/InlineRatingDrawer'; +import { BreathingDrawer } from '../components/urge/Breathing'; +import GamePickerDrawer from '../components/urge/GamePickerDrawer'; +import { VoiceBars } from '../components/urge/InlineIndicators'; +import MessageRow, { GameHeader, type SosMsg } from '../components/urge/MessageRow'; +import { SOS_BOOT } from '../lib/sosPrompts'; +import { CHIP_SETS, type ChipSet } from '../lib/sosConstants'; +import { parseLyraResponse, detectEmotion, type LyraEmotion, type ChipSpec } from '../lib/lyraResponse'; +import { streamSosLyra } from '../lib/sosStream'; +import { SosTtsQueue } from '../lib/sosTtsQueue'; +import { endpointForProvider, useTtsProvider, type TtsProvider } from '../lib/ttsProvider'; +import { TtsProviderToggle } from '../components/urge/TtsProviderToggle'; + +// ── Main Screen ─────────────────────────────────────────────────────────────── + +export default function SOSScreen() { + const { t, i18n } = useTranslation(); + const router = useRouter(); + const insets = useSafeAreaInsets(); + const flatRef = useRef(null); + + const [messages, setMessages] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [isBreathing, setIsBreathing] = useState(false); + const [input, setInput] = useState(''); + const [thinking, setThinking] = useState(false); + const [emotion, setEmotion] = useState('idle'); + const [isSpeaking, setIsSpeaking] = useState(false); + const [isTtsLoading, setIsTtsLoading] = useState(false); + const [soundEnabled, setSoundEnabled] = useState(true); + const soundEnabledRef = useRef(true); + const [chipSet, setChipSet] = useState('start'); + const [dynamicChips, setDynamicChips] = useState([]); + const [userTurnCount, setUserTurnCount] = useState(0); + + // ——— Session-Tracking für DiGA ——— + const sessionStartRef = useRef(new Date()); + const breathingCountRef = useRef(0); + const gamesPlayedRef = useRef>([]); + const wasOvercomeRef = useRef(false); + const sessionSavedRef = useRef(false); + // Hint-Trigger: User klickt "weiter reden" nach Atmen/Spiel + const requestStatsReminderRef = useRef(false); + // Hint-Trigger: nach Atmen/Spiel → Lyra soll Check-in machen + const requestCheckInRef = useRef<'after_breathing' | 'after_game' | null>(null); + // Hint-Trigger: nach Überwinden → Lyra soll zu Share/Rate motivieren + const requestOvercomeMotivationRef = useRef(false); + // Game-Score-Tracking: PB vor dem aktuellen Spielstart (für Vergleich nach Spiel-Ende) + const pbBeforeGameRef = useRef(0); + // Hint-Trigger: nach Spiel-Ende mit neuem Personal-Best → Lyra soll feiern + const requestNewPbCelebrationRef = useRef<{ game: string; oldScore: number; newScore: number } | null>(null); + + // Exit-Feedback-Modal + const [feedbackVisible, setFeedbackVisible] = useState(false); + const exitingRef = useRef(false); + // Inline-Feedback (Drawer in der Chat-Session) — wenn gegeben, kein Exit-Modal mehr + const inlineFeedbackRef = useRef(null); + const [ratingDrawerVisible, setRatingDrawerVisible] = useState(false); + // Share-Success Drawer + const [shareDrawerVisible, setShareDrawerVisible] = useState(false); + const [shareDraft, setShareDraft] = useState(''); + const [shareGenerating, setShareGenerating] = useState(false); + const sharePostedRef = useRef(false); + const [isPickingGame, setIsPickingGame] = useState(false); + const [breathingDone, setBreathingDone] = useState(false); + const [playingGame, setPlayingGame] = useState(null); + const [keyboardHeight, setKeyboardHeight] = useState(0); + const [showScrollBtn, setShowScrollBtn] = useState(false); + + const ttsRef = useRef(null); + const ttsAbortRef = useRef(null); + // Phase B: sentence-level streaming TTS — eine Queue pro sendToLyra-Call. + const ttsQueueRef = useRef(null); + const emotionTimer = useRef | null>(null); + const isNearBottomRef = useRef(true); + + useEffect(() => { soundEnabledRef.current = soundEnabled; }, [soundEnabled]); + + // Aktueller TTS-Provider — Ref damit async-Code (sendToLyra) den frischen Wert + // sieht ohne stale-closure aus dem ursprünglichen Render. + const [ttsProvider] = useTtsProvider(); + const ttsProviderRef = useRef(ttsProvider); + useEffect(() => { ttsProviderRef.current = ttsProvider; }, [ttsProvider]); + + // Audio-Mode: bei SOS-Mount Audio-Session konfigurieren. + // - playsInSilentModeIOS: Lyra spricht auch wenn iPhone auf "stumm" + // - shouldDuckAndroid: andere Audio-Quellen (Spotify) leiser machen wenn Lyra spricht + // - staysActiveInBackground: false → OS pausiert TTS automatisch wenn App im Background + // Plus warm-up: Audio.Sound.createAsync hat auf Android ~500ms Cold-Start. + // Wir feuern einen no-op-load + unload damit ExoPlayer warm ist bevor Lyra spricht. + useEffect(() => { + Audio.setAudioModeAsync({ + allowsRecordingIOS: false, + playsInSilentModeIOS: true, + staysActiveInBackground: false, + interruptionModeIOS: InterruptionModeIOS.DoNotMix, + shouldDuckAndroid: true, + interruptionModeAndroid: InterruptionModeAndroid.DoNotMix, + playThroughEarpieceAndroid: false, + }).catch((err) => { + console.warn('[sos-audio-mode] failed:', err); + }); + }, []); + + // Cleanup-Effect: bei Component-Unmount (router.back, hardware-back, replace, + // ...) ALLE TTS-Resources freigeben — damit Lyra nicht im Background weiter + // redet wenn der User die SOS-Page verlässt. User-Bug 2026-05-04 auf A50. + useEffect(() => { + return () => { + ttsAbortRef.current?.abort(); + ttsAbortRef.current = null; + if (ttsRef.current) { + const s = ttsRef.current; + ttsRef.current = null; + s.stopAsync().catch(() => {}); + s.unloadAsync().catch(() => {}); + } + ttsQueueRef.current?.abort(); + ttsQueueRef.current = null; + if (emotionTimer.current) { + clearTimeout(emotionTimer.current); + emotionTimer.current = null; + } + }; + }, []); + + // AppState: wenn App in den Background geht (Home-Button, App-Switcher, + // Lock-Screen) → TTS stoppen. Sonst spricht Lyra durch's Lock-Screen weiter. + useEffect(() => { + const sub = AppState.addEventListener('change', (state) => { + if (state === 'background' || state === 'inactive') { + ttsAbortRef.current?.abort(); + ttsAbortRef.current = null; + if (ttsRef.current) { + const s = ttsRef.current; + ttsRef.current = null; + s.stopAsync().catch(() => {}); + s.unloadAsync().catch(() => {}); + } + ttsQueueRef.current?.abort(); + ttsQueueRef.current = null; + setIsSpeaking(false); + setIsTtsLoading(false); + } + }); + return () => sub.remove(); + }, []); + + useEffect(() => { + const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow'; + const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide'; + const show = Keyboard.addListener(showEvent, (e) => setKeyboardHeight(e.endCoordinates.height)); + const hide = Keyboard.addListener(hideEvent, () => setKeyboardHeight(0)); + return () => { show.remove(); hide.remove(); }; + }, []); + + useEffect(() => { + if (messages.length === 0) return; + if (isNearBottomRef.current) requestAnimationFrame(() => flatRef.current?.scrollToEnd({ animated: true })); + else setShowScrollBtn(true); + }, [messages.length, thinking, isBreathing]); + + function handleScroll(e: NativeSyntheticEvent) { + const { layoutMeasurement, contentOffset, contentSize } = e.nativeEvent; + const dist = contentSize.height - contentOffset.y - layoutMeasurement.height; + isNearBottomRef.current = dist < 80; + if (isNearBottomRef.current) setShowScrollBtn(false); + } + + function addMessage(msg: SosMsg) { setMessages((prev) => [...prev, msg]); } + function scheduleEmotionReset(delay = 4000) { + if (emotionTimer.current) clearTimeout(emotionTimer.current); + emotionTimer.current = setTimeout(() => setEmotion('idle'), delay); + } + + function stopSpeaking() { + ttsAbortRef.current?.abort(); + ttsAbortRef.current = null; + ttsRef.current?.stopAsync().catch(() => {}); + ttsRef.current?.unloadAsync().catch(() => {}); + ttsRef.current = null; + // Phase B: laufende Sentence-Queue auch abbrechen + ttsQueueRef.current?.abort(); + ttsQueueRef.current = null; + setIsSpeaking(false); setIsTtsLoading(false); setEmotion('idle'); + } + + // Holt Audio-MP3 von speak-openai und speichert als temporäre Datei + async function fetchTtsAudio(rawText: string): Promise<{ uri: string; controller: AbortController } | null> { + if (!soundEnabledRef.current) return null; + const text = rawText.replace(/\s+/g, ' ').trim(); + if (!text) return null; + const controller = new AbortController(); + const session = (await supabase.auth.getSession()).data.session; + if (controller.signal.aborted) return null; + const apiBase = Constants.expoConfig?.extra?.apiUrl as string; + const ttsRes = await fetch(`${apiBase}/api/coach/speak-openai`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(session?.access_token ? { Authorization: `Bearer ${session.access_token}` } : {}), + }, + body: JSON.stringify({ text, locale: i18n.language, mode: 'sos' }), + signal: controller.signal, + }); + if (!ttsRes.ok || controller.signal.aborted) return null; + const buffer = await ttsRes.arrayBuffer(); + if (controller.signal.aborted || buffer.byteLength === 0) return null; + const bytes = new Uint8Array(buffer); + const chunks: string[] = []; + const cs = 0x8000; + for (let i = 0; i < bytes.length; i += cs) + chunks.push(String.fromCharCode(...bytes.subarray(i, Math.min(i + cs, bytes.length)))); + const base64 = btoa(chunks.join('')); + const tmpPath = `${FileSystem.cacheDirectory}sos-tts-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.mp3`; + await FileSystem.writeAsStringAsync(tmpPath, base64, { encoding: FileSystem.EncodingType.Base64 }); + if (controller.signal.aborted) return null; + return { uri: tmpPath, controller }; + } + + // Spielt eine fertige Audio-Datei (oder fetcht sie wenn nicht vorhanden) und wartet auf didJustFinish + async function playTtsAudio(rawText: string, prefetched: { uri: string; controller: AbortController } | null): Promise { + if (!soundEnabledRef.current) return; + // Bestehenden Audio NICHT abbrechen — wir wollen nahtlos anschließen + setIsTtsLoading(!prefetched); + let audio = prefetched; + if (!audio) { + try { audio = await fetchTtsAudio(rawText); } catch { audio = null; } + } + if (!audio) { setIsTtsLoading(false); return; } + ttsAbortRef.current = audio.controller; + try { + const { sound } = await Audio.Sound.createAsync({ uri: audio.uri }, { shouldPlay: true }); + if (audio.controller.signal.aborted) { sound.unloadAsync(); setIsTtsLoading(false); return; } + setIsTtsLoading(false); + ttsRef.current = sound; + setIsSpeaking(true); + await new Promise((resolve) => { + const aborted = () => { resolve(); }; + audio!.controller.signal.addEventListener('abort', aborted); + sound.setOnPlaybackStatusUpdate((status) => { + if (status.isLoaded && status.didJustFinish) { + setIsSpeaking(false); + scheduleEmotionReset(0); + sound.unloadAsync().catch(() => {}); + audio!.controller.signal.removeEventListener('abort', aborted); + resolve(); + } + }); + }); + } catch (err: any) { + if (err?.name === 'AbortError' || audio.controller.signal.aborted) { setIsTtsLoading(false); return; } + console.warn('[sos-tts]', err); + setIsTtsLoading(false); + setIsSpeaking(false); + } + } + + // Legacy: einmaliger TTS-Call (für nicht-Streaming Pfade z.B. opening greeting) + async function speakText(rawText: string): Promise { + if (!soundEnabledRef.current) return; + ttsAbortRef.current?.abort(); + if (ttsRef.current) { + ttsRef.current.stopAsync().catch(() => {}); + ttsRef.current.unloadAsync().catch(() => {}); + ttsRef.current = null; + setIsSpeaking(false); + } + const audio = await fetchTtsAudio(rawText).catch(() => null); + if (!audio) return; + await playTtsAudio(rawText, audio); + } + + async function sendToLyra(userText: string) { + if (thinking) return; + if (isSpeaking) stopSpeaking(); + addMessage({ id: Date.now().toString(), role: 'user', content: userText, timestamp: new Date() }); + setUserTurnCount((n) => n + 1); + setThinking(true); setEmotion('thinking'); + try { + const visibleHistory = messages.filter((m) => !m.cardType).map((m) => ({ role: m.role, content: m.content })); + + // ----- Hint-Builder ----- + const hints: string[] = []; + + // (1) Check-in nach Atmen/Spiel + const checkIn = requestCheckInRef.current; + requestCheckInRef.current = null; + if (checkIn === 'after_breathing') { + hints.push('[SYSTEM-HINT] Der User hat gerade die Atemübung beendet. Frage warm nach, wie er sich JETZT fühlt. Biete 3-4 Chips: "🫁 Nochmal atmen", "🎮 Spiel", "💭 An was anderes denken", "😄 Erzähl mir einen Witz".'); + } else if (checkIn === 'after_game') { + hints.push('[SYSTEM-HINT] Der User hat gerade das Spiel beendet. Frage warm, wie er sich JETZT fühlt. Biete 3-4 Chips: "🎮 Weiter spielen", "🫁 Atemübung", "💭 An was anderes denken", "😄 Erzähl mir einen Witz".'); + } + + // (1b) Personal-Best-Celebration — wenn User gerade neuen PB gemacht hat + if (requestNewPbCelebrationRef.current) { + const { game, oldScore, newScore } = requestNewPbCelebrationRef.current; + requestNewPbCelebrationRef.current = null; + const pbHint = oldScore > 0 + ? `[SYSTEM-HINT] NEUER REKORD! Der User hat sein bisheriges Personal-Best in ${game} (${oldScore}) auf ${newScore} verbessert. Feiere das warm in 1-2 Sätzen — nenne KONKRET die Zahlen ${oldScore} → ${newScore}. Mach klar: das ist sein eigener Sieg, ein echter Schritt. Dann der normale after_game-Check-in (siehe oben).` + : `[SYSTEM-HINT] Erster Score! Der User hat seinen ALLERSTEN Score in ${game}: ${newScore}. Feiere den Erstversuch warm — der erste Score ist immer was Besonderes. Dann der normale after_game-Check-in.`; + hints.push(pbHint); + } + + // (2) Stats-Reminder bei "weiter reden" nach Aktion + if (requestStatsReminderRef.current) { + requestStatsReminderRef.current = false; + const b = breathingCountRef.current; + const g = gamesPlayedRef.current.length; + const parts: string[] = []; + if (b > 0) parts.push(`${b}x Atemübung`); + if (g > 0) parts.push(`${g}x Spiel`); + const stats = parts.join(' + ') || 'mehrere Bewältigungs-Tools'; + hints.push(`[SYSTEM-HINT] Erinnere den User WARM und KONKRET: Wir haben in dieser Session schon ${stats} gemacht. Das senkt wissenschaftlich nachweislich den Spielimpuls (z.B. Atemübungen aktivieren den Parasympathikus). Lobe ihn: er hat die Gambling-Industrie heute schon geschlagen. Dann frage, was er jetzt braucht. Chips: "❤️ Überwunden", "🫁 Nochmal atmen", "🎮 Spiel".`); + } + + // (3) Nach 3+ Turns: Service-Angebot wenn Lyra noch nicht angeboten hat + if ((userTurnCount + 1) >= 3 && breathingCountRef.current === 0 && gamesPlayedRef.current.length === 0) { + hints.push('[SYSTEM-HINT] Ich habe jetzt schon mehrere Male geantwortet. Biete mir JETZT konkret eine Atemübung ODER ein Spiel an. Chips müssen "breathing" + "game_picker" enthalten, optional "send_text:Lass uns weiter reden".'); + } + + // (4) Nach Überwinden → Lyra motiviert zu Sharing + Bewertung + if (requestOvercomeMotivationRef.current) { + requestOvercomeMotivationRef.current = false; + hints.push( + '[SYSTEM-HINT] Der User hat den Impuls ÜBERWUNDEN. Feiere ihn warm und KURZ (2-3 Sätze max). Dann motiviere ihn zu ZWEI konkreten Aktionen: ' + + '(a) Erfolg mit der Community teilen — das stärkt andere Betroffene und macht ihn stolz. ' + + '(b) Diese Session bewerten + kurze Bemerkung geben — das hilft uns Lyra zu verbessern und die App für DiGA-Zertifizierung qualitativ besser zu machen. ' + + 'WICHTIG: Liefere GENAU diese Chips: ' + + '[{"label":"✨ Erfolg teilen","action":"share_success"}, {"label":"⭐ Session bewerten","action":"rate_session"}, {"label":"✅ Fertig","action":"close"}]' + ); + } + + const hintMsgs = hints.map((h) => ({ role: 'user' as const, content: h })); + const apiMessages = [...SOS_BOOT, ...visibleHistory, { role: 'user' as const, content: userText }, ...hintMsgs]; + + // ── SSE-Streaming via react-native-sse ── + const session = (await supabase.auth.getSession()).data.session; + const apiBase = Constants.expoConfig?.extra?.apiUrl as string; + + if (!session?.access_token) throw new Error('no token'); + + const assistantId = (Date.now() + 1).toString(); + let assistantAdded = false; + const ensureBubble = (text: string) => { + if (!assistantAdded) { + assistantAdded = true; + addMessage({ id: assistantId, role: 'assistant', content: text, timestamp: new Date() }); + } else { + setMessages((prev) => prev.map((m) => (m.id === assistantId ? { ...m, content: text } : m))); + } + }; + + let visible = ''; + let parsedChips: Array<{ label: string; action: string }> = []; + let streamError: any = null; + // Hybrid-TTS v3 (Threshold 3): warte auf 3 Sätze bevor first-chunk + // ans TTS geht. Mit prompt "max 3 Sätze" sind 99% aller Antworten + // single-shot → NULL Voice-Boundary, 100% konsistente Stimme. + // Trade-off: ~1s mehr First-Audio-Latenz im 3-Satz-Fall vs. v2. + const firstChunkSentences: string[] = []; + let firstChunkConsumed = false; + let firstChunkText = ''; + + // Hybrid-TTS-Queue: erster Satz live + Rest als ein Block. Pre-fetch in + // der Queue startet sofort beim enqueue → läuft parallel zum Playback + // des ersten Satzes → null Gap zwischen Satz-1 und Rest. + ttsQueueRef.current?.abort(); + const ttsQueue = soundEnabledRef.current + ? new SosTtsQueue({ + apiBase, + accessToken: session.access_token, + locale: i18n.language, + endpoint: endpointForProvider(ttsProviderRef.current), + onStart: () => { setIsSpeaking(true); setIsTtsLoading(false); }, + onIdle: () => { setIsSpeaking(false); setIsTtsLoading(false); scheduleEmotionReset(0); }, + onError: (err, sentence) => { + console.warn('[sos-tts-queue] segment failed:', sentence.slice(0, 50), err); + }, + }) + : null; + ttsQueueRef.current = ttsQueue; + if (ttsQueue) setIsTtsLoading(true); + + await new Promise((resolve) => { + let cancel: (() => void) | undefined; + streamSosLyra({ + apiBase, + token: session.access_token, + messages: apiMessages, + locale: i18n.language, + onTextUpdate: (full) => { + visible = full; + ensureBubble(full); + }, + onChips: (chips) => { + parsedChips = chips; + }, + onSentence: (sentence) => { + if (firstChunkConsumed) return; + firstChunkSentences.push(sentence); + // Trigger erst bei 3 vollständigen Sätzen (war v2: 2). Mit Server- + // Prompt "max 3 Sätze" landen 99% aller Antworten als single-shot + // im onDone (count<3) → NULL Boundary, 100% konsistente Stimme. + // Boundary nur bei seltenen 4+-Satz-Antworten. + if (firstChunkSentences.length >= 2) { + firstChunkConsumed = true; + firstChunkText = firstChunkSentences.join(' '); + ttsQueue?.enqueue(firstChunkText); + } + }, + onDone: (full) => { + visible = full || visible; + ensureBubble(visible); + if (ttsQueue) { + if (firstChunkConsumed) { + // Rest = full minus first-chunk. Fester Match via indexOf. + const idx = full.indexOf(firstChunkText); + const rest = + idx >= 0 + ? full.slice(idx + firstChunkText.length).trim() + : full.replace(firstChunkText, '').trim(); + if (rest.length > 0) { + // Mode 'sos-continuation' → server gibt OpenAI explizite + // "no fresh-start"-Instructions → Boundary weicher. + ttsQueue.enqueue(rest, 'sos-continuation'); + } + } else { + // <2 Sätze detektiert (kurze Antwort) — kompletten Text als + // single-shot. Voice 100% konsistent, kein Boundary überhaupt. + const cleaned = (full || visible).trim(); + if (cleaned) ttsQueue.enqueue(cleaned); + } + } + resolve(); + }, + onError: (err) => { + streamError = err; + resolve(); + }, + }) + .then((c) => { + cancel = c; + }) + .catch((err) => { + streamError = err; + resolve(); + }); + }); + + // Fallback bei Stream-Fehler: alter non-streaming Endpoint + if (streamError) { + console.warn('[sos-stream] failed, fallback', streamError); + // Queue abbrechen damit nicht halb-gefüllter Stream noch Audio spielt + ttsQueueRef.current?.abort(); + ttsQueueRef.current = null; + const res = await apiFetch<{ message: string }>('/api/coach/message', { + method: 'POST', + body: { messages: apiMessages, locale: i18n.language, sosMode: true }, + }); + const parsed = parseLyraResponse(res?.message ?? ''); + visible = parsed.message; + parsedChips = parsed.chips; + ensureBubble(visible); + if (visible) speakText(visible); + } + // Wenn Stream erfolgreich war, Queue aber leer geblieben (z.B. Stream hat + // 0 Sätze geliefert, Edge-Case bei sehr kurzer LLM-Antwort) → Loading-Flag + // selbst zurücksetzen, sonst hängt der Spinner. + if (!streamError && ttsQueue && !ttsQueue.isActive()) { + setIsTtsLoading(false); + } + + const e = detectEmotion(visible); + + // Fallback-Chips wenn Lyra keine liefert + let finalChips = parsedChips; + if (finalChips.length === 0) { + if (checkIn === 'after_breathing') { + finalChips = [ + { label: '🫁 Nochmal atmen', action: 'breathing' }, + { label: '🎮 Spiel', action: 'game_picker' }, + { label: '💭 An was anderes denken', action: 'send_text:Lenk mich bitte ab — erzähl mir was Schönes.' }, + { label: '❤️ Überwunden', action: 'overcome' }, + ]; + } else if (checkIn === 'after_game') { + finalChips = [ + { label: '🎮 Weiter spielen', action: 'game_picker' }, + { label: '🫁 Atemübung', action: 'breathing' }, + { label: '😄 Erzähl mir einen Witz', action: 'send_text:Erzähl mir bitte einen kurzen Witz.' }, + { label: '❤️ Überwunden', action: 'overcome' }, + ]; + } else if ((userTurnCount + 1) >= 3) { + finalChips = [ + { label: '🫁 Atemübung', action: 'breathing' }, + { label: '🎮 Spiel', action: 'game_picker' }, + { label: '💬 Weiter reden', action: 'send_text:Lass uns weiter reden.' }, + ]; + } else { + finalChips = [ + { label: '💬 Mehr erzählen', action: 'send_text:Ich möchte mehr erzählen.' }, + { label: '🫁 Atemübung', action: 'breathing' }, + { label: '🎮 Ablenken', action: 'game_picker' }, + ]; + } + } + setDynamicChips(finalChips); + setChipSet('none'); + setEmotion(e); scheduleEmotionReset(e === 'empathy' ? 6000 : 4000); + } catch { + addMessage({ id: (Date.now() + 1).toString(), role: 'assistant', content: t('coach.error'), timestamp: new Date() }); + setEmotion('idle'); + } finally { setThinking(false); } + } + + // Opening greeting on mount — nutzt gleichen Streaming-Pfad wie sendToLyra, + // damit Hybrid-TTS auch beim Start greift (sonst dauerte es 4-5s bis Lyra + // sprach, weil non-streaming /api/coach/message + dann separater TTS-Call). + useEffect(() => { + let cancelled = false; + async function openGreeting() { + setIsLoading(true); setEmotion('thinking'); setThinking(true); + const greetingId = 'greeting-' + Date.now(); + let assistantAdded = false; + const ensureBubble = (text: string) => { + if (!assistantAdded) { + assistantAdded = true; + addMessage({ id: greetingId, role: 'assistant', content: text, timestamp: new Date() }); + } else { + setMessages((prev) => prev.map((m) => (m.id === greetingId ? { ...m, content: text } : m))); + } + }; + try { + const session = (await supabase.auth.getSession()).data.session; + if (cancelled) return; + if (!session?.access_token) throw new Error('no token'); + const apiBase = Constants.expoConfig?.extra?.apiUrl as string; + + // Hybrid-TTS-Queue, gleiches Pattern wie sendToLyra + const ttsQueue = soundEnabledRef.current + ? new SosTtsQueue({ + apiBase, + accessToken: session.access_token, + locale: i18n.language, + endpoint: endpointForProvider(ttsProviderRef.current), + onStart: () => { setIsSpeaking(true); setIsTtsLoading(false); }, + onIdle: () => { setIsSpeaking(false); setIsTtsLoading(false); scheduleEmotionReset(0); }, + onError: (err, sentence) => { + console.warn('[sos-tts-greeting] segment failed:', sentence.slice(0, 50), err); + }, + }) + : null; + ttsQueueRef.current?.abort(); + ttsQueueRef.current = ttsQueue; + if (ttsQueue) setIsTtsLoading(true); + + const greetingChunkSentences: string[] = []; + let greetingChunkConsumed = false; + let greetingChunkText = ''; + let visible = ''; + let parsedChips: Array<{ label: string; action: string }> = []; + + await new Promise((resolve) => { + streamSosLyra({ + apiBase, + token: session.access_token, + messages: SOS_BOOT, + locale: i18n.language, + onTextUpdate: (full) => { + if (cancelled) return; + visible = full; + ensureBubble(full); + }, + onChips: (chips) => { parsedChips = chips; }, + onSentence: (s) => { + if (greetingChunkConsumed) return; + greetingChunkSentences.push(s); + if (greetingChunkSentences.length >= 2) { + greetingChunkConsumed = true; + greetingChunkText = greetingChunkSentences.join(' '); + ttsQueue?.enqueue(greetingChunkText); + } + }, + onDone: (full) => { + if (cancelled) { resolve(); return; } + visible = full || visible; + ensureBubble(visible); + if (ttsQueue) { + if (greetingChunkConsumed) { + const idx = full.indexOf(greetingChunkText); + const rest = idx >= 0 + ? full.slice(idx + greetingChunkText.length).trim() + : full.replace(greetingChunkText, '').trim(); + if (rest.length > 0) ttsQueue.enqueue(rest, 'sos-continuation'); + } else { + const cleaned = (full || visible).trim(); + if (cleaned) ttsQueue.enqueue(cleaned); + } + } + resolve(); + }, + onError: (err) => { + console.warn('[sos-greeting] stream failed:', err); + resolve(); + }, + }) + .then(() => {}) + .catch(() => resolve()); + }); + + if (cancelled) return; + const e = detectEmotion(visible); + // Fallback-Chips falls Lyra im Greeting keine liefert + const greetingChips = parsedChips.length > 0 ? parsedChips : [ + { label: '😤 Wütend', action: 'feel:Ich bin gerade sehr wütend.' }, + { label: '😰 Ängstlich', action: 'feel:Ich bin ängstlich und nervos.' }, + { label: '😔 Traurig', action: 'feel:Ich bin traurig.' }, + { label: '🤔 Etwas anderes', action: 'need_help' }, + ]; + setDynamicChips(greetingChips); + setChipSet('none'); + setEmotion(e); scheduleEmotionReset(e === 'empathy' ? 6000 : 4000); + } catch (err) { + if (cancelled) return; + console.warn('[sos-greeting] failed, fallback message', err); + addMessage({ id: 'greeting-err', role: 'assistant', content: 'Ich bin für dich da. Was ist gerade los?', timestamp: new Date() }); + setEmotion('empathy'); scheduleEmotionReset(5000); + } finally { if (!cancelled) { setIsLoading(false); setThinking(false); } } + } + openGreeting(); + return () => { cancelled = true; }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + function startBreathing() { + // Laufendes Lyra-TTS stoppen — sonst spricht sie in die Atemübung rein + stopSpeaking(); + setIsBreathing(true); + setChipSet('none'); + } + + async function handleBreathingDone() { + setIsBreathing(false); + setBreathingDone(true); + breathingCountRef.current += 1; + setChipSet('after_breathing'); + requestCheckInRef.current = 'after_breathing'; + await sendToLyra('Ich habe gerade die 4-7-8 Atemübung abgeschlossen.'); + } + + async function handleChip(action: string) { + if (thinking) return; + Keyboard.dismiss(); + // Clear dynamic chips on any chip action — Lyra will provide new ones in her reply + setDynamicChips([]); + if (action.startsWith('feel:')) { + const feelingText = action.replace('feel:', ''); + setChipSet('none'); + await sendToLyra(feelingText); + } else if (action === 'need_help') { + setChipSet('none'); + await sendToLyra('Ich möchte darüber reden, was gerade los ist.'); + } else if (action === 'just_play') { + setChipSet('none'); + setIsPickingGame(true); + } else if (action === 'breathing') { + startBreathing(); + } else if (action === 'game_picker') { + setChipSet('none'); + setIsPickingGame(true); + } else if (action === 'overcome') { + await finalizeOvercome(); + } else if (action === 'show_stats') { + setChipSet('none'); + try { + const res = await apiFetch<{ urges?: number; overcomeCnt?: number; streakDays?: number }>('/api/urge/stats'); + const msg = res + ? `Du hast heute ${res.overcomeCnt ?? 1} von ${res.urges ?? 1} Impulsen widerstanden. Dein Streak: ${res.streakDays ?? 1} Tag(e). Ich bin stolz auf dich! 💪` + : 'Jeder überwundene Impuls macht dich stärker. Ich bin stolz auf dich! 💪'; + addMessage({ id: 'stats-' + Date.now(), role: 'assistant', content: msg, timestamp: new Date() }); + speakText(msg); + } catch { + addMessage({ id: 'stats-err', role: 'assistant', content: 'Jeder überwundene Impuls zählt. Du machst das großartig! 💪', timestamp: new Date() }); + } + } else if (action === 'close') { + attemptExit(); + } else if (action === 'share_success') { + setChipSet('none'); + openShareDrawer(); + } else if (action === 'rate_session') { + setChipSet('none'); + setRatingDrawerVisible(true); + } else if (action.startsWith('send_text:')) { + setChipSet('none'); + const txt = action.replace('send_text:', ''); + // Wenn der User nach Atem/Spiel "weiter reden" klickt → Stats-Reminder triggern + if ((breathingCountRef.current > 0 || gamesPlayedRef.current.length > 0) && + /weiter reden|weiterreden|reden|sprechen/i.test(txt)) { + requestStatsReminderRef.current = true; + } + await sendToLyra(txt); + } + } + + async function handleGameSelect(game: GameType) { + setIsPickingGame(false); + setPlayingGame(game); + const titles: Record = { snake: 'Snake', tetris: 'Tetris', memory: 'Memory', tictactoe: 'Tic-Tac-Toe' }; + const title = titles[game]; + + // Personal-Best fetchen (parallel zum Game-Start) — Lyra mentions PB im Pep-Talk. + // Cache pbBeforeGame für späteren Vergleich nach Spiel-Ende (Rekord-Detection). + let pbContext = ''; + pbBeforeGameRef.current = 0; + try { + const pbRes = await apiFetch<{ score: number; hasRecord: boolean }>(`/api/games/highscore?gameName=${encodeURIComponent(game)}`); + const pb = pbRes?.score ?? 0; + pbBeforeGameRef.current = pb; + if (pbRes?.hasRecord && pb > 0) { + pbContext = ` Sein bisheriger Personal-Best in ${title} ist ${pb}. Erwähne diesen PB konkret im Kommentar UND glaube an ihn dass er es heute schaffen kann.`; + } else { + pbContext = ` Es ist sein ALLERSTER ${title}-Versuch. Ermutige zum Erstversuch.`; + } + } catch { + // Silent fallback — Lyra bekommt einfach keinen PB-Hint + } + + // Fire-and-forget Lyra-Kommentar (UI launches game immediately) + (async () => { + try { + const promptMsg = `[INTERN: Der User hat das Spiel "${title}" gewählt.${pbContext} Gib EINEN warmen Kommentar (1-2 Sätze) — bei Tetris nostalgisch, Snake spielerisch, Memory ermutigend, Tic-Tac-Toe entspannt. Antworte als reines JSON: {"message":"...","chips":[]}. Kein Markdown.]`; + const visibleHistory = messages.filter((m) => !m.cardType).map((m) => ({ role: m.role, content: m.content })); + const apiMessages = [...SOS_BOOT, ...visibleHistory, { role: 'user' as const, content: promptMsg }]; + const res = await apiFetch<{ message: string }>('/api/coach/message', { + method: 'POST', + body: { messages: apiMessages, locale: i18n.language, sosMode: true }, + }); + const parsed = parseLyraResponse(res?.message ?? ''); + const visible = parsed.message || `Viel Spaß mit ${title}!`; + addMessage({ id: 'gamecomment-' + Date.now(), role: 'assistant', content: visible, timestamp: new Date() }); + setEmotion('happy'); scheduleEmotionReset(4000); speakText(visible); + } catch { + const fallback = pbBeforeGameRef.current > 0 + ? `Viel Spaß mit ${title}! Dein PB ist ${pbBeforeGameRef.current} — ich glaube du schaffst heute mehr.` + : `Viel Spaß mit ${title}! Ich bin hier, wenn du fertig bist.`; + addMessage({ id: 'gamecomment-err-' + Date.now(), role: 'assistant', content: fallback, timestamp: new Date() }); + speakText(fallback); + } + })(); + } + + async function handleGameComplete(score: number) { + const gameName = GAME_META.find((g) => g.id === playingGame)?.id ?? 'das Spiel'; + const game = playingGame ?? 'unknown'; + gamesPlayedRef.current.push({ game, score, durationSec: 0 }); + + // Score persistieren — server entscheidet ob's ein neuer PB ist (idempotent) + let isNewBest = false; + try { + const res = await apiFetch<{ ok: boolean; isNewBest: boolean }>('/api/games/score', { + method: 'POST', + body: { gameName: game, score }, + }); + isNewBest = !!res?.isNewBest; + } catch (err) { + console.warn('[urge] score submit failed', err); + } + + // Wenn Rekord: Lyra im nächsten Reply feiern lassen via Hint-Trigger + const oldPb = pbBeforeGameRef.current; + if (isNewBest && score > oldPb) { + requestNewPbCelebrationRef.current = { game: gameName, oldScore: oldPb, newScore: score }; + } + + setPlayingGame(null); setChipSet('after_game'); + requestCheckInRef.current = 'after_game'; + await sendToLyra(`Ich habe ${gameName} gespielt und ${score} Punkte erreicht.`); + } + + async function handleGameAbandon() { + const game = playingGame; + if (game) gamesPlayedRef.current.push({ game, score: 0, durationSec: 0 }); + setPlayingGame(null); setChipSet('after_game'); + requestCheckInRef.current = 'after_game'; + await sendToLyra('Ich habe das Spiel abgebrochen.'); + } + + async function finalizeOvercome() { + if (thinking) return; + setChipSet('none'); + wasOvercomeRef.current = true; + try { await apiFetch('/api/urge', { method: 'POST', body: { emotion: 'other', wasOvercome: true, breathingDone } }); } catch {} + // Lyra antwortet warm + motiviert zu Share/Rate + requestOvercomeMotivationRef.current = true; + await sendToLyra('Ich habe den Spielimpuls gerade überwunden – ich bin so erleichtert und stolz auf mich!'); + addMessage({ id: 'overcome-' + Date.now(), role: 'assistant', content: '', cardType: 'overcome', timestamp: new Date() }); + // Chips garantiert anzeigen — egal was Lyra zurückgibt (Hint kann verfehlt werden) + setDynamicChips([ + { label: '✨ Erfolg teilen', action: 'share_success' }, + { label: '⭐ Session bewerten', action: 'rate_session' }, + { label: '✅ Fertig', action: 'close' }, + ]); + setChipSet('none'); + } + + // ───── Share-Success Drawer ───── + async function generateShareDraft() { + setShareGenerating(true); + try { + const b = breathingCountRef.current; + const g = gamesPlayedRef.current.length; + const stats: string[] = []; + if (b > 0) stats.push(`${b}x Atemübung`); + if (g > 0) stats.push(`${g}x Mini-Spiel`); + const statsLine = stats.length > 0 ? stats.join(' + ') : 'Lyras Begleitung'; + + const promptMsg = + `[INTERN: Schreibe einen kurzen, ehrlichen, warmen anonymen Community-Post (max 4 Sätze, ich-Form, Deutsch) über meinen heutigen Erfolg. ` + + `Ich habe einen akuten Spielimpuls überwunden mit ${statsLine}. ` + + `Inspiriere andere, ohne zu predigen. Kein Hashtag, keine Emojis-Spam (max 1 Emoji am Ende). ` + + `Antworte als reines JSON: {"message":"","chips":[]}. Kein Markdown.]`; + const visibleHistory = messages + .filter((m) => !m.cardType && m.content) + .slice(-10) + .map((m) => ({ role: m.role, content: m.content })); + const apiMessages = [...SOS_BOOT, ...visibleHistory, { role: 'user' as const, content: promptMsg }]; + const res = await apiFetch<{ message: string }>('/api/coach/message', { + method: 'POST', + body: { messages: apiMessages, locale: i18n.language, sosMode: true }, + }); + const parsed = parseLyraResponse(res?.message ?? ''); + const draft = + parsed.message?.trim() || + 'Heute hatte ich einen heftigen Spielimpuls — und ich habe ihn überwunden. Es war hart, aber ich bin geblieben. 💪'; + setShareDraft(draft); + } catch { + setShareDraft( + 'Heute hatte ich einen Spielimpuls — und ich habe ihn überwunden. Schritt für Schritt. 💪', + ); + } finally { + setShareGenerating(false); + } + } + + async function openShareDrawer() { + setShareDrawerVisible(true); + setShareDraft(''); + await generateShareDraft(); + } + + async function submitSharePost(text: string) { + if (sharePostedRef.current) return; + sharePostedRef.current = true; + try { + await apiFetch('/api/community/post', { + method: 'POST', + body: { category: 'story', content: text }, + }); + addMessage({ + id: 'share-ack-' + Date.now(), + role: 'assistant', + content: 'Dein Erfolg ist geteilt — danke, dass du andere stärkst. 💛', + timestamp: new Date(), + }); + } catch { + sharePostedRef.current = false; + addMessage({ + id: 'share-err-' + Date.now(), + role: 'assistant', + content: 'Das Teilen hat gerade nicht geklappt. Versuch es später nochmal.', + timestamp: new Date(), + }); + } finally { + setShareDrawerVisible(false); + } + } + + // ───── Inline-Rating Drawer ───── + async function submitInlineRating(fb: SosFeedback) { + inlineFeedbackRef.current = fb; + setRatingDrawerVisible(false); + addMessage({ + id: 'rate-ack-' + Date.now(), + role: 'assistant', + content: 'Danke für dein Feedback — das hilft mir, besser zu werden. 💛', + timestamp: new Date(), + }); + } + + + async function handleSend() { + const content = input.trim(); + if (!content || thinking) return; + setInput(''); + if (chipSet === 'start') setChipSet('help'); + await sendToLyra(content); + } + + // ───── Exit + Session-Persist ───── + function hasInteracted(): boolean { + const userMsgs = messages.filter((m) => m.role === 'user').length; + return ( + userMsgs > 0 || + breathingCountRef.current > 0 || + gamesPlayedRef.current.length > 0 || + wasOvercomeRef.current + ); + } + + function attemptExit() { + if (exitingRef.current) return; + if (isSpeaking) stopSpeaking(); + // Wenn User schon inline bewertet hat → direkt speichern, kein Modal + if (inlineFeedbackRef.current) { + exitingRef.current = true; + void persistSession(inlineFeedbackRef.current).finally(() => router.back()); + return; + } + if (hasInteracted()) { + setFeedbackVisible(true); + } else { + // Nur reingeschaut → kein Modal, kein DB-Save + exitingRef.current = true; + router.back(); + } + } + + async function persistSession(feedback: SosFeedback | null) { + const endedAt = new Date(); + const durationSec = Math.max( + 1, + Math.round((endedAt.getTime() - sessionStartRef.current.getTime()) / 1000), + ); + const payload = { + startedAt: sessionStartRef.current.toISOString(), + endedAt: endedAt.toISOString(), + durationSec, + messages: messages + .filter((m) => !m.cardType && m.content) + .map((m) => ({ + role: m.role, + content: m.content, + timestamp: m.timestamp.toISOString(), + })), + gamesPlayed: gamesPlayedRef.current, + breathingCount: breathingCountRef.current, + wasOvercome: wasOvercomeRef.current, + feedbackBetter: feedback?.better ?? null, + feedbackRating: feedback?.rating ?? null, + feedbackText: feedback?.text || null, + locale: i18n.language, + }; + try { + await apiFetch('/api/sos/session', { method: 'POST', body: payload }); + } catch (err) { + console.warn('[sos-session-save]', err); + } + } + + async function handleFeedbackSubmit(feedback: SosFeedback) { + setFeedbackVisible(false); + exitingRef.current = true; + await persistSession(feedback); + router.back(); + } + + async function handleFeedbackSkip() { + setFeedbackVisible(false); + exitingRef.current = true; + await persistSession(null); + router.back(); + } + + const currentChips = dynamicChips.length > 0 ? dynamicChips : (CHIP_SETS[chipSet] ?? []); + const topBarHeight = insets.top + 160; + + const renderMessage = useCallback( + ({ item }: { item: SosMsg }) => ( + {}} + /> + ), + // eslint-disable-next-line react-hooks/exhaustive-deps + [], + ); + + const listData: SosMsg[] = messages; + + function renderItem({ item }: { item: SosMsg }) { + return {}} />; + } + + return ( + + + + {/* Header */} + + + + + + + + Lyra · SOS + {(thinking || isLoading) && !isSpeaking && ( + + + denkt nach … + + )} + {!thinking && !isLoading && isTtsLoading && !isSpeaking && ( + + + spricht... + + )} + {isSpeaking && ( + + + + + + + )} + + + setSoundEnabled((s) => !s)} hitSlop={12}> + + + + + + + + + {playingGame ? ( + + + + {playingGame === 'memory' && } + {playingGame === 'tictactoe' && } + {playingGame === 'snake' && } + {playingGame === 'tetris' && } + + + ) : ( + + {( + + item.id} + contentContainerStyle={[st.listContent, { paddingTop: topBarHeight + 10 }]} + showsVerticalScrollIndicator={false} + onScroll={handleScroll} + scrollEventThrottle={100} + onContentSizeChange={() => { if (isNearBottomRef.current) flatRef.current?.scrollToEnd({ animated: false }); }} + ListFooterComponent={null} + /> + {showScrollBtn && ( + { flatRef.current?.scrollToEnd({ animated: true }); setShowScrollBtn(false); }}> + + + )} + + )} + + {/* Chips above input — only after Lyra has answered. + Bei Standard-Actions (breathing/game/overcome/etc): Ionicons + (native = SF Symbols iOS, Material Android) + Label OHNE Emoji. + Bei custom-Actions ohne Mapping: Emoji aus chip.label (LLM-generiert). + Verhindert "Lung-Emoji + leaf-Icon"-Doppelung. */} + {currentChips.length > 0 && !isLoading && !thinking && ( + + {currentChips.map((chip) => { + const isOvercome = chip.action === 'overcome'; + const isStats = chip.action === 'show_stats'; + const isFeel = chip.action.startsWith('feel:'); + const isBreathing = chip.action === 'breathing'; + const isGame = chip.action === 'game_picker' || chip.action === 'just_play'; + const isHelp = chip.action === 'need_help'; + const isClose = chip.action === 'close'; + + const iconColor = + isBreathing ? '#0891b2' : + isGame ? '#9333ea' : + isOvercome ? '#16a34a' : + isStats ? '#2563eb' : + isHelp ? '#dc2626' : + isClose ? '#94a3b8' : + isFeel ? '#7c3aed' : + '#475569'; + + const iconName: any = + isBreathing ? 'leaf-outline' : + isGame ? 'game-controller-outline' : + isOvercome ? 'checkmark-circle-outline' : + isStats ? 'stats-chart-outline' : + isHelp ? 'alert-circle-outline' : + isClose ? 'close-circle-outline' : + isFeel ? 'heart-outline' : + null; + + // Wenn Ionicons-Match: Emoji aus Label strippen (kein Doppel-Icon) + const labelText = iconName + ? chip.label.replace(/^\s*[\p{Extended_Pictographic}\p{Emoji_Component}]+\s*/u, '') + : chip.label; + + return ( + handleChip(chip.action)} + disabled={thinking} + style={({ pressed }) => [ + st.chip, + pressed && st.chipPressed, + thinking && { opacity: 0.4 }, + ]} + > + + {iconName && } + {labelText} + + + ); + })} + + )} + + {/* Input bar — natürliche Höhe, außerhalb flex:1 */} + 0 ? 8 : Math.max(12, insets.bottom) }]}> + + {input.trim() !== '' && ( + + + + )} + + + )} + + {/* Breathing drawer — absolute, slides up over input */} + {isBreathing && ( + + )} + + {/* Game picker drawer — absolute, slides up over input */} + {isPickingGame && !playingGame && ( + setIsPickingGame(false)} + /> + )} + + {/* Inline-Rating Drawer (Alternative zum Exit-Modal) */} + {ratingDrawerVisible && ( + setRatingDrawerVisible(false)} + /> + )} + + {/* Share-Success Drawer */} + {shareDrawerVisible && ( + setShareDrawerVisible(false)} + onRegenerate={generateShareDraft} + /> + )} + + {/* Exit-Feedback-Modal */} + + + ); +} + +const st = StyleSheet.create({ + container: { flex: 1, backgroundColor: '#ffffff' }, + topBar: { position: 'absolute', left: 0, right: 0, zIndex: 10, flexDirection: 'row', alignItems: 'flex-start', justifyContent: 'space-between', paddingHorizontal: 12 }, + topBarBackdrop: { position: 'absolute', top: 0, left: 0, right: 0, zIndex: 9, backgroundColor: '#ffffff' }, + ttsToggleBar: { position: 'absolute', left: 0, right: 0, zIndex: 8, alignItems: 'center' }, + actionBtn: { width: 40, height: 40, borderRadius: 20, backgroundColor: 'rgba(255,255,255,0.92)', alignItems: 'center', justifyContent: 'center', shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.08, shadowRadius: 6, elevation: 4 }, + avatarCenter: { flex: 1, alignItems: 'center', gap: 4 }, + avatarMeta: { alignItems: 'center', gap: 2 }, + avatarName: { fontSize: 14, fontFamily: 'Nunito_700Bold', color: '#0a0a0a' }, + speakingRow: { flexDirection: 'row', alignItems: 'center', gap: 6 }, + stopBtn: { width: 18, height: 18, borderRadius: 9, backgroundColor: '#f5f5f5', alignItems: 'center', justifyContent: 'center' }, + listContent: { paddingHorizontal: 12, paddingBottom: 4 }, + scrollDownBtn: { position: 'absolute', bottom: 8, right: 16, width: 36, height: 36, borderRadius: 18, backgroundColor: '#374151', alignItems: 'center', justifyContent: 'center', shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.15, shadowRadius: 4, elevation: 4 }, + chip: { + borderRadius: 14, + borderWidth: 1.5, + borderColor: '#9ca3af', // sichtbarer Ring (medium-grau gegen weiß) + backgroundColor: '#ffffff', + paddingHorizontal: 16, + paddingVertical: 11, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.12, + shadowRadius: 6, + elevation: 3, + }, + chipPressed: { + backgroundColor: '#f3f4f6', + borderColor: '#6b7280', // dunkler beim Press → spürbares Feedback + transform: [{ scale: 0.97 }], + shadowOpacity: 0.05, + }, + chipText: { fontFamily: 'Nunito_600SemiBold', fontSize: 14, color: '#334155' }, + inputBar: { flexDirection: 'row', alignItems: 'flex-end', paddingHorizontal: 12, paddingTop: 8, borderTopWidth: 1, borderTopColor: '#f3f4f6', backgroundColor: '#fff', gap: 8 }, + textInput: { flex: 1, minHeight: 40, maxHeight: 120, backgroundColor: '#f3f4f6', borderRadius: 20, paddingHorizontal: 14, paddingVertical: 10, fontSize: 15, fontFamily: 'Nunito_400Regular', color: '#111827' }, + sendBtn: { width: 38, height: 38, borderRadius: 19, backgroundColor: '#007AFF', alignItems: 'center', justifyContent: 'center' }, +}); diff --git a/apps/rebreak-native/assets/adaptive-icon-android.png b/apps/rebreak-native/assets/adaptive-icon-android.png new file mode 100644 index 0000000..b820d5b Binary files /dev/null and b/apps/rebreak-native/assets/adaptive-icon-android.png differ diff --git a/apps/rebreak-native/assets/adaptive-icon.png b/apps/rebreak-native/assets/adaptive-icon.png new file mode 100644 index 0000000..ec9b4d7 Binary files /dev/null and b/apps/rebreak-native/assets/adaptive-icon.png differ diff --git a/apps/rebreak-native/assets/icon.png b/apps/rebreak-native/assets/icon.png new file mode 100644 index 0000000..ec9b4d7 Binary files /dev/null and b/apps/rebreak-native/assets/icon.png differ diff --git a/apps/rebreak-native/assets/lyra-avatar.riv b/apps/rebreak-native/assets/lyra-avatar.riv new file mode 100644 index 0000000..cb26d8c Binary files /dev/null and b/apps/rebreak-native/assets/lyra-avatar.riv differ diff --git a/apps/rebreak-native/assets/splash.png b/apps/rebreak-native/assets/splash.png new file mode 100644 index 0000000..2ba07d3 Binary files /dev/null and b/apps/rebreak-native/assets/splash.png differ diff --git a/apps/rebreak-native/assets/tabs/chatbubble.png b/apps/rebreak-native/assets/tabs/chatbubble.png new file mode 100644 index 0000000..8d7baa8 Binary files /dev/null and b/apps/rebreak-native/assets/tabs/chatbubble.png differ diff --git a/apps/rebreak-native/assets/tabs/chatbubble@2x.png b/apps/rebreak-native/assets/tabs/chatbubble@2x.png new file mode 100644 index 0000000..9229eca Binary files /dev/null and b/apps/rebreak-native/assets/tabs/chatbubble@2x.png differ diff --git a/apps/rebreak-native/assets/tabs/chatbubble@3x.png b/apps/rebreak-native/assets/tabs/chatbubble@3x.png new file mode 100644 index 0000000..c364082 Binary files /dev/null and b/apps/rebreak-native/assets/tabs/chatbubble@3x.png differ diff --git a/apps/rebreak-native/assets/tabs/home.png b/apps/rebreak-native/assets/tabs/home.png new file mode 100644 index 0000000..2ff2148 Binary files /dev/null and b/apps/rebreak-native/assets/tabs/home.png differ diff --git a/apps/rebreak-native/assets/tabs/home@2x.png b/apps/rebreak-native/assets/tabs/home@2x.png new file mode 100644 index 0000000..784abd9 Binary files /dev/null and b/apps/rebreak-native/assets/tabs/home@2x.png differ diff --git a/apps/rebreak-native/assets/tabs/home@3x.png b/apps/rebreak-native/assets/tabs/home@3x.png new file mode 100644 index 0000000..1222fb4 Binary files /dev/null and b/apps/rebreak-native/assets/tabs/home@3x.png differ diff --git a/apps/rebreak-native/assets/tabs/mail.png b/apps/rebreak-native/assets/tabs/mail.png new file mode 100644 index 0000000..d2fedff Binary files /dev/null and b/apps/rebreak-native/assets/tabs/mail.png differ diff --git a/apps/rebreak-native/assets/tabs/mail@2x.png b/apps/rebreak-native/assets/tabs/mail@2x.png new file mode 100644 index 0000000..d33ef4e Binary files /dev/null and b/apps/rebreak-native/assets/tabs/mail@2x.png differ diff --git a/apps/rebreak-native/assets/tabs/mail@3x.png b/apps/rebreak-native/assets/tabs/mail@3x.png new file mode 100644 index 0000000..bda70a0 Binary files /dev/null and b/apps/rebreak-native/assets/tabs/mail@3x.png differ diff --git a/apps/rebreak-native/assets/tabs/shield-checkmark.png b/apps/rebreak-native/assets/tabs/shield-checkmark.png new file mode 100644 index 0000000..0985651 Binary files /dev/null and b/apps/rebreak-native/assets/tabs/shield-checkmark.png differ diff --git a/apps/rebreak-native/assets/tabs/shield-checkmark@2x.png b/apps/rebreak-native/assets/tabs/shield-checkmark@2x.png new file mode 100644 index 0000000..191f722 Binary files /dev/null and b/apps/rebreak-native/assets/tabs/shield-checkmark@2x.png differ diff --git a/apps/rebreak-native/assets/tabs/shield-checkmark@3x.png b/apps/rebreak-native/assets/tabs/shield-checkmark@3x.png new file mode 100644 index 0000000..904a313 Binary files /dev/null and b/apps/rebreak-native/assets/tabs/shield-checkmark@3x.png differ diff --git a/apps/rebreak-native/assets/tabs/sparkles.png b/apps/rebreak-native/assets/tabs/sparkles.png new file mode 100644 index 0000000..677a869 Binary files /dev/null and b/apps/rebreak-native/assets/tabs/sparkles.png differ diff --git a/apps/rebreak-native/assets/tabs/sparkles@2x.png b/apps/rebreak-native/assets/tabs/sparkles@2x.png new file mode 100644 index 0000000..1afa07d Binary files /dev/null and b/apps/rebreak-native/assets/tabs/sparkles@2x.png differ diff --git a/apps/rebreak-native/assets/tabs/sparkles@3x.png b/apps/rebreak-native/assets/tabs/sparkles@3x.png new file mode 100644 index 0000000..39ce31f Binary files /dev/null and b/apps/rebreak-native/assets/tabs/sparkles@3x.png differ diff --git a/apps/rebreak-native/babel.config.js b/apps/rebreak-native/babel.config.js new file mode 100644 index 0000000..1e66d22 --- /dev/null +++ b/apps/rebreak-native/babel.config.js @@ -0,0 +1,20 @@ +module.exports = function (api) { + api.cache(true); + return { + presets: [ + // reanimated: false → Auto-Load von react-native-reanimated/plugin durch + // babel-preset-expo abschalten. Wir laden unten react-native-worklets/plugin + // selbst (das ist der korrekte Plugin für Reanimated 4.x). + [ + 'babel-preset-expo', + { jsxImportSource: 'nativewind', reanimated: false }, + ], + 'nativewind/babel', + ], + plugins: [ + // Reanimated 4.x: Worklets ausgelagert in eigenes Package. + // Plugin muss letzter sein. + 'react-native-worklets/plugin', + ], + }; +}; diff --git a/apps/rebreak-native/clean-ios.sh b/apps/rebreak-native/clean-ios.sh new file mode 100755 index 0000000..bb3432b --- /dev/null +++ b/apps/rebreak-native/clean-ios.sh @@ -0,0 +1,83 @@ +#!/bin/bash +# Rebreak Native: Nuclear iOS Clean + Rebuild +# +# Wann brauchst du das? +# - Build-Errors die nach Native-Code-Änderung aus dem Nichts kommen +# - "fmt consteval", "Hermes", "RCT-Folly" Compile-Errors in Pods +# - Stale DerivedData-Cache nach Xcode-Upgrade +# - Nach App-Bundle-ID/Entitlement-Änderungen +# - Nach Hinzufügen/Entfernen von Native Modules oder Config-Plugins +# +# Was es NICHT zerstört: +# - Code in app/, components/, modules/, plugins/ (alles was in git getrackt ist) +# - node_modules +# +# Was es zerstört: +# - ios/Pods, ios/Podfile.lock, ios/build (vollständig regeneriert via prebuild) +# - DerivedData-Cache von Xcode (wird beim nächsten Build neu aufgebaut) +# +# Modi: +# ./clean-ios.sh → clean + prebuild + pod install (kein Build) +# ./clean-ios.sh --build → + zusätzlich pnpm ios am Ende (wirft App auf Sim/Device) +# ./clean-ios.sh --xcode → + zum Schluss Xcode-Workspace öffnen + +set -e +cd "$(dirname "$0")" + +MODE="${1:-}" + +echo "🧹 Rebreak Native iOS Clean" +echo "===========================" +echo "" + +# 1. Wipe Pods, Lockfile, Build-Output +echo "→ rm -rf ios/Pods ios/Podfile.lock ios/build" +rm -rf ios/Pods ios/Podfile.lock ios/build + +# 2. Wipe Xcode DerivedData (nur für Rebreak — nicht das ganze ~/Library) +DERIVED_DATA="$HOME/Library/Developer/Xcode/DerivedData" +if [ -d "$DERIVED_DATA" ]; then + echo "→ rm -rf $DERIVED_DATA/Rebreak-*" + rm -rf "$DERIVED_DATA"/Rebreak-* 2>/dev/null || true +fi + +# 3. Prebuild: regeneriert ios/ aus app.config.ts + Config-Plugins +# Dank with-fmt-consteval-fix-Plugin wird das Podfile auto-gepatcht. +echo "→ pnpm expo prebuild --clean" +pnpm expo prebuild --clean + +# 4. Pod install +echo "→ cd ios && pod install" +(cd ios && pod install) + +echo "" +echo "✅ Clean done." +echo "" + +case "$MODE" in + --build|build) + echo "🔨 Building + running on connected device/simulator..." + pnpm ios + ;; + + --xcode|xcode) + echo "🔨 Opening Xcode-Workspace..." + osascript -e 'tell application "Xcode" to close every window whose name contains "Rebreak.xcodeproj"' 2>/dev/null || true + open -a Xcode ios/Rebreak.xcworkspace + echo "" + echo "ℹ️ In Xcode: Cmd+R für Build & Run" + ;; + + "") + echo "ℹ️ Nächste Schritte:" + echo " ./dev-ios.sh # Xcode öffnen (manueller Build)" + echo " pnpm ios # CLI-Build auf Sim/Device" + echo " ./clean-ios.sh --build # alles in einem Rutsch" + ;; + + *) + echo "Unknown mode: $MODE" + echo "Usage: ./clean-ios.sh [--build|--xcode]" + exit 1 + ;; +esac diff --git a/apps/rebreak-native/components/AppHeader.tsx b/apps/rebreak-native/components/AppHeader.tsx new file mode 100644 index 0000000..11e21f0 --- /dev/null +++ b/apps/rebreak-native/components/AppHeader.tsx @@ -0,0 +1,238 @@ +import { useState } from 'react'; +import { View, Text, Pressable, Modal, Image } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { Ionicons } from '@expo/vector-icons'; +import { useRouter, type RelativePathString } from 'expo-router'; +import { useTranslation } from 'react-i18next'; +import { useAuthStore } from '../stores/auth'; +import { useNotificationStore } from '../stores/notifications'; +import { supabase } from '../lib/supabase'; +import { resolveAvatar } from '../lib/resolveAvatar'; +import { useMe } from '../hooks/useMe'; +import { NotificationsDropdown } from './NotificationsDropdown'; + +type Props = { + notifCount?: number; +}; + +type MenuItem = { + icon: React.ComponentProps['name']; + label: string; + color?: string; + action: () => void; +}; + +export function AppHeader({ notifCount }: Props = {}) { + const insets = useSafeAreaInsets(); + const router = useRouter(); + const { t } = useTranslation(); + const { user } = useAuthStore(); + const { me } = useMe(); + const storeUnread = useNotificationStore((s) => s.unread); + const badge = notifCount ?? storeUnread; + const [dropdownOpen, setDropdownOpen] = useState(false); + const [notifOpen, setNotifOpen] = useState(false); + + const firstName = (user?.user_metadata?.first_name as string | undefined) ?? ''; + const lastName = (user?.user_metadata?.last_name as string | undefined) ?? ''; + const email = user?.email ?? ''; + // Initials-Fallback: erst nickname (DB), dann firstName/email + const initials = (() => { + if (me?.nickname) return me.nickname.slice(0, 2).toUpperCase(); + return ((firstName.charAt(0) + (lastName.charAt(0) || email.charAt(0))).toUpperCase() || '?'); + })(); + + // Avatar: aus DB (`/api/auth/me` → profiles.avatar). Kann Hero-Avatar-ID + // ("spider"/"hulk"/...) ODER Custom-Photo-URL (https://... von Foto-Upload) + // sein. resolveAvatar handlet beide Fälle. + // user_metadata.avatar_id ist veraltet — wird bei Profile-Edit nicht + // aktualisiert. DB ist Single Source of Truth. + const avatarUrl = me ? resolveAvatar(me.avatar, me.nickname ?? '') : ''; + const [avatarLoadFailed, setAvatarLoadFailed] = useState(false); + const showAvatarImage = !!avatarUrl && !avatarLoadFailed && !!me?.avatar; + + function closeAndNavigate(path: RelativePathString) { + setDropdownOpen(false); + router.push(path); + } + + async function handleSignOut() { + setDropdownOpen(false); + await supabase.auth.signOut(); + router.replace('/' as RelativePathString); + } + + const menuItems: MenuItem[] = [ + { + icon: 'person-outline', + label: t('appHeader.editProfile'), + action: () => closeAndNavigate('/settings' as RelativePathString), + }, + { + icon: 'settings-outline', + label: t('appHeader.settings'), + action: () => closeAndNavigate('/settings' as RelativePathString), + }, + ]; + + const headerHeight = insets.top + 56; + + return ( + + + + {t('appHeader.appName')} + + + + {/* Notifications dropdown trigger */} + setNotifOpen(true)} + className="w-9 h-9 rounded-full bg-white items-center justify-center" + style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })} + > + + {badge > 0 && ( + + + {badge > 9 ? '9+' : String(badge)} + + + )} + + + {/* Profil-Avatar — tap → dropdown */} + setDropdownOpen(true)} + className={`w-9 h-9 rounded-full items-center justify-center overflow-hidden ${showAvatarImage ? 'bg-neutral-100' : 'bg-rebreak-500'}`} + style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })} + > + {showAvatarImage ? ( + setAvatarLoadFailed(true)} + style={{ width: 36, height: 36, borderRadius: 18 }} + /> + ) : ( + {initials} + )} + + + + + {/* Dropdown modal */} + setDropdownOpen(false)} + > + setDropdownOpen(false)} + style={{ flex: 1, backgroundColor: 'rgba(0,0,0,0.18)' }} + > + true} + style={{ + position: 'absolute', + top: headerHeight + 6, + right: 12, + backgroundColor: '#ffffff', + borderRadius: 18, + shadowColor: '#000', + shadowOffset: { width: 0, height: 8 }, + shadowOpacity: 0.18, + shadowRadius: 20, + elevation: 12, + minWidth: 260, + overflow: 'hidden', + }} + > + {/* SOS prominent oben — Pressable mit innerem Row-View */} + closeAndNavigate('/urge' as RelativePathString)}> + + + + + + + {t('appHeader.sosLabel')} + + + {t('appHeader.sosSubtitle')} + + + + + + + + + {menuItems.map((item) => ( + + + + + {item.label} + + + + ))} + + + + + + + + {t('appHeader.signOut')} + + + + + + + + setNotifOpen(false)} + topOffset={headerHeight} + /> + + ); +} diff --git a/apps/rebreak-native/components/BrandSplash.tsx b/apps/rebreak-native/components/BrandSplash.tsx new file mode 100644 index 0000000..961ddb4 --- /dev/null +++ b/apps/rebreak-native/components/BrandSplash.tsx @@ -0,0 +1,412 @@ +import { useEffect, useRef } from 'react'; +import { Animated, Dimensions, Image, Text, View } from 'react-native'; +import Svg, { Defs, RadialGradient, Rect, Stop } from 'react-native-svg'; +import { useTranslation } from 'react-i18next'; + +// Phase-Timings (ms ab Mount) — 1:1 portiert aus apps/rebreak/app/components/AppSplash.vue +const T_GLOW = 0; +const T_NAME = 300; +const T_LOGO = 700; +const T_PULSE = 1100; +const T_TAGLINE = 1300; +const T_SUB = 1700; +const T_HOLD_END = 3200; +const T_LEAVE_DUR = 500; + +const { width: SW, height: SH } = Dimensions.get('window'); + +type ParticleConfig = { + size: number; + top?: number; + bottom?: number; + left?: number; + right?: number; + duration: number; + delay: number; +}; + +const PARTICLES: ParticleConfig[] = [ + { size: 180, top: -40, left: -60, duration: 7000, delay: 0 }, + { size: 120, bottom: SH * 0.1, right: -30, duration: 9000, delay: 1500 }, + { size: 80, top: SH * 0.35, left: SW * 0.08, duration: 11000, delay: 800 }, + { size: 60, bottom: SH * 0.2, left: SW * 0.2, duration: 8000, delay: 2200 }, + { size: 100, top: SH * 0.15, right: SW * 0.1, duration: 10000, delay: 400 }, +]; + +function Particle({ config }: { config: ParticleConfig }) { + const translateY = useRef(new Animated.Value(0)).current; + const scale = useRef(new Animated.Value(1)).current; + const opacity = useRef(new Animated.Value(0.6)).current; + + useEffect(() => { + const animate = () => { + Animated.loop( + Animated.sequence([ + Animated.parallel([ + Animated.timing(translateY, { + toValue: 18, + duration: config.duration, + useNativeDriver: true, + }), + Animated.timing(scale, { + toValue: 1.1, + duration: config.duration, + useNativeDriver: true, + }), + Animated.timing(opacity, { + toValue: 1, + duration: config.duration, + useNativeDriver: true, + }), + ]), + Animated.parallel([ + Animated.timing(translateY, { + toValue: 0, + duration: config.duration, + useNativeDriver: true, + }), + Animated.timing(scale, { + toValue: 1, + duration: config.duration, + useNativeDriver: true, + }), + Animated.timing(opacity, { + toValue: 0.6, + duration: config.duration, + useNativeDriver: true, + }), + ]), + ]), + ).start(); + }; + const t = setTimeout(animate, config.delay); + return () => clearTimeout(t); + }, [config, translateY, scale, opacity]); + + return ( + + ); +} + +export function BrandSplash() { + const { t } = useTranslation(); + // Phase-Opacity-Animationen + const containerOpacity = useRef(new Animated.Value(1)).current; + const glowCenterOpacity = useRef(new Animated.Value(0)).current; + const glowCenterScale = useRef(new Animated.Value(0.6)).current; + const glowTopOpacity = useRef(new Animated.Value(0.5)).current; + + const nameOpacity = useRef(new Animated.Value(0)).current; + const nameTranslateY = useRef(new Animated.Value(12)).current; + + const logoOpacity = useRef(new Animated.Value(0)).current; + const logoScale = useRef(new Animated.Value(0.82)).current; + const logoTranslateY = useRef(new Animated.Value(8)).current; + const logoPulse = useRef(new Animated.Value(1)).current; + + const taglineOpacity = useRef(new Animated.Value(0)).current; + const taglineTranslateY = useRef(new Animated.Value(8)).current; + + const subOpacity = useRef(new Animated.Value(0)).current; + const subTranslateY = useRef(new Animated.Value(6)).current; + + const footerOpacity = useRef(new Animated.Value(0)).current; + + useEffect(() => { + // Top-glow breath loop (4s alternating) — startet sofort + Animated.loop( + Animated.sequence([ + Animated.timing(glowTopOpacity, { + toValue: 0.9, + duration: 2000, + useNativeDriver: true, + }), + Animated.timing(glowTopOpacity, { + toValue: 0.5, + duration: 2000, + useNativeDriver: true, + }), + ]), + ).start(); + + const ease = (toValue: number, duration: number) => ({ + toValue, + duration, + useNativeDriver: true, + }); + + // Phase 1: glow center bloom (T=0) + Animated.parallel([ + Animated.timing(glowCenterOpacity, ease(1, 900)), + Animated.timing(glowCenterScale, ease(1, 900)), + ]).start(); + + // Phase 2: Name fade-in (T=300) + setTimeout(() => { + Animated.parallel([ + Animated.timing(nameOpacity, ease(1, 600)), + Animated.timing(nameTranslateY, ease(0, 600)), + ]).start(); + }, T_NAME); + + // Phase 3: Logo bouncy scale-in (T=700) + setTimeout(() => { + Animated.parallel([ + Animated.timing(logoOpacity, ease(1, 650)), + Animated.spring(logoScale, { + toValue: 1, + useNativeDriver: true, + friction: 6, + tension: 80, + }), + Animated.timing(logoTranslateY, ease(0, 650)), + ]).start(); + }, T_LOGO); + + // Phase 3b: Logo breathing pulse (T=1100) + setTimeout(() => { + Animated.loop( + Animated.sequence([ + Animated.timing(logoPulse, { + toValue: 1.04, + duration: 1300, + useNativeDriver: true, + }), + Animated.timing(logoPulse, { + toValue: 1, + duration: 1300, + useNativeDriver: true, + }), + ]), + ).start(); + }, T_PULSE); + + // Phase 4: Tagline (T=1300) + setTimeout(() => { + Animated.parallel([ + Animated.timing(taglineOpacity, ease(1, 550)), + Animated.timing(taglineTranslateY, ease(0, 550)), + ]).start(); + }, T_TAGLINE); + + // Phase 5: Sub-text + Footer (T=1700) + setTimeout(() => { + Animated.parallel([ + Animated.timing(subOpacity, ease(1, 500)), + Animated.timing(subTranslateY, ease(0, 500)), + Animated.timing(footerOpacity, ease(1, 600)), + ]).start(); + }, T_SUB); + + // Phase 7: whole-screen fade-out (T=3200, dauert 500ms) + const fadeOutTimer = setTimeout(() => { + Animated.timing(containerOpacity, { + toValue: 0, + duration: T_LEAVE_DUR, + useNativeDriver: true, + }).start(); + }, T_HOLD_END); + + return () => clearTimeout(fadeOutTimer); + }, [ + glowTopOpacity, + glowCenterOpacity, + glowCenterScale, + nameOpacity, + nameTranslateY, + logoOpacity, + logoScale, + logoTranslateY, + logoPulse, + taglineOpacity, + taglineTranslateY, + subOpacity, + subTranslateY, + footerOpacity, + containerOpacity, + ]); + + return ( + + {/* Top breathing radial-gradient ellipse (#1e3a8a auf transparent) */} + + + + + + + + + + + + + {/* Center indigo halo — bloomt rein wenn Logo erscheint */} + + + + + + + + + + + + + {/* Floating particles (5 Stück) */} + {PARTICLES.map((p, i) => ( + + ))} + + {/* Content-Column */} + + {/* App-Name */} + + {t('appHeader.appName')} + + + {/* Logo (mit Pulse + Bouncy Entry) */} + + + + + {/* Tagline */} + + {t('splash.tagline')} + + + {/* Sub-text */} + + {t('splash.subtitle')} + + + + {/* Footer */} + + {t('splash.madeInGermany')} + + + ); +} diff --git a/apps/rebreak-native/components/Button.tsx b/apps/rebreak-native/components/Button.tsx new file mode 100644 index 0000000..ce01a4e --- /dev/null +++ b/apps/rebreak-native/components/Button.tsx @@ -0,0 +1,57 @@ +import { ActivityIndicator, Pressable, Text } from 'react-native'; +import type { PressableProps } from 'react-native'; + +type Variant = 'primary' | 'secondary' | 'ghost' | 'danger'; + +type Props = PressableProps & { + children: React.ReactNode; + variant?: Variant; + loading?: boolean; + disabled?: boolean; + className?: string; +}; + +const variantStyles: Record = { + primary: { + container: 'bg-rebreak-500 rounded-xl px-5 py-3.5 items-center justify-center', + text: 'text-white text-base', + }, + secondary: { + container: 'bg-neutral-100 border border-neutral-200 rounded-xl px-5 py-3.5 items-center justify-center', + text: 'text-neutral-800 text-base', + }, + ghost: { + container: 'rounded-xl px-5 py-3.5 items-center justify-center', + text: 'text-neutral-600 text-base', + }, + danger: { + container: 'bg-red-50 border border-red-200 rounded-xl px-5 py-3.5 items-center justify-center', + text: 'text-red-600 text-base', + }, +}; + +export function Button({ + children, + variant = 'primary', + loading = false, + disabled = false, + className = '', + ...rest +}: Props) { + const styles = variantStyles[variant]; + const isDisabled = disabled || loading; + + return ( + + {loading ? ( + + ) : ( + {children} + )} + + ); +} diff --git a/apps/rebreak-native/components/Card.tsx b/apps/rebreak-native/components/Card.tsx new file mode 100644 index 0000000..e339494 --- /dev/null +++ b/apps/rebreak-native/components/Card.tsx @@ -0,0 +1,19 @@ +import { View } from 'react-native'; +import type { ViewProps } from 'react-native'; + +type Props = ViewProps & { + children: React.ReactNode; + className?: string; +}; + +export function Card({ children, className = '', style, ...rest }: Props) { + return ( + + {children} + + ); +} diff --git a/apps/rebreak-native/components/ComposeCard.tsx b/apps/rebreak-native/components/ComposeCard.tsx new file mode 100644 index 0000000..9522ac4 --- /dev/null +++ b/apps/rebreak-native/components/ComposeCard.tsx @@ -0,0 +1,174 @@ +import { useState, useRef } from 'react'; +import { + View, + Text, + TextInput, + Pressable, + Image, + ActivityIndicator, + Alert, +} from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { useQueryClient } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; +import * as FileSystem from 'expo-file-system'; +import * as ImagePicker from 'expo-image-picker'; +import { apiFetch } from '../lib/api'; +import { resolveAvatar } from '../lib/resolveAvatar'; +import { useAuthStore } from '../stores/auth'; +import { colors } from '../lib/theme'; + +type Props = { + onPosted?: () => void; +}; + +export function ComposeCard({ onPosted }: Props) { + const { t } = useTranslation(); + const { user } = useAuthStore(); + const queryClient = useQueryClient(); + const inputRef = useRef(null); + const [focused, setFocused] = useState(false); + const [content, setContent] = useState(''); + const [imageUri, setImageUri] = useState(null); + const [posting, setPosting] = useState(false); + + const avatarId = user?.user_metadata?.avatar_id as string | undefined; + const nickname = (user?.user_metadata?.username as string | undefined) ?? t('community.compose_default_user'); + const avatarUrl = resolveAvatar(avatarId ?? null, nickname); + + const cancel = () => { + setContent(''); + setImageUri(null); + setFocused(false); + inputRef.current?.blur(); + }; + + const pickImage = async () => { + const perm = await ImagePicker.requestMediaLibraryPermissionsAsync(); + if (!perm.granted) { + Alert.alert(t('community.compose_photo_perm_title'), t('community.compose_photo_perm_desc')); + return; + } + const result = await ImagePicker.launchImageLibraryAsync({ + mediaTypes: ImagePicker.MediaTypeOptions.Images, + allowsEditing: false, + quality: 0.8, + }); + if (!result.canceled && result.assets[0]?.uri) { + setImageUri(result.assets[0].uri); + } + }; + + const submit = async () => { + if (!content.trim() && !imageUri) return; + setPosting(true); + try { + let uploadedImageUrl: string | undefined; + + if (imageUri) { + const base64 = await FileSystem.readAsStringAsync(imageUri, { + encoding: FileSystem.EncodingType.Base64, + }); + const upload = await apiFetch<{ url: string }>('/api/community/upload-image', { + method: 'POST', + body: { + image: `data:image/jpeg;base64,${base64}`, + mimeType: 'image/jpeg', + }, + }); + uploadedImageUrl = upload?.url; + } + + await apiFetch('/api/community/post', { + method: 'POST', + body: { + category: 'story', + content: content.trim(), + ...(uploadedImageUrl ? { imageUrl: uploadedImageUrl } : {}), + }, + }); + cancel(); + queryClient.invalidateQueries({ queryKey: ['community-posts'] }); + onPosted?.(); + } catch (err: any) { + Alert.alert(t('common.error'), err?.message ?? t('community.post_failed')); + } finally { + setPosting(false); + } + }; + + const showActions = focused || content.length > 0; + + return ( + + + + + setFocused(true)} + placeholder={t('community.compose_placeholder')} + placeholderTextColor="#a3a3a3" + multiline + className="text-sm text-neutral-900 leading-5 min-h-[40px]" + style={{ textAlignVertical: 'top', fontFamily: 'Nunito_400Regular' }} + /> + {imageUri && ( + + + setImageUri(null)} + className="absolute top-2 right-2 w-7 h-7 rounded-full bg-black/50 items-center justify-center" + > + + + + )} + + + + {showActions && ( + + ({ opacity: pressed ? 0.6 : 1 })} + > + + {t('community.image')} + + + + + {t('common.cancel')} + + ({ + opacity: pressed || !content.trim() || posting ? 0.5 : 1, + })} + > + {posting ? ( + + ) : ( + {t('community.share')} + )} + + + + )} + + ); +} diff --git a/apps/rebreak-native/components/ConfirmAlert.tsx b/apps/rebreak-native/components/ConfirmAlert.tsx new file mode 100644 index 0000000..9d502dd --- /dev/null +++ b/apps/rebreak-native/components/ConfirmAlert.tsx @@ -0,0 +1,204 @@ +import { useEffect, useRef } from 'react'; +import { Modal, View, Text, Pressable, Animated, Easing } from 'react-native'; +// Wichtig (UX-Entscheidung 2026-05-05): Icon im Confirm-Modal NICHT animieren — +// User sieht zwei Modals nacheinander (Confirm → Success), beide animierte Icons +// = visuelle Doppel-Eskalation, wirkt verwirrend. Daher: Card animiert auf, +// Icon erscheint statisch (kein scale-pop). Nur das nachfolgende SuccessAlert +// behält seine Icon-Animation als "Belohnungs-Moment". +import { Ionicons } from '@expo/vector-icons'; +import { useTranslation } from 'react-i18next'; + +type Props = { + visible: boolean; + title: string; + message?: string; + /** Default: i18n "common.cancel" */ + cancelLabel?: string; + /** Default: i18n "common.confirm" */ + confirmLabel?: string; + /** Wenn true: Confirm-Button rot statt blau (für destructive Actions). */ + destructive?: boolean; + /** Icon im Top-Circle. Default: question-mark. */ + icon?: React.ComponentProps['name']; + /** Icon-Circle-Color. Default: #007AFF (iOS-blue). */ + iconColor?: string; + onConfirm: () => void; + onCancel: () => void; +}; + +/** + * Animiertes iOS-style Confirm-Modal — gleicher Animations-Stil wie SuccessAlert, + * aber mit zwei Buttons (Cancel + Confirm). Tap-outside cancelt. + */ +export function ConfirmAlert({ + visible, + title, + message, + cancelLabel, + confirmLabel, + destructive = false, + icon = 'help-circle', + iconColor = '#007AFF', + onConfirm, + onCancel, +}: Props) { + const { t } = useTranslation(); + const resolvedCancelLabel = cancelLabel ?? t('common.cancel'); + const resolvedConfirmLabel = confirmLabel ?? t('common.confirm'); + const cardScale = useRef(new Animated.Value(0.8)).current; + const cardOpacity = useRef(new Animated.Value(0)).current; + + useEffect(() => { + if (visible) { + cardScale.setValue(0.8); + cardOpacity.setValue(0); + + Animated.parallel([ + Animated.spring(cardScale, { + toValue: 1, + useNativeDriver: true, + friction: 7, + tension: 80, + }), + Animated.timing(cardOpacity, { + toValue: 1, + duration: 220, + useNativeDriver: true, + easing: Easing.out(Easing.cubic), + }), + ]).start(); + } + }, [visible, cardScale, cardOpacity]); + + const confirmBg = destructive ? '#FF3B30' : '#007AFF'; + + return ( + + + {}} style={{ width: '85%', maxWidth: 340 }}> + + {/* Icon-Circle — statisch (keine Pop-Animation, siehe Header-Comment). */} + + + + + + {title} + + {message && ( + + {message} + + )} + + {/* Two buttons row */} + + + + + {resolvedCancelLabel} + + + + + + + {resolvedConfirmLabel} + + + + + + + + + ); +} diff --git a/apps/rebreak-native/components/EmptyState.tsx b/apps/rebreak-native/components/EmptyState.tsx new file mode 100644 index 0000000..0abba88 --- /dev/null +++ b/apps/rebreak-native/components/EmptyState.tsx @@ -0,0 +1,25 @@ +import { View, Text } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import type { ComponentProps } from 'react'; + +type Props = { + icon: ComponentProps['name']; + title: string; + subtitle?: string; + children?: React.ReactNode; +}; + +export function EmptyState({ icon, title, subtitle, children }: Props) { + return ( + + + + + {title} + {subtitle ? ( + {subtitle} + ) : null} + {children ? {children} : null} + + ); +} diff --git a/apps/rebreak-native/components/HeroShieldCheck.tsx b/apps/rebreak-native/components/HeroShieldCheck.tsx new file mode 100644 index 0000000..eca2958 --- /dev/null +++ b/apps/rebreak-native/components/HeroShieldCheck.tsx @@ -0,0 +1,23 @@ +// HeroIcons shield-check — Replacement für Ionicons "shield-checkmark" +// (User-Entscheidung 2026-05-05: HeroIcons-Stil gefällt besser für Domain- +// Approved-Indikator). Inline-SVG via react-native-svg, kein Extra-Package. +// +// Quelle: heroicons.com/solid → shield-check (Apache 2.0) +import Svg, { Path } from 'react-native-svg'; + +type Props = { + size?: number; + color?: string; +}; + +export function HeroShieldCheck({ size = 18, color = '#22c55e' }: Props) { + return ( + + + + ); +} diff --git a/apps/rebreak-native/components/IconButton.tsx b/apps/rebreak-native/components/IconButton.tsx new file mode 100644 index 0000000..3eac807 --- /dev/null +++ b/apps/rebreak-native/components/IconButton.tsx @@ -0,0 +1,31 @@ +import { Pressable } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import type { PressableProps } from 'react-native'; +import type { ComponentProps } from 'react'; + +type Props = PressableProps & { + name: ComponentProps['name']; + size?: number; + color?: string; + className?: string; + badge?: number; +}; + +export function IconButton({ + name, + size = 22, + color = '#0a0a0a', + className = '', + badge, + ...rest +}: Props) { + return ( + + + + ); +} diff --git a/apps/rebreak-native/components/NativeTabs.tsx b/apps/rebreak-native/components/NativeTabs.tsx new file mode 100644 index 0000000..d37cf63 --- /dev/null +++ b/apps/rebreak-native/components/NativeTabs.tsx @@ -0,0 +1,152 @@ +import { + createNavigatorFactory, + TabRouter, + useNavigationBuilder, + type DefaultNavigatorOptions, + type NavigationProp, + type ParamListBase, + type TabActionHelpers, + type TabNavigationState, + type TabRouterOptions, +} from '@react-navigation/native'; +import { withLayoutContext } from 'expo-router'; +import type { ImageSourcePropType } from 'react-native'; +import TabView, { type AppleIcon } from 'react-native-bottom-tabs'; + +// Pro-Screen Optionen (kompatibel mit Expo Router's Tabs.Screen API) +export type NativeTabsScreenOptions = { + title?: string; + tabBarIcon?: (props: { focused: boolean }) => AppleIcon | ImageSourcePropType; + tabBarBadge?: string; + // Expo-Router-Konvention: href === null → Screen NICHT in TabBar zeigen + href?: string | null; +}; + +type NativeTabNavigationEventMap = { + tabPress: { data: undefined; canPreventDefault: true }; + tabLongPress: { data: undefined }; +}; + +// Native-spezifische Tab-Layer-Optionen (iOS 26 Glass-Pill, Haptik, etc.) +type NativeOnlyOptions = { + sidebarAdaptable?: boolean; + hapticFeedbackEnabled?: boolean; + disablePageAnimations?: boolean; + scrollEdgeAppearance?: 'default' | 'opaque' | 'transparent'; + minimizeBehavior?: 'automatic' | 'onScrollDown' | 'onScrollUp' | 'never'; + tabBarActiveTintColor?: string; + tabBarInactiveTintColor?: string; + labeled?: boolean; + tabLabelStyle?: { + fontFamily?: string; + fontWeight?: string; + fontSize?: number; + }; +}; + +type Props = DefaultNavigatorOptions< + ParamListBase, + string | undefined, + TabNavigationState, + NativeTabsScreenOptions, + NativeTabNavigationEventMap, + NavigationProp +> & + TabRouterOptions & + NativeOnlyOptions; + +function NativeTabsNavigator({ + id, + initialRouteName, + children, + screenOptions, + layout, + sidebarAdaptable = true, + hapticFeedbackEnabled = true, + disablePageAnimations, + scrollEdgeAppearance, + minimizeBehavior, + tabBarActiveTintColor, + tabBarInactiveTintColor, + labeled = true, + tabLabelStyle, +}: Props) { + const { state, descriptors, navigation, NavigationContent } = + useNavigationBuilder< + TabNavigationState, + TabRouterOptions, + TabActionHelpers, + NativeTabsScreenOptions, + NativeTabNavigationEventMap + >(TabRouter, { + id, + initialRouteName, + children, + screenOptions, + layout, + }); + + return ( + + { + const options = descriptors[route.key].options; + return { + key: route.key, + title: options.title ?? route.name, + focusedIcon: options.tabBarIcon + ? options.tabBarIcon({ focused: true }) + : undefined, + unfocusedIcon: options.tabBarIcon + ? options.tabBarIcon({ focused: false }) + : undefined, + badge: options.tabBarBadge, + hidden: options.href === null, + }; + }), + }} + onIndexChange={(index) => { + const route = state.routes[index]; + const event = navigation.emit({ + type: 'tabPress', + target: route.key, + canPreventDefault: true, + }); + if (!event.defaultPrevented) { + navigation.dispatch({ + type: 'NAVIGATE', + payload: { name: route.name, merge: true }, + target: state.key, + }); + } + }} + onTabLongPress={(index) => { + const route = state.routes[index]; + navigation.emit({ + type: 'tabLongPress', + target: route.key, + }); + }} + renderScene={({ route }) => descriptors[route.key].render()} + /> + + ); +} + +export const createNativeTabNavigator = createNavigatorFactory(NativeTabsNavigator); + +const NativeTabNav = createNativeTabNavigator(); + +// withLayoutContext-wrapped Navigator für Expo-Router-Kompatibilität +export const NativeTabs = withLayoutContext(NativeTabNav.Navigator); diff --git a/apps/rebreak-native/components/NotificationsDropdown.tsx b/apps/rebreak-native/components/NotificationsDropdown.tsx new file mode 100644 index 0000000..aa79605 --- /dev/null +++ b/apps/rebreak-native/components/NotificationsDropdown.tsx @@ -0,0 +1,321 @@ +import { useEffect, useRef } from 'react'; +import { View, Text, Pressable, Modal, FlatList, Animated, Image } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { useRouter, type RelativePathString } from 'expo-router'; +import { useTranslation } from 'react-i18next'; +import { useNotificationStore, type AppNotification } from '../stores/notifications'; +import { resolveAvatar } from '../lib/resolveAvatar'; +import { HeroShieldCheck } from './HeroShieldCheck'; + +type Props = { + visible: boolean; + onClose: () => void; + /** Distanz vom oberen Rand bis Dropdown anchor (Header-Höhe inkl. SafeArea) */ + topOffset: number; +}; + +export function NotificationsDropdown({ visible, onClose, topOffset }: Props) { + const { t } = useTranslation(); + const router = useRouter(); + const items = useNotificationStore((s) => s.items); + const loaded = useNotificationStore((s) => s.loaded); + const load = useNotificationStore((s) => s.load); + const markRead = useNotificationStore((s) => s.markRead); + const unread = useNotificationStore((s) => s.unread); + + const opacity = useRef(new Animated.Value(0)).current; + const translateY = useRef(new Animated.Value(-8)).current; + + useEffect(() => { + if (visible) { + if (!loaded) load(); + // Mark as read with delay so user sees the unread highlight briefly + const tm = setTimeout(() => { + if (unread > 0) markRead(); + }, 600); + Animated.parallel([ + Animated.timing(opacity, { toValue: 1, duration: 140, useNativeDriver: true }), + Animated.timing(translateY, { toValue: 0, duration: 160, useNativeDriver: true }), + ]).start(); + return () => clearTimeout(tm); + } + opacity.setValue(0); + translateY.setValue(-8); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [visible]); + + function handleNavigate(n: AppNotification) { + onClose(); + if (n.type === 'domain_accepted' || n.type === 'domain_rejected') { + router.push('/blocker' as RelativePathString); + } else if (n.postId) { + router.push(`/?postId=${n.postId}` as RelativePathString); + } + } + + return ( + + + true} + style={{ + position: 'absolute', + top: topOffset + 6, + right: 12, + backgroundColor: '#ffffff', + borderRadius: 18, + shadowColor: '#000', + shadowOffset: { width: 0, height: 8 }, + shadowOpacity: 0.18, + shadowRadius: 20, + elevation: 12, + width: 320, + maxHeight: 480, + overflow: 'hidden', + opacity, + transform: [{ translateY }], + }} + > + {/* Header */} + + + {t('notifications.title')} + + {unread > 0 && ( + markRead()} hitSlop={6}> + + {t('notifications.mark_all_read')} + + + )} + + + {items.length === 0 ? ( + + + + {t('notifications.empty_title')} + + + {t('notifications.empty_subtitle')} + + + ) : ( + n.id} + renderItem={({ item }) => ( + handleNavigate(item)} + t={t} + /> + )} + /> + )} + + + + ); +} + +function notifLabel(n: AppNotification, t: (k: string, opts?: any) => string): string { + switch (n.type) { + case 'new_like': + return `${n.actorName} ${t('notifications.liked_post')}`; + case 'new_comment': + return `${n.actorName} ${t('notifications.commented_post')}`; + case 'domain_vote': + return `${n.actorName} ${t('notifications.voted_domain')}`; + case 'domain_accepted': + return n.preview + ? `${n.preview} ${t('notifications.domain_accepted')}` + : t('notifications.domain_accepted'); + case 'domain_rejected': + return n.preview + ? `${n.preview} ${t('notifications.domain_rejected')}` + : t('notifications.domain_rejected'); + case 'new_follower': + return `${n.actorName} ${t('notifications.new_follower')}`; + default: + return `${n.actorName} ${t('notifications.generic')}`; + } +} + +function notifIcon(type: string): { + icon: React.ComponentProps['name']; + color: string; + bg: string; +} { + switch (type) { + case 'new_like': + return { icon: 'heart', color: '#dc2626', bg: '#fee2e2' }; + case 'new_comment': + return { icon: 'chatbubble-ellipses', color: '#2563eb', bg: '#dbeafe' }; + case 'domain_accepted': + return { icon: 'shield-checkmark', color: '#16a34a', bg: '#dcfce7' }; + case 'domain_rejected': + return { icon: 'close-circle', color: '#dc2626', bg: '#fee2e2' }; + case 'domain_vote': + return { icon: 'thumbs-up', color: '#d97706', bg: '#fef3c7' }; + case 'new_follower': + return { icon: 'person-add', color: '#7c3aed', bg: '#ede9fe' }; + default: + return { icon: 'notifications', color: '#737373', bg: '#f5f5f5' }; + } +} + +function timeAgo(dateStr: string, t: (k: string, opts?: any) => string): string { + const m = Math.floor((Date.now() - new Date(dateStr).getTime()) / 60000); + if (m < 1) return t('notifications.just_now'); + if (m < 60) return t('notifications.min_ago', { n: m }); + const h = Math.floor(m / 60); + if (h < 24) return t('notifications.hours_ago', { n: h }); + return t('notifications.days_ago', { n: Math.floor(h / 24) }); +} + +function NotificationRow({ + notif, + onPress, + t, +}: { + notif: AppNotification; + onPress: () => void; + t: (k: string, opts?: any) => string; +}) { + const isUnread = !notif.readAt; + const { icon, color, bg } = notifIcon(notif.type); + const isSocial = + notif.type === 'new_like' || + notif.type === 'new_comment' || + notif.type === 'new_follower' || + notif.type === 'domain_vote'; + // System-Notifications (von ReBreak selbst) bekommen das App-Icon als Avatar + const isSystem = + notif.type === 'domain_accepted' || + notif.type === 'domain_rejected' || + (notif.actorName ?? '').toLowerCase().startsWith('rebreak'); + const avatarUrl = isSocial ? resolveAvatar(notif.actorAvatar, notif.actorName) : null; + + return ( + ({ + opacity: pressed ? 0.65 : 1, + backgroundColor: isUnread ? '#fff7ed' : '#ffffff', + })} + > + + {/* Avatar-Logik: + - Social: User-Avatar mit kleinem Type-Badge + - System (ReBreak): App-Icon mit kleinem Type-Badge + - Sonst: Typed Icon */} + {avatarUrl ? ( + // Social: Avatar mit kleinem Mini-Badge — Badge ohne weißen Ring (clean). + + + + + + + ) : ( + // System (domain_accepted/rejected/etc.) + Fallback: NUR clean Icon, + // kein Avatar-Overlay mehr — vorher hatte ReBreak-App-Icon mit + // Shield-Badge-Overlay den Logo verdeckt (User-Feedback 2026-05-05). + + {notif.type === 'domain_accepted' ? ( + + ) : ( + + )} + + )} + + + {notifLabel(notif, t)} + + + {timeAgo(notif.createdAt, t)} + + + + + ); +} diff --git a/apps/rebreak-native/components/PostCard.tsx b/apps/rebreak-native/components/PostCard.tsx new file mode 100644 index 0000000..5f7cb84 --- /dev/null +++ b/apps/rebreak-native/components/PostCard.tsx @@ -0,0 +1,558 @@ +import { memo, useState, useCallback, useRef, useEffect } from 'react'; +import { View, Text, Pressable, Image, Animated } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { useQueryClient } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; +import { apiFetch } from '../lib/api'; +import { resolveAvatar } from '../lib/resolveAvatar'; +import { formatRelativeTime } from '../lib/formatTime'; +import { useCommunityStore, type CommunityPost } from '../stores/community'; +import { RiveAvatar } from './RiveAvatar'; +import { HeroShieldCheck } from './HeroShieldCheck'; + +type Props = { + post: CommunityPost; + onCommentPress: (postId: string) => void; +}; + +function PostCardImpl({ post, onCommentPress }: Props) { + const { t } = useTranslation(); + const queryClient = useQueryClient(); + // Granular selectors — subscribing to the whole store would re-render every + // PostCard whenever any user likes any post (optimisticLikes mutates). + const applyOptimisticLike = useCommunityStore((s) => s.applyOptimisticLike); + const revertOptimisticLike = useCommunityStore((s) => s.revertOptimisticLike); + const clearOptimisticLike = useCommunityStore((s) => s.clearOptimisticLike); + + const [localLike, setLocalLike] = useState<'like' | null>(post.userLike === 'like' ? 'like' : null); + const [localCount, setLocalCount] = useState(post.likesCount); + const [isLiking, setIsLiking] = useState(false); + + // Heart-Pop Animation — Insta-Style: quick scale-up + spring-bounce back + const heartScale = useRef(new Animated.Value(1)).current; + const triggerHeartPop = useCallback(() => { + heartScale.setValue(1); + Animated.sequence([ + Animated.timing(heartScale, { + toValue: 1.4, + duration: 120, + useNativeDriver: true, + }), + Animated.spring(heartScale, { + toValue: 1, + friction: 4, + tension: 80, + useNativeDriver: true, + }), + ]).start(); + }, [heartScale]); + + const displayAuthor = post.repostOf ? post.repostOf.author : post.author; + const displayContent = post.repostOf ? post.repostOf.content : post.content; + const displayImage = post.repostOf ? post.repostOf.imageUrl : post.imageUrl; + + // Image aspect-ratio: ermittelt aus onLoad event.source.{width,height}. + // Fallback während loading = 1.78 (16:9). Clamp 0.6..1.78 verhindert, + // dass hyper-portrait-Bilder (9:21) den ganzen Screen einnehmen. + const [imageAspectRatio, setImageAspectRatio] = useState(null); + // Reset bei post-change (FlatList-Recycling). + useEffect(() => { + setImageAspectRatio(null); + }, [displayImage]); + + const authorLabel = post.isAnonymous || !displayAuthor.id ? t('community.anonymous_label') : displayAuthor.nickname; + + // Lyra bot posts use the RiveAvatar (sm = 40px circle). All other bots and + // regular users use the image/initials fallback path. + const isLyraPost = post.isBot && post.botType === 'lyra'; + + // Avatar: only render Image if author has avatar id; resolveAvatar returns the URL. + // On image-load error or missing avatar id → initials fallback. + const hasAvatar = !!displayAuthor.avatar && !post.isAnonymous && !isLyraPost; + const avatarUrl = hasAvatar ? resolveAvatar(displayAuthor.avatar, displayAuthor.nickname) : ''; + const [avatarLoadFailed, setAvatarLoadFailed] = useState(false); + // Reset error-state when post (or its avatar) changes — list-virtualization may reuse component. + useEffect(() => { + setAvatarLoadFailed(false); + }, [avatarUrl]); + const showAvatarImage = hasAvatar && !avatarLoadFailed; + const avatarInitials = ( + authorLabel.charAt(0) + (authorLabel.charAt(1) ?? '') + ).toUpperCase() || '?'; + + // domain_approved: extract domain name from Google favicon URL stored in imageUrl + const approvedDomain = (() => { + if (post.category !== 'domain_approved' || !displayImage) return null; + try { return new URL(displayImage).searchParams.get('domain'); } catch { return null; } + })(); + + // domain_vote vote action — delegate up via apiFetch, no local store mutation needed + // (realtime hook invalidates query on submission UPDATE) + const [voting, setVoting] = useState(false); + const [localVote, setLocalVote] = useState<'yes' | 'no' | null>(post.userVote ?? null); + const [localYes, setLocalYes] = useState(post.submission?.yesVotes ?? 0); + const [localNo, setLocalNo] = useState(post.submission?.noVotes ?? 0); + + useEffect(() => { + setLocalVote(post.userVote ?? null); + setLocalYes(post.submission?.yesVotes ?? 0); + setLocalNo(post.submission?.noVotes ?? 0); + }, [post.userVote, post.submission?.yesVotes, post.submission?.noVotes]); + + const handleVote = useCallback(async (vote: 'yes' | 'no') => { + if (voting || !post.submission?.id || localVote) return; + setVoting(true); + setLocalVote(vote); + if (vote === 'yes') setLocalYes((n) => n + 1); + else setLocalNo((n) => n + 1); + try { + const res = await apiFetch<{ yesVotes: number; noVotes: number; movedToReview: boolean }>( + `/api/domain-submissions/${post.submission.id}/vote`, + { method: 'POST', body: { vote } }, + ); + setLocalYes(res.yesVotes); + setLocalNo(res.noVotes); + queryClient.invalidateQueries({ queryKey: ['community-posts'] }); + } catch { + setLocalVote(null); + if (vote === 'yes') setLocalYes((n) => Math.max(0, n - 1)); + else setLocalNo((n) => Math.max(0, n - 1)); + } finally { + setVoting(false); + } + }, [voting, localVote, post.submission?.id, queryClient]); + + const authorDescription = (() => { + if (post.isBot) return post.botType === 'rebreak' ? t('community.bot_admin') : t('community.bot_ai'); + if (post.isAnonymous || !displayAuthor.id) return undefined; + const plan = displayAuthor.plan; + if (plan === 'legend') return t('community.tier_legend'); + if (plan === 'pro') return t('community.tier_pro'); + return t('community.tier_starter'); + })(); + + const handleLike = useCallback(async () => { + if (isLiking) return; + triggerHeartPop(); + const { newLike, newCount } = applyOptimisticLike(post.id, localLike, localCount); + setLocalLike(newLike); + setLocalCount(newCount); + setIsLiking(true); + try { + const res = await apiFetch<{ + likesCount: number; + dislikesCount: number; + userLike: 'like' | 'dislike' | null; + }>('/api/community/like', { + method: 'POST', + body: { postId: post.id, type: 'like' }, + }); + setLocalCount(res.likesCount); + setLocalLike(res.userLike === 'like' ? 'like' : null); + clearOptimisticLike(post.id); + // KEIN queryClient.invalidateQueries — würde die komplette Liste neu laden, + // PostCard remounted, Heart-Pop-Animation abgebrochen. Local-State reicht. + } catch { + revertOptimisticLike(post.id); + setLocalLike(post.userLike === 'like' ? 'like' : null); + setLocalCount(post.likesCount); + } finally { + setIsLiking(false); + } + }, [isLiking, localLike, localCount, post.id, post.userLike, post.likesCount, applyOptimisticLike, clearOptimisticLike, revertOptimisticLike, queryClient, triggerHeartPop]); + + return ( + + {/* Repost header */} + {post.repostOf && ( + + + + {post.author.nickname} {t('community.reposted_suffix')} + + + )} + + {/* Author + Meta */} + + + {isLyraPost ? ( + // Lyra bot posts use the animated Rive avatar at sm (40px). + // The RiveAvatar sm-variant has no border/shadow by design — fits tight in list. + + ) : showAvatarImage ? ( + setAvatarLoadFailed(true)} + className="w-10 h-10 rounded-full bg-neutral-100" + /> + ) : ( + + + {avatarInitials} + + + )} + + + {authorLabel} + + {authorDescription !== undefined && ( + {authorDescription} + )} + + + + {formatRelativeTime(post.createdAt)} + + + + {/* Content — hidden for domain_vote (replaced by poll below) */} + {!!displayContent && post.category !== 'domain_vote' && ( + + {displayContent} + + )} + + {/* domain_approved: favicon + domain name + shield badge */} + {post.category === 'domain_approved' && !!approvedDomain && ( + + + + + {approvedDomain} + + + {t('community.domain_added_to_blocklist')} + + + + + )} + + {/* domain_vote: poll card with domain banner + yes/no bars + vote buttons */} + {post.category === 'domain_vote' && !!post.submission && ( + + )} + + {/* Image — respektiert echtes Aspect-Ratio (portrait/square/landscape), + clamped 0.6..1.78 damit 9:21-Storys nicht den ganzen Screen einnehmen. */} + {!!displayImage && post.category !== 'domain_approved' && post.category !== 'domain_vote' && ( + { + const { width, height } = e.nativeEvent.source; + if (width && height) { + const ratio = width / height; + setImageAspectRatio(Math.max(0.6, Math.min(1.78, ratio))); + } + }} + className="w-full rounded-xl mt-3" + style={{ aspectRatio: imageAspectRatio ?? 1.78 }} + resizeMode="cover" + /> + )} + + {/* Actions: Like, Comment — not shown for domain_vote */} + {/* HitSlop +12pt rundum → effektiver Touch-Bereich ~44pt (HIG-Min). */} + {/* Vorher: Tap-Area = nur Icon-Größe (~21pt) → User-Feedback "reagiert nicht beim 1. Klick". */} + {post.category !== 'domain_vote' && ( + + ({ opacity: pressed ? 0.5 : 1, transform: [{ scale: pressed ? 0.94 : 1 }] })} + > + + + + {localCount > 0 && ( + {localCount} + )} + + + onCommentPress(post.id)} + hitSlop={{ top: 12, bottom: 12, left: 12, right: 12 }} + android_ripple={{ color: 'rgba(0,0,0,0.08)', borderless: true, radius: 22 }} + className="flex-row items-center gap-1.5" + style={({ pressed }) => ({ opacity: pressed ? 0.5 : 1, transform: [{ scale: pressed ? 0.94 : 1 }] })} + > + + {post.commentsCount > 0 && ( + {post.commentsCount} + )} + + + )} + + ); +} + +// React.memo with a shallow compare on the fields that actually drive visible +// content. Without this, every realtime patch (likes/comments/domain-vote) +// re-mapping the posts array would re-render every visible PostCard, even +// though only one item changed. onCommentPress is expected stable from parent. +export const PostCard = memo(PostCardImpl, (prev, next) => { + if (prev.onCommentPress !== next.onCommentPress) return false; + const a = prev.post; + const b = next.post; + if (a === b) return true; + if (a.id !== b.id) return false; + if (a.likesCount !== b.likesCount) return false; + if (a.dislikesCount !== b.dislikesCount) return false; + if (a.commentsCount !== b.commentsCount) return false; + if (a.repostsCount !== b.repostsCount) return false; + if (a.userLike !== b.userLike) return false; + if (a.userVote !== b.userVote) return false; + if (a.content !== b.content) return false; + if (a.imageUrl !== b.imageUrl) return false; + if (a.category !== b.category) return false; + if (a.isAnonymous !== b.isAnonymous) return false; + if (a.challengeStatus !== b.challengeStatus) return false; + if (a.isLive !== b.isLive) return false; + // submission shallow compare on the fields the card reads + const sa = a.submission; + const sb = b.submission; + if (!!sa !== !!sb) return false; + if (sa && sb) { + if (sa.id !== sb.id) return false; + if (sa.status !== sb.status) return false; + if (sa.yesVotes !== sb.yesVotes) return false; + if (sa.noVotes !== sb.noVotes) return false; + if (sa.reviewedAt !== sb.reviewedAt) return false; + } + // author identity (anonymous toggling, avatar swap) + if (a.author.id !== b.author.id) return false; + if (a.author.avatar !== b.author.avatar) return false; + if (a.author.nickname !== b.author.nickname) return false; + if (a.author.plan !== b.author.plan) return false; + // repostOf identity + const ra = a.repostOf; + const rb = b.repostOf; + if (!!ra !== !!rb) return false; + if (ra && rb) { + if (ra.content !== rb.content) return false; + if (ra.imageUrl !== rb.imageUrl) return false; + if (ra.author.id !== rb.author.id) return false; + if (ra.author.nickname !== rb.author.nickname) return false; + if (ra.author.avatar !== rb.author.avatar) return false; + } + return true; +}); + +// ── Domain Favicon ───────────────────────────────────────────────────────── +// Google S2 favicon-API as in Nuxt PostCard — on error: letter-avatar fallback. +type DomainFaviconProps = { domain: string; size: number }; + +function DomainFavicon({ domain, size }: DomainFaviconProps) { + const [failed, setFailed] = useState(false); + const uri = `https://www.google.com/s2/favicons?domain=${encodeURIComponent(domain)}&sz=64`; + const letter = domain.charAt(0).toUpperCase(); + + if (failed) { + return ( + + + {letter} + + + ); + } + + return ( + setFailed(true)} + /> + ); +} + +// ── Domain Vote Poll Card ─────────────────────────────────────────────────── +type Submission = NonNullable; + +type DomainVoteCardProps = { + submission: Submission; + localYes: number; + localNo: number; + localVote: 'yes' | 'no' | null; + voting: boolean; + isOwnPost: boolean; + onVote: (v: 'yes' | 'no') => void; + // TFunction from react-i18next has a complex overload signature; using + // a simple callable type avoids generics noise here. + t: (key: string) => string; +}; + +function DomainVoteCard({ + submission, + localYes, + localNo, + localVote, + voting, + isOwnPost, + onVote, + t, +}: DomainVoteCardProps) { + const total = localYes + localNo; + const yesWidth = total === 0 ? 0 : Math.round((localYes / 10) * 100); + // No-bar is relative to yes + no total to mirror Nuxt logic + const noWidth = total === 0 ? 0 : Math.round((localNo / Math.max(total, localYes)) * 100); + + const isPending = submission.status === 'pending' || submission.status === 'in_review'; + const isApproved = submission.status === 'approved'; + + const statusLabel = (() => { + if (isApproved) return submission.reviewedAt ? `Approved ${formatApprovedDate(submission.reviewedAt)}` : 'Global'; + if (submission.status === 'rejected') return t('community.vote_rejected'); + if (submission.status === 'in_review') return t('community.vote_in_review'); + return `${localYes} / 10`; + })(); + + return ( + + {/* Header: label + status badge */} + + + + + {t('community.domain_proposal_label')} + + + + + {statusLabel} + + + + + {/* Domain card */} + + + + + {submission.domain} + + + {isApproved ? t('community.domain_added') : t('community.domain_proposed')} + + + + + + {/* Yes bar */} + + + + + + {t('community.vote_yes')} + + + + {localYes} / 10 + + + + + + + + {/* No bar */} + + + + + + {t('community.vote_no')} + + + + {localNo} + + + + + + + + {/* Vote buttons — only for pending + not own post + not already voted */} + {isPending && !isOwnPost && !localVote && ( + + onVote('yes')} + disabled={voting} + className="flex-1 flex-row items-center justify-center gap-1.5 h-9 rounded-xl border border-rebreak-500" + style={({ pressed }) => ({ opacity: pressed || voting ? 0.5 : 1 })} + > + + + {t('community.vote_yes')} + + + onVote('no')} + disabled={voting} + className="flex-1 flex-row items-center justify-center gap-1.5 h-9 rounded-xl border border-neutral-300" + style={({ pressed }) => ({ opacity: pressed || voting ? 0.5 : 1 })} + > + + + {t('community.vote_no')} + + + + )} + + {/* Already voted indicator */} + {isPending && !isOwnPost && !!localVote && ( + + {t('community.voted_thanks')} + + )} + + {/* Own post indicator */} + {isPending && isOwnPost && ( + + {t('community.domain_vote_own')} + + )} + + ); +} + +function formatApprovedDate(dateStr: string): string { + try { + const d = new Date(dateStr); + return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }); + } catch { return ''; } +} diff --git a/apps/rebreak-native/components/PostCardSkeleton.tsx b/apps/rebreak-native/components/PostCardSkeleton.tsx new file mode 100644 index 0000000..f5edf20 --- /dev/null +++ b/apps/rebreak-native/components/PostCardSkeleton.tsx @@ -0,0 +1,18 @@ +import { View } from 'react-native'; + +export function PostCardSkeleton() { + return ( + + + + + + + + + + + + + ); +} diff --git a/apps/rebreak-native/components/PostCommentsSheet.tsx b/apps/rebreak-native/components/PostCommentsSheet.tsx new file mode 100644 index 0000000..fb5bcb0 --- /dev/null +++ b/apps/rebreak-native/components/PostCommentsSheet.tsx @@ -0,0 +1,503 @@ +import { useState, useRef, useCallback, useEffect } from 'react'; +import { + View, + Text, + Modal, + FlatList, + TextInput, + Pressable, + Keyboard, + Platform, + ActivityIndicator, + Animated, + PanResponder, + useWindowDimensions, +} from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { Ionicons } from '@expo/vector-icons'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; +import { apiFetch } from '../lib/api'; +import { formatRelativeTime } from '../lib/formatTime'; +import { colors } from '../lib/theme'; +import type { CommunityComment } from '../stores/community'; + +const EMOJIS = ['❤️', '🙌', '🔥', '👏', '😢', '😍', '😮', '😂']; +const SNAP_THRESHOLD = 50; + +type Props = { + postId: string | null; + visible: boolean; + onClose: () => void; +}; + +export function PostCommentsSheet({ postId, visible, onClose }: Props) { + const { t } = useTranslation(); + const insets = useSafeAreaInsets(); + const queryClient = useQueryClient(); + const inputRef = useRef(null); + const [text, setText] = useState(''); + const [submitting, setSubmitting] = useState(false); + const [replyTarget, setReplyTarget] = useState<{ id: string; nickname: string } | null>(null); + const [keyboardHeight, setKeyboardHeight] = useState(0); + + // useWindowDimensions: live-tracking. Auf Android schrumpft `height` wenn die + // Tastatur aufgeht (windowSoftInputMode=adjustResize) — daher dynamisch statt + // `Dimensions.get` (statisch beim Modul-Load). + const { height: SCREEN_HEIGHT } = useWindowDimensions(); + const COLLAPSED_HEIGHT = SCREEN_HEIGHT * 0.65; + const EXPANDED_HEIGHT = SCREEN_HEIGHT * 0.92; + const MIN_HEIGHT = SCREEN_HEIGHT * 0.35; + + // Sheet-Höhe animiert (height-based, bottom: 0 fix → Input bleibt immer am Edge sichtbar). + // Plus separater translateY für die Dismiss-Slide-Animation (native). + const sheetHeight = useRef(new Animated.Value(COLLAPSED_HEIGHT)).current; + const dismissY = useRef(new Animated.Value(0)).current; + const currentHeight = useRef(COLLAPSED_HEIGHT); + + const handleClose = useCallback(() => { + Keyboard.dismiss(); + setText(''); + setReplyTarget(null); + sheetHeight.setValue(COLLAPSED_HEIGHT); + dismissY.setValue(0); + currentHeight.current = COLLAPSED_HEIGHT; + onClose(); + }, [onClose, sheetHeight, dismissY]); + + useEffect(() => { + if (visible) { + sheetHeight.setValue(COLLAPSED_HEIGHT); + dismissY.setValue(0); + currentHeight.current = COLLAPSED_HEIGHT; + } + }, [visible, sheetHeight, dismissY]); + + const panResponder = useRef( + PanResponder.create({ + // Claim Gesture sofort, kein Wartet-bis-5px + onStartShouldSetPanResponder: () => true, + onStartShouldSetPanResponderCapture: () => true, + onMoveShouldSetPanResponder: () => true, + onMoveShouldSetPanResponderCapture: () => true, + onPanResponderTerminationRequest: () => false, + onPanResponderMove: (_, g) => { + // Drag rauf (g.dy < 0) → height grösser. Drag runter → height kleiner. + const next = currentHeight.current - g.dy; + const clamped = Math.max(MIN_HEIGHT - 100, Math.min(EXPANDED_HEIGHT + 20, next)); + sheetHeight.setValue(clamped); + }, + onPanResponderRelease: (_, g) => { + const finalH = currentHeight.current - g.dy; + const velocity = g.vy; // Pixel pro ms (negativ = nach oben, positiv = nach unten) + + // Unter MIN_HEIGHT oder schneller Flick nach unten → dismiss + if (finalH < MIN_HEIGHT || velocity > 1.5) { + Animated.timing(dismissY, { + toValue: SCREEN_HEIGHT, + duration: 200, + useNativeDriver: true, + }).start(() => { + handleClose(); + }); + return; + } + + // Schneller Flick nach oben → auf Maximum schnappen + let target = finalH; + if (velocity < -1.5) { + target = EXPANDED_HEIGHT; + } + + // Clamp auf gültigen Bereich, sonst bleibt's wo der User losgelassen hat + const clamped = Math.max(MIN_HEIGHT, Math.min(EXPANDED_HEIGHT, target)); + + Animated.spring(sheetHeight, { + toValue: clamped, + useNativeDriver: false, + friction: 9, + tension: 70, + }).start(); + currentHeight.current = clamped; + }, + }), + ).current; + + useEffect(() => { + const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow'; + const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide'; + const showSub = Keyboard.addListener(showEvent, (e) => { + setKeyboardHeight(e.endCoordinates.height); + }); + const hideSub = Keyboard.addListener(hideEvent, () => { + setKeyboardHeight(0); + }); + return () => { + showSub.remove(); + hideSub.remove(); + }; + }, []); + + const { data: comments = [], isLoading } = useQuery({ + queryKey: ['post-comments', postId], + queryFn: () => apiFetch(`/api/community/${postId}/comments`), + enabled: !!postId && visible, + staleTime: 30_000, + }); + + const topLevel = comments.filter((c) => !c.parentCommentId); + const repliesFor = (id: string) => comments.filter((c) => c.parentCommentId === id); + + const submit = useCallback(async () => { + if (!text.trim() || !postId) return; + setSubmitting(true); + try { + await apiFetch('/api/community/comment', { + method: 'POST', + body: { + postId, + content: text.trim(), + ...(replyTarget ? { parentCommentId: replyTarget.id } : {}), + }, + }); + setText(''); + setReplyTarget(null); + queryClient.invalidateQueries({ queryKey: ['post-comments', postId] }); + queryClient.invalidateQueries({ queryKey: ['community-posts'] }); + } catch { + // ignore + } finally { + setSubmitting(false); + } + }, [text, postId, replyTarget, queryClient]); + + const likeComment = useCallback( + async (comment: CommunityComment) => { + try { + await apiFetch('/api/community/comment-like', { + method: 'POST', + body: { commentId: comment.id }, + }); + queryClient.invalidateQueries({ queryKey: ['post-comments', postId] }); + } catch { + // ignore + } + }, + [postId, queryClient], + ); + + // Bei offener Tastatur → automatisch expanded + useEffect(() => { + if (keyboardHeight > 0 && currentHeight.current !== EXPANDED_HEIGHT) { + Animated.spring(sheetHeight, { + toValue: EXPANDED_HEIGHT, + useNativeDriver: false, + friction: 9, + tension: 70, + }).start(); + currentHeight.current = EXPANDED_HEIGHT; + } + }, [keyboardHeight, sheetHeight]); + + return ( + + {/* Backdrop — sehr leichter Dim damit man Posts vom Drawer unterscheidet */} + + + {/* Outer: animated height (non-native driver) */} + + {/* Inner: animated transform (native driver) — getrennt damit kein Driver-Mix */} + + {/* Drag-Bar — drag-down dismisst via PanResponder */} + + + + + {/* Header — auch drag-area, kein X-Button */} + + + {t('community.comments_title')} + + + + {/* Comments-Liste */} + {isLoading ? ( + + + + ) : ( + item.id} + style={{ flex: 1 }} + contentContainerStyle={{ paddingVertical: 8, paddingHorizontal: 16 }} + keyboardShouldPersistTaps="handled" + ListEmptyComponent={ + + + {t('community.comments_empty')} + + + } + renderItem={({ item: comment }) => ( + + { + setReplyTarget({ id: comment.id, nickname: comment.authorNickname }); + inputRef.current?.focus(); + }} + onLike={() => likeComment(comment)} + /> + {repliesFor(comment.id).map((reply) => ( + + likeComment(reply)} /> + + ))} + + )} + /> + )} + + {/* Emoji-Bar */} + + {EMOJIS.map((e) => ( + setText((t) => t + e)}> + {e} + + ))} + + + {/* Reply-Context */} + {replyTarget && ( + + + {t('community.reply_to')}{' '} + @{replyTarget.nickname} + + setReplyTarget(null)}> + + + + )} + + {/* Input + Send-Button */} + 0 ? 8 : Math.max(12, insets.bottom), + borderTopWidth: 1, + borderTopColor: '#e5e5e5', + }} + > + + ({ + width: 40, + height: 40, + borderRadius: 20, + backgroundColor: colors.brandOrange, + alignItems: 'center', + justifyContent: 'center', + opacity: pressed || !text.trim() || submitting ? 0.5 : 1, + })} + > + {submitting ? ( + + ) : ( + + )} + + + + + + ); +} + +type CommentRowProps = { + comment: CommunityComment; + isReply?: boolean; + onReply?: () => void; + onLike: () => void; +}; + +function CommentRow({ comment, isReply = false, onReply, onLike }: CommentRowProps) { + const { t } = useTranslation(); + const heartScale = useRef(new Animated.Value(1)).current; + const handleLikeWithPop = useCallback(() => { + heartScale.setValue(1); + Animated.sequence([ + Animated.timing(heartScale, { toValue: 1.4, duration: 120, useNativeDriver: true }), + Animated.spring(heartScale, { toValue: 1, friction: 4, tension: 80, useNativeDriver: true }), + ]).start(); + onLike(); + }, [heartScale, onLike]); + + return ( + + + + {(comment.authorNickname ?? 'AN').slice(0, 2).toUpperCase()} + + + + + + {comment.authorNickname ?? t('community.anonymous_label')} + + + {comment.content} + + + + {formatRelativeTime(comment.createdAt)} + + {!isReply && onReply && ( + + + {t('community.reply')} + + + )} + + + + + + + + {comment.likesCount > 0 && ( + + {comment.likesCount} + + )} + + + ); +} diff --git a/apps/rebreak-native/components/RiveAvatar.tsx b/apps/rebreak-native/components/RiveAvatar.tsx new file mode 100644 index 0000000..254e180 --- /dev/null +++ b/apps/rebreak-native/components/RiveAvatar.tsx @@ -0,0 +1,166 @@ +import { useEffect, useState } from 'react'; +import { View, Text, Platform } from 'react-native'; +import { Asset } from 'expo-asset'; +import Rive, { Fit, Alignment } from 'rive-react-native'; + +// Android: Rive akzeptiert NUR raw-resource oder url, kein file:// uri. +// Asset liegt als android/app/src/main/res/raw/lyra_avatar.riv (gebundelt +// via plugins/with-rive-asset-android.js). resourceName = filename ohne +// extension, lowercase + underscores (Android raw-resource convention). +const ANDROID_RIVE_RESOURCE = 'lyra_avatar'; + +// Modul-Level: nur EINMAL die Asset registrieren + URI cachen. +// In Production-Builds wird die .riv ins App-Bundle gebakt (Asset.localUri +// zeigt sofort auf das Bundle-File). In Dev-Builds wird sie beim ersten Mal +// von Metro gezogen + ins App-Sandbox-Cache geschrieben — danach offline. +const RIVE_MODULE = require('../assets/lyra-avatar.riv'); +let cachedRiveUri: string | null = null; +let preloadPromise: Promise | null = null; + +function preloadRiveAsset(): Promise { + if (cachedRiveUri) return Promise.resolve(cachedRiveUri); + if (preloadPromise) return preloadPromise; + preloadPromise = Asset.fromModule(RIVE_MODULE) + .downloadAsync() + .then((asset) => { + const uri = asset.localUri ?? asset.uri; + cachedRiveUri = uri; + return uri; + }) + .catch((err) => { + console.warn('[RiveAvatar] preload failed:', err?.message ?? err); + preloadPromise = null; + return null; + }); + return preloadPromise; +} + +// Kicke den Preload sofort beim Modul-Import an — damit der erste +// Render bereits die cached URI nutzt (außer im allerersten App-Start). +preloadRiveAsset(); + +export type Emotion = 'idle' | 'happy' | 'thinking' | 'empathy'; + +// Direkte Animation-Namen aus der .riv-Datei (1:1 mit Nuxt BenAvatar.vue). +// "happy" hat zwei Phasen: erst Übergangs-Animation, dann Loop. +const EMOTION_ANIMATIONS: Record = { + idle: 'Idle Loop', + happy: 'idle to Pose 1', + thinking: 'WALK', + empathy: '01 Wave 1', +}; + +const EMOTION_LABELS: Record = { + idle: 'bereit', + happy: 'froh für dich', + thinking: 'überlegt ...', + empathy: 'versteht dich', +}; + +const SIZE_PX: Record<'sm' | 'md' | 'lg', number> = { + sm: 40, + md: 112, + lg: 160, +}; + +type Props = { + emotion: Emotion; + size?: 'sm' | 'md' | 'lg'; + showLabel?: boolean; +}; + +export function RiveAvatar({ emotion, size = 'md', showLabel = false }: Props) { + const px = SIZE_PX[size]; + + // Aktuelle Animation als deklarativer State (kein imperatives ref.play()). + const [currentAnim, setCurrentAnim] = useState(EMOTION_ANIMATIONS.idle); + + // Lokale URI für die .riv-Datei — geht über expo-asset damit der File + // im App-Sandbox gecached wird statt jedes Mal von Metro zu streamen. + // Nach erstem Load funktioniert's auch komplett offline. + const [riveUri, setRiveUri] = useState(cachedRiveUri); + + useEffect(() => { + if (riveUri) return; // schon gecached + let active = true; + preloadRiveAsset().then((uri) => { + if (active && uri) setRiveUri(uri); + }); + return () => { + active = false; + }; + }, [riveUri]); + + useEffect(() => { + if (emotion === 'happy') { + // 2-Phasen-Flow: Übergang (~900ms) → Loop (1:1 wie Nuxt-BenAvatar) + setCurrentAnim('idle to Pose 1'); + const t = setTimeout(() => setCurrentAnim('Pose 1 loop'), 900); + return () => clearTimeout(t); + } + setCurrentAnim(EMOTION_ANIMATIONS[emotion]); + }, [emotion]); + + return ( + + + + {Platform.OS === 'android' ? ( + // Android: Bundle-Resource direkt (kein expo-asset Preload nötig) + + ) : riveUri ? ( + // iOS: file:// URI aus expo-asset Cache funktioniert + + ) : ( + + )} + + + + {showLabel && ( + + {EMOTION_LABELS[emotion]} + + )} + + ); +} diff --git a/apps/rebreak-native/components/StreakBadge.tsx b/apps/rebreak-native/components/StreakBadge.tsx new file mode 100644 index 0000000..cc03ee4 --- /dev/null +++ b/apps/rebreak-native/components/StreakBadge.tsx @@ -0,0 +1,31 @@ +import { View, Text } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { useTranslation } from 'react-i18next'; +import { colors } from '../lib/theme'; + +type Props = { + days: number; + size?: 'sm' | 'md' | 'lg'; +}; + +const sizeMap = { + sm: { number: 'text-2xl', label: 'text-xs', icon: 16, padding: 'px-3 py-2' }, + md: { number: 'text-5xl', label: 'text-sm', icon: 20, padding: 'px-5 py-4' }, + lg: { number: 'text-7xl', label: 'text-base', icon: 24, padding: 'px-6 py-5' }, +}; + +export function StreakBadge({ days, size = 'md' }: Props) { + const { t } = useTranslation(); + const s = sizeMap[size]; + return ( + + + + {days} + + + {days === 1 ? t('streak.label_one') : t('streak.label_other')} {t('streak.label_suffix')} + + + ); +} diff --git a/apps/rebreak-native/components/SuccessAlert.tsx b/apps/rebreak-native/components/SuccessAlert.tsx new file mode 100644 index 0000000..5185b7e --- /dev/null +++ b/apps/rebreak-native/components/SuccessAlert.tsx @@ -0,0 +1,173 @@ +import { useEffect, useRef } from 'react'; +import { Modal, View, Text, Pressable, Animated, Easing } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { useTranslation } from 'react-i18next'; + +type Props = { + visible: boolean; + title: string; + message?: string; + onClose: () => void; +}; + +/** + * iOS-style success alert mit animiertem Check-Icon. + * - Card scaled mit Spring-overshoot rein + * - Check-Icon sequenced danach mit eigenem Spring + rotation-pop + * - Tap auf Backdrop schließt + * - OK-Button schließt + */ +export function SuccessAlert({ visible, title, message, onClose }: Props) { + const { t } = useTranslation(); + const cardScale = useRef(new Animated.Value(0.8)).current; + const cardOpacity = useRef(new Animated.Value(0)).current; + const checkScale = useRef(new Animated.Value(0)).current; + const checkRotate = useRef(new Animated.Value(0)).current; + + useEffect(() => { + if (visible) { + cardScale.setValue(0.8); + cardOpacity.setValue(0); + checkScale.setValue(0); + checkRotate.setValue(0); + + Animated.parallel([ + Animated.spring(cardScale, { + toValue: 1, + useNativeDriver: true, + friction: 7, + tension: 80, + }), + Animated.timing(cardOpacity, { + toValue: 1, + duration: 220, + useNativeDriver: true, + easing: Easing.out(Easing.cubic), + }), + Animated.sequence([ + Animated.delay(140), + Animated.parallel([ + Animated.spring(checkScale, { + toValue: 1, + useNativeDriver: true, + friction: 5, + tension: 180, + }), + Animated.timing(checkRotate, { + toValue: 1, + duration: 380, + useNativeDriver: true, + easing: Easing.out(Easing.back(1.7)), + }), + ]), + ]), + ]).start(); + } + }, [visible, cardScale, cardOpacity, checkScale, checkRotate]); + + const rotateInterpolate = checkRotate.interpolate({ + inputRange: [0, 1], + outputRange: ['-30deg', '0deg'], + }); + + return ( + + {/* Backdrop — Pressable damit Tap-outside schließt */} + + {/* Card — Pressable mit onPress={()=>{}} damit Tap auf Card NICHT bubbelt + * zum Backdrop und das Modal schließt. */} + {}} style={{ width: '85%', maxWidth: 320 }}> + + {/* Animated Check-Circle */} + + + + + + {title} + + {message && ( + + {message} + + )} + + + + {t('common.ok')} + + + + + + + ); +} diff --git a/apps/rebreak-native/components/blocker/AddDomainSheet.tsx b/apps/rebreak-native/components/blocker/AddDomainSheet.tsx new file mode 100644 index 0000000..92f6582 --- /dev/null +++ b/apps/rebreak-native/components/blocker/AddDomainSheet.tsx @@ -0,0 +1,352 @@ +import { useState, useEffect, useRef } from 'react'; +import { + Modal, + View, + Text, + TextInput, + Pressable, + KeyboardAvoidingView, + Platform, + Image, + ActivityIndicator, + Animated, + Dimensions, + Easing, +} from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { Ionicons } from '@expo/vector-icons'; +import { useTranslation } from 'react-i18next'; +import { + isValidDomain, + normalizeDomain, + type Tier, +} from '../../hooks/useCustomDomains'; + +const SCREEN_HEIGHT = Dimensions.get('window').height; +const SHEET_HEIGHT = SCREEN_HEIGHT * 0.65; // wie bei PostCommentsSheet — 65% der Screen-Höhe + +type Props = { + visible: boolean; + tier: Tier; + onClose: () => void; + onAdd: (domain: string) => Promise<{ ok: boolean; error?: string; alreadyGlobal?: boolean }>; +}; + +export function AddDomainSheet({ visible, tier, onClose, onAdd }: Props) { + const { t } = useTranslation(); + const insets = useSafeAreaInsets(); + const [input, setInput] = useState(''); + const [confirmPermanent, setConfirmPermanent] = useState(false); + const [adding, setAdding] = useState(false); + const [error, setError] = useState(null); + + const valid = isValidDomain(input); + const normalized = normalizeDomain(input); + + // Slide-up Animation für die Sheet (translateY von SHEET_HEIGHT → 0) + const translateY = useRef(new Animated.Value(SHEET_HEIGHT)).current; + const backdropOpacity = useRef(new Animated.Value(0)).current; + + useEffect(() => { + if (visible) { + translateY.setValue(SHEET_HEIGHT); + backdropOpacity.setValue(0); + Animated.parallel([ + Animated.timing(translateY, { + toValue: 0, + duration: 280, + easing: Easing.out(Easing.cubic), + useNativeDriver: true, + }), + Animated.timing(backdropOpacity, { + toValue: 1, + duration: 220, + useNativeDriver: true, + }), + ]).start(); + } + }, [visible, translateY, backdropOpacity]); + + function close() { + setInput(''); + setConfirmPermanent(false); + setError(null); + onClose(); + } + + async function handleAdd() { + if (!valid || !confirmPermanent || adding) return; + setAdding(true); + setError(null); + const result = await onAdd(input); + setAdding(false); + if (result.ok) { + close(); + return; + } + if (result.alreadyGlobal) { + setError(t('blocker.add_sheet_already_global', { domain: normalized })); + } else { + setError(result.error ?? t('blocker.add_sheet_add_failed')); + } + } + + const warningText = + tier.plan === 'free' + ? t('blocker.add_sheet_warning_free') + : t('blocker.add_sheet_warning_pro'); + + return ( + + {/* Backdrop — Tap-outside schließt */} + + + + + {/* Sheet — slide-up von unten, 65% der Screen-Höhe */} + + + {/* Drag-handle */} + + + + + {/* Header */} + + + + {t('common.cancel')} + + + + {t('blocker.add_sheet_title')} + + + + + + {/* Input */} + + + {t('blocker.add_sheet_label')} + + { + setInput(v); + setError(null); + }} + placeholder={t('blocker.add_sheet_placeholder')} + placeholderTextColor="#a3a3a3" + autoCapitalize="none" + autoCorrect={false} + autoFocus + keyboardType="url" + returnKeyType="done" + onSubmitEditing={handleAdd} + style={{ + backgroundColor: '#f5f5f5', + borderRadius: 12, + paddingHorizontal: 14, + paddingVertical: 12, + fontSize: 15, + fontFamily: 'Nunito_400Regular', + color: '#0a0a0a', + }} + /> + {input && !valid && ( + + {t('blocker.add_sheet_invalid')} + + )} + + + {/* Preview */} + {valid && ( + + + + {normalized} + + + )} + + {/* Warning */} + {valid && ( + + + + {warningText} + + + )} + + {/* Confirm-Checkbox */} + {valid && ( + setConfirmPermanent((v) => !v)} + style={{ + flexDirection: 'row', + alignItems: 'flex-start', + gap: 10, + paddingVertical: 4, + }} + > + + {confirmPermanent && } + + + {t('blocker.add_sheet_confirm_permanent')} + + + )} + + {/* Error */} + {error && ( + + {error} + + )} + + + + {/* Add-Button */} + ({ + backgroundColor: !valid || !confirmPermanent ? '#d4d4d4' : '#dc2626', + borderRadius: 14, + paddingVertical: 14, + alignItems: 'center', + opacity: pressed ? 0.85 : 1, + marginBottom: insets.bottom > 0 ? 8 : 12, + })} + > + {adding ? ( + + ) : ( + + {t('blocker.add_sheet_title')} + + )} + + + + + + ); +} diff --git a/apps/rebreak-native/components/blocker/CooldownBanner.tsx b/apps/rebreak-native/components/blocker/CooldownBanner.tsx new file mode 100644 index 0000000..161cc4a --- /dev/null +++ b/apps/rebreak-native/components/blocker/CooldownBanner.tsx @@ -0,0 +1,75 @@ +import { View, Text, Pressable, ActivityIndicator } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +type Props = { + remainingFormatted: string; // "23:59:42" + onCancel: () => Promise; +}; + +export function CooldownBanner({ remainingFormatted, onCancel }: Props) { + const { t } = useTranslation(); + const [cancelling, setCancelling] = useState(false); + + async function handleCancel() { + setCancelling(true); + try { + await onCancel(); + } finally { + setCancelling(false); + } + } + + return ( + + + + + {t('blocker.cooldown_banner_title')} + + + {remainingFormatted} + + + ({ + paddingHorizontal: 12, + paddingVertical: 8, + borderRadius: 12, + backgroundColor: '#16a34a', + opacity: pressed || cancelling ? 0.7 : 1, + })} + > + {cancelling ? ( + + ) : ( + + {t('common.cancel')} + + )} + + + ); +} diff --git a/apps/rebreak-native/components/blocker/DeactivationExplainerSheet.tsx b/apps/rebreak-native/components/blocker/DeactivationExplainerSheet.tsx new file mode 100644 index 0000000..dea1390 --- /dev/null +++ b/apps/rebreak-native/components/blocker/DeactivationExplainerSheet.tsx @@ -0,0 +1,227 @@ +import { Modal, View, Text, Pressable, ScrollView, ActionSheetIOS, Platform, Alert } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +type Props = { + visible: boolean; + onClose: () => void; + /** User triggert "Atmen" → Deflector zur Urge-Page (Click-2 ende, kein Cooldown). */ + onBreathe: () => void; + /** Click-3 Bestätigung — final destruktive Aktion via ActionSheet. */ + onStartCooldown: (reason: string) => Promise; +}; + +/** + * Click 2 of 3 (Cooldown-Friction). + * + * Erklärt was Cooldown bedeutet, bietet [Atmen] als primary Deflector an, + * und ein kleines [Cooldown trotzdem starten] das einen nativen ActionSheet + * zur finalen Bestätigung öffnet (Click 3). + */ +export function DeactivationExplainerSheet({ + visible, + onClose, + onBreathe, + onStartCooldown, +}: Props) { + const { t } = useTranslation(); + const [submitting, setSubmitting] = useState(false); + + function showFinalConfirm() { + if (Platform.OS === 'ios') { + ActionSheetIOS.showActionSheetWithOptions( + { + title: t('blocker.deactivation_actionsheet_title'), + message: t('blocker.deactivation_actionsheet_message'), + options: [t('common.cancel'), t('blocker.deactivation_start_cta')], + destructiveButtonIndex: 1, + cancelButtonIndex: 0, + }, + async (idx) => { + if (idx === 1) await runCooldown(); + }, + ); + } else { + // Android Fallback + Alert.alert( + t('blocker.deactivation_actionsheet_title'), + t('blocker.deactivation_actionsheet_message'), + [ + { text: t('common.cancel'), style: 'cancel' }, + { text: t('blocker.deactivation_start_cta'), style: 'destructive', onPress: runCooldown }, + ], + ); + } + } + + async function runCooldown() { + setSubmitting(true); + try { + await onStartCooldown('user_requested_deactivation'); + onClose(); + } catch (e: any) { + Alert.alert(t('common.error'), e?.message ?? t('blocker.deactivation_failed_msg')); + } finally { + setSubmitting(false); + } + } + + return ( + + + {/* Header */} + + + + {t('common.back')} + + + + {t('blocker.deactivation_heading')} + + + + + + + {t('blocker.deactivation_title')} + + + {t('blocker.deactivation_intro')} + + + {/* Was passiert */} + + + + + + + + + {/* Primary Deflector */} + ({ + backgroundColor: '#16a34a', + borderRadius: 14, + paddingVertical: 16, + paddingHorizontal: 16, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 10, + opacity: pressed ? 0.85 : 1, + })} + > + + + {t('blocker.deactivation_breathe_cta')} + + + + {/* Destructive secondary */} + ({ + alignSelf: 'center', + paddingVertical: 12, + opacity: pressed || submitting ? 0.5 : 1, + })} + > + + {submitting ? t('blocker.deactivation_starting') : t('blocker.deactivation_start_anyway')} + + + + + + ); +} + +function BulletRow({ + icon, + title, + text, +}: { + icon: React.ComponentProps['name']; + title: string; + text: string; +}) { + return ( + + + + + + + {title} + + + {text} + + + + ); +} diff --git a/apps/rebreak-native/components/blocker/DomainGrid.tsx b/apps/rebreak-native/components/blocker/DomainGrid.tsx new file mode 100644 index 0000000..7d0b05f --- /dev/null +++ b/apps/rebreak-native/components/blocker/DomainGrid.tsx @@ -0,0 +1,515 @@ +import { useState, useMemo } from 'react'; +import { + View, + Text, + Pressable, + Image, + ActivityIndicator, +} from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { useTranslation } from 'react-i18next'; +import { SuccessAlert } from '../SuccessAlert'; +import { ConfirmAlert } from '../ConfirmAlert'; +import type { CustomDomain, Tier } from '../../hooks/useCustomDomains'; + +// ─── Helpers ───────────────────────────────────────────────────────────── + +function timeAgo(input?: string | Date): string { + if (!input) return ''; + const date = typeof input === 'string' ? new Date(input) : input; + const diffMs = Date.now() - date.getTime(); + const minutes = Math.floor(diffMs / 60_000); + if (minutes < 1) return 'jetzt'; + if (minutes < 60) return `${minutes}m`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h`; + const days = Math.floor(hours / 24); + if (days < 7) return `${days}d`; + const weeks = Math.floor(days / 7); + if (weeks < 5) return `${weeks}w`; + const months = Math.floor(days / 30); + return `${months}mo`; +} + +function timeSinceSubmit(input?: string | Date): string { + if (!input) return ''; + const date = typeof input === 'string' ? new Date(input) : input; + const diffMs = Date.now() - date.getTime(); + const hours = Math.floor(diffMs / 3_600_000); + if (hours < 1) { + const minutes = Math.max(1, Math.floor(diffMs / 60_000)); + return `${minutes} min`; + } + if (hours < 24) return `${hours} Std`; + const days = Math.floor(hours / 24); + return `${days} Tag${days === 1 ? '' : 'e'}`; +} + +type Props = { + domains: CustomDomain[]; + tier: Tier; + onAdd?: () => void; + onSubmit?: (id: string) => Promise<{ ok: boolean }>; + onRemove?: (id: string) => Promise<{ ok: boolean }>; + onUpgradePro?: () => void; +}; + +// Sort-Reihenfolge: User sieht zuerst was Aufmerksamkeit braucht. +// submitted (in Prüfung) > rejected (kann erneut) > active (settled OK) +const STATUS_PRIORITY: Record = { + submitted: 0, + rejected: 1, + active: 2, + approved: 99, +}; + +export function DomainGrid({ domains, tier, onAdd, onSubmit, onUpgradePro }: Props) { + const { t } = useTranslation(); + // Slot-relevante Domains (alles außer approved). Sortiert nach Status-Priority, + // innerhalb gleicher Priority dann newest-first by addedAt. + const visible = useMemo(() => { + return domains + .filter((d) => d.status !== 'approved') + .slice() + .sort((a, b) => { + const pa = STATUS_PRIORITY[a.status] ?? 99; + const pb = STATUS_PRIORITY[b.status] ?? 99; + if (pa !== pb) return pa - pb; + const ta = a.addedAt ? new Date(a.addedAt).getTime() : 0; + const tb = b.addedAt ? new Date(b.addedAt).getTime() : 0; + return tb - ta; + }); + }, [domains]); + + return ( + + {/* Header: Section-Title + Slot-Counter + Add-Button (inline, neben SlotPill) */} + + + {t('blocker.domain_section_title')} + + + + {onAdd && ( + + + + + + )} + + + + {/* Progress-Bar — 3-stufige Color-Schwelle: <60% grün, 60-90% orange, >=90% rot */} + {(() => { + const pct = (tier.usedSlots / tier.domainLimit) * 100; + const barColor = pct >= 90 ? '#dc2626' : pct >= 60 ? '#f59e0b' : '#16a34a'; + return ( + + + + ); + })()} + + {/* Limit-Reached Upsell (nur Free) */} + {tier.atLimit && tier.plan === 'free' && ( + ({ + backgroundColor: '#eff6ff', + borderWidth: 1, + borderColor: '#bfdbfe', + borderRadius: 12, + padding: 12, + flexDirection: 'row', + alignItems: 'center', + gap: 10, + opacity: pressed ? 0.85 : 1, + })} + > + + + + {t('blocker.domain_limit_title')} + + + {t('blocker.domain_limit_desc')} + + + + )} + + {/* Empty State */} + {visible.length === 0 ? ( + + + + {t('blocker.domain_empty')} + + + ) : ( + + )} + + ); +} + +// ─── SlotPill ───────────────────────────────────────────────────────────── + +function SlotPill({ tier }: { tier: Tier }) { + const bg = tier.atLimit ? '#fee2e2' : '#f5f5f5'; + const fg = tier.atLimit ? '#dc2626' : '#525252'; + return ( + + + {tier.usedSlots}/{tier.domainLimit} + + + ); +} + +// ─── Tiles ──────────────────────────────────────────────────────────────── + +function DomainTilesGrid({ + domains, + tier, + onSubmit, +}: { + domains: CustomDomain[]; + tier: Tier; + onSubmit?: (id: string) => Promise<{ ok: boolean }>; +}) { + // 3-Spalten-Grid via flex-wrap. Parent ScrollView (in blocker.tsx) handles scroll — + // KEIN nested ScrollView hier, sonst collabiert der Layout-Pass weil ScrollView + // inner-content-view keine definierte Width für %-basierte Tile-Widths hat. + return ( + + {domains.map((d) => ( + + + + ))} + + ); +} + +function DomainTile({ + domain, + tier, + onSubmit, +}: { + domain: CustomDomain; + tier: Tier; + onSubmit?: (id: string) => Promise<{ ok: boolean }>; +}) { + const { t } = useTranslation(); + const [submitting, setSubmitting] = useState(false); + const [imgError, setImgError] = useState(false); + const [successVisible, setSuccessVisible] = useState(false); + const [successContent, setSuccessContent] = useState<{ title: string; message: string }>({ + title: '', + message: '', + }); + const [confirmVisible, setConfirmVisible] = useState(false); + const stripped = domain.domain.replace(/^www\./, ''); + + const isLegend = tier.plan === 'legend'; + + // statusColor wird auf Badge + Button angewendet. + // iOS-native: blue (active), orange (submitted), red (rejected). + const statusColor = (() => { + switch (domain.status) { + case 'submitted': + return '#f59e0b'; // orange (Voting/Prüfung) + case 'rejected': + return '#FF3B30'; // iOS-red + default: + return '#007AFF'; // iOS-blue (active, "freigeben"-CTA) + } + })(); + + // Time-Color: nur Status die Aufmerksamkeit brauchen (submitted/rejected) sind farbig. + // Active = neutral gray (settled state, kein Alarm-Indikator nötig). + const timeColor = domain.status === 'active' ? '#a3a3a3' : statusColor; + + const badgeLabel = (() => { + switch (domain.status) { + case 'submitted': + return isLegend ? t('blocker.domain_badge_pruefung') : t('blocker.domain_badge_voting'); + case 'rejected': + return t('blocker.domain_badge_rejected'); + default: + return t('blocker.domain_badge_active'); + } + })(); + + // Tier-aware Confirm-Dialog vor Freigabe — Pro geht zu Community-Voting, + // Legend direkt zum ReBreak-Team. Animiertes Modal statt nativem Alert. + const isResubmit = domain.status === 'rejected'; + const confirmTitle = isLegend + ? isResubmit + ? t('blocker.domain_confirm_legend_resubmit') + : t('blocker.domain_confirm_legend_first') + : isResubmit + ? t('blocker.domain_confirm_community_resubmit') + : t('blocker.domain_confirm_community_first'); + const confirmMessage = isLegend + ? t('blocker.domain_confirm_legend_message', { domain: stripped }) + : t('blocker.domain_confirm_community_message', { domain: stripped }); + + function openConfirm() { + if (!onSubmit) return; + setConfirmVisible(true); + } + + async function handleConfirm() { + setConfirmVisible(false); + if (!onSubmit) return; + + setSubmitting(true); + try { + const result = await onSubmit(domain.id); + if (result.ok) { + setSuccessContent({ + title: isLegend ? t('blocker.domain_success_legend_title') : t('blocker.domain_success_community_title'), + message: isLegend + ? t('blocker.domain_success_legend_message') + : t('blocker.domain_success_community_message'), + }); + setSuccessVisible(true); + } + } finally { + setSubmitting(false); + } + } + + const isFreeAndUsed = tier.plan === 'free' && domain.status !== 'active'; + const showSubmit = tier.canSubmit && domain.status === 'active'; + const showResubmit = tier.canSubmit && domain.status === 'rejected'; + const showInPruefungBtn = domain.status === 'submitted'; + + return ( + + {/* Top-Row: Zeit links · Badge rechts — beide in Status-Color (matcht Bottom-Button). */} + + + + + {timeAgo(domain.addedAt)} + + + + + {badgeLabel} + + + + + {/* Mitte: Favicon + Domain-Name (zentriert, flex-1) */} + + {!imgError ? ( + setImgError(true)} + /> + ) : ( + + + {stripped.slice(0, 2).toUpperCase()} + + + )} + + {stripped} + + + + {/* Bottom-Slot: ALWAYS rendered Container (32px), Inhalt je nach Status. + * Garantiert konsistente Tile-Höhe + sichtbaren Button. */} + + {showInPruefungBtn && ( + + + {isLegend ? t('blocker.domain_btn_rebreak_prueft') : t('blocker.domain_btn_in_abstimmung')} + + + )} + {showSubmit && ( + + {submitting ? ( + + ) : ( + + {t('blocker.domain_btn_freigeben')} + + )} + + )} + {showResubmit && ( + + {submitting ? ( + + ) : ( + + {t('blocker.domain_btn_erneut')} + + )} + + )} + + + {/* Confirm-Modal vor Submit (statt nativer Alert.alert — selber animation-Style wie SuccessAlert) */} + setConfirmVisible(false)} + /> + + {/* Success-Alert mit animiertem Check-Icon nach erfolgreichem Submit */} + setSuccessVisible(false)} + /> + + ); +} diff --git a/apps/rebreak-native/components/blocker/LayerSwitchCard.tsx b/apps/rebreak-native/components/blocker/LayerSwitchCard.tsx new file mode 100644 index 0000000..124820f --- /dev/null +++ b/apps/rebreak-native/components/blocker/LayerSwitchCard.tsx @@ -0,0 +1,111 @@ +import { useState } from 'react'; +import { View, Text, Switch, ActivityIndicator } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; + +type Props = { + icon: React.ComponentProps['name']; + title: string; + subtitle: string; + /** Wenn true: zeigt grünen Check statt Switch (Layer ist an). */ + active: boolean; + /** Aktivierung (zeigt System-Dialog). UI hat nur read-on-flow, + * Toggle-off ist nicht hier — passiert nur über Cooldown. */ + onActivate: () => Promise<{ enabled: boolean; error?: string }>; + /** Optional: Hinweistext unter Subtitle für commit-heavy Layer. */ + warning?: string; +}; + +export function LayerSwitchCard({ icon, title, subtitle, active, onActivate, warning }: Props) { + const [busy, setBusy] = useState(false); + + async function handleSwitch(v: boolean) { + if (!v || active || busy) return; + setBusy(true); + try { + await onActivate(); + } finally { + setBusy(false); + } + } + + const iconBg = active ? '#dcfce7' : '#f5f5f5'; + const iconColor = active ? '#16a34a' : '#737373'; + const borderColor = active ? '#86efac' : '#e5e5e5'; + const cardBg = active ? '#f0fdf4' : '#ffffff'; + + return ( + + + + + + + + {title} + + + {subtitle} + + + + {busy ? ( + + ) : active ? ( + + ) : ( + + )} + + + {warning && !active && ( + + + + {warning} + + + )} + + ); +} diff --git a/apps/rebreak-native/components/blocker/ProtectionCard.tsx b/apps/rebreak-native/components/blocker/ProtectionCard.tsx new file mode 100644 index 0000000..f5a9a06 --- /dev/null +++ b/apps/rebreak-native/components/blocker/ProtectionCard.tsx @@ -0,0 +1,167 @@ +import { View, Text, Switch, Pressable, ActivityIndicator } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { useTranslation } from 'react-i18next'; +import type { ProtectionState } from '../../lib/protection'; +import { colors } from '../../lib/theme'; + +type Props = { + state: ProtectionState; + loading: boolean; + /** Aktiviert den Schutz. UI sollte missingLayers den User durchsteppen lassen. */ + onActivate: () => Promise; + /** Click 1 of 3 — öffnet ProtectionDetailsSheet. KEIN direktes deactivate! */ + onPressSettings: () => void; +}; + +export function ProtectionCard({ state, loading, onActivate, onPressSettings }: Props) { + const { t } = useTranslation(); + const isActive = state.phase === 'active' || state.phase === 'cooldownActive'; + const isCooldown = state.phase === 'cooldownActive'; + + const subtitle = (() => { + if (state.phase === 'inactive') return t('blocker.protection_subtitle_inactive'); + if (state.phase === 'cooldownActive') return t('blocker.protection_subtitle_cooldown'); + if (state.plan === 'free') { + return t('blocker.protection_subtitle_free', { count: state.blocklistCount }); + } + if (state.plan === 'legend') { + return t('blocker.protection_subtitle_legend'); + } + return t('blocker.protection_subtitle_pro'); + })(); + + const cardBg = isCooldown ? '#fef3c7' : isActive ? '#dcfce7' : '#ffffff'; + const cardBorder = isCooldown ? '#fcd34d' : isActive ? '#86efac' : '#e5e5e5'; + const iconBg = isCooldown ? '#fde68a' : isActive ? '#bbf7d0' : '#f5f5f5'; + const iconColor = isCooldown ? '#d97706' : isActive ? '#16a34a' : '#a3a3a3'; + + return ( + + + + + + + + {t('blocker.protection_card_title')} + + + {subtitle} + + + + {/* Loading: Spinner. Inactive: Switch zum aktivieren. Active: Settings-Icon */} + {loading ? ( + + ) : isActive ? ( + ({ + width: 36, + height: 36, + borderRadius: 18, + backgroundColor: '#ffffff', + alignItems: 'center', + justifyContent: 'center', + opacity: pressed ? 0.6 : 1, + shadowColor: '#000', + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.05, + shadowRadius: 2, + })} + accessibilityLabel={t('blocker.protection_settings_a11y')} + > + + + ) : ( + { + if (v) onActivate(); + }} + trackColor={{ true: '#16a34a' }} + /> + )} + + + {/* Stats-Row nur wenn aktiv und kein Cooldown */} + {state.phase === 'active' && ( + + + + + + )} + + ); +} + +function Stat({ + label, + value, + valueColor = '#0a0a0a', +}: { + label: string; + value: string; + valueColor?: string; +}) { + return ( + + + {value} + + + {label} + + + ); +} + +function formatCount(n: number): string { + if (n >= 1000) return `${Math.floor(n / 1000)}k+`; + return String(n); +} diff --git a/apps/rebreak-native/components/blocker/ProtectionDetailsSheet.tsx b/apps/rebreak-native/components/blocker/ProtectionDetailsSheet.tsx new file mode 100644 index 0000000..0390065 --- /dev/null +++ b/apps/rebreak-native/components/blocker/ProtectionDetailsSheet.tsx @@ -0,0 +1,703 @@ +import { useEffect, useRef, useState } from 'react'; +import { + Modal, + View, + Text, + Pressable, + ScrollView, + Dimensions, + Animated, + PanResponder, + ActivityIndicator, + Easing, +} from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { useTranslation } from 'react-i18next'; +import Svg, { Path, Circle } from 'react-native-svg'; +import type { ProtectionState } from '../../lib/protection'; +import { apiFetch } from '../../lib/api'; + +type Props = { + visible: boolean; + state: ProtectionState; + onClose: () => void; + onRequestDeactivation: () => void; + onTalkToLyra: () => void; +}; + +type StatsResponse = { + current: number; + weeklyAdded: number; + monthlyAdded: number; + history: { label: string; count: number }[]; + submissions: { inVote: number; inReview: number }; + mySubmissions: { active: number; inVote: number; inReview: number }; + avgPerUser: number; + avgApprovalWaitDays: number; +}; + +const SCREEN_HEIGHT = Dimensions.get('window').height; +const DEFAULT_HEIGHT = SCREEN_HEIGHT * 0.85; +const EXPANDED_HEIGHT = SCREEN_HEIGHT * 0.95; +const MIN_HEIGHT = SCREEN_HEIGHT * 0.4; +const DISMISS_HEIGHT = SCREEN_HEIGHT * 0.3; + +// Brand colors +const HERO_COLOR = '#f97316'; // orange-500 (counter accent) +const SEG_ACTIVE = '#16a34a'; +const SEG_VOTE = '#3b82f6'; +const SEG_REVIEW = '#f59e0b'; + +export function ProtectionDetailsSheet({ + visible, + state, + onClose, + onRequestDeactivation, +}: Props) { + const { t, i18n } = useTranslation(); + const localeTag = i18n.language === 'de' ? 'de-DE' : 'en-US'; + + const sheetHeight = useRef(new Animated.Value(DEFAULT_HEIGHT)).current; + const dismissY = useRef(new Animated.Value(0)).current; + const currentHeight = useRef(DEFAULT_HEIGHT); + + useEffect(() => { + if (visible) { + sheetHeight.setValue(DEFAULT_HEIGHT); + dismissY.setValue(0); + currentHeight.current = DEFAULT_HEIGHT; + } + }, [visible, sheetHeight, dismissY]); + + const handleClose = () => { + sheetHeight.setValue(DEFAULT_HEIGHT); + dismissY.setValue(0); + currentHeight.current = DEFAULT_HEIGHT; + onClose(); + }; + + const panResponder = useRef( + PanResponder.create({ + onStartShouldSetPanResponder: () => true, + onMoveShouldSetPanResponder: () => true, + onPanResponderTerminationRequest: () => false, + onPanResponderMove: (_, g) => { + const next = currentHeight.current - g.dy; + const clamped = Math.max(DISMISS_HEIGHT - 60, Math.min(EXPANDED_HEIGHT + 20, next)); + sheetHeight.setValue(clamped); + }, + onPanResponderRelease: (_, g) => { + const finalH = currentHeight.current - g.dy; + const velocity = g.vy; + + if (finalH < DISMISS_HEIGHT || velocity > 1.5) { + Animated.timing(dismissY, { + toValue: SCREEN_HEIGHT, + duration: 200, + useNativeDriver: true, + }).start(() => handleClose()); + return; + } + + let target = finalH; + if (velocity < -1.5) target = EXPANDED_HEIGHT; + const clamped = Math.max(MIN_HEIGHT, Math.min(EXPANDED_HEIGHT, target)); + + Animated.spring(sheetHeight, { + toValue: clamped, + useNativeDriver: false, + friction: 9, + tension: 70, + }).start(); + currentHeight.current = clamped; + }, + }), + ).current; + + const [stats, setStats] = useState(null); + const [loadingStats, setLoadingStats] = useState(false); + + useEffect(() => { + if (!visible) return; + let alive = true; + setLoadingStats(true); + apiFetch('/api/blocklist/stats') + .then((res) => { if (alive) setStats(res); }) + .catch(() => { /* silent */ }) + .finally(() => { if (alive) setLoadingStats(false); }); + return () => { alive = false; }; + }, [visible]); + + const globalCount = stats?.current ?? state.blocklistCount; + const weeklyAdded = stats?.weeklyAdded ?? 0; + const monthlyAdded = stats?.monthlyAdded ?? 0; + const myActive = stats?.mySubmissions?.active ?? 0; + const myInVote = stats?.mySubmissions?.inVote ?? 0; + const myInReview = stats?.mySubmissions?.inReview ?? 0; + const avgPerUser = stats?.avgPerUser ?? 0; + const avgWait = stats?.avgApprovalWaitDays ?? 0; + + return ( + + + + + + {/* Drag-Bar */} + + + + + {/* Header */} + + + + {t('blocker.details_title')} + + + + {t('blocker.details_done')} + + + + + + {loadingStats && !stats ? ( + + + + ) : null} + + {/* HERO – Globale geblockte Domains: Counter (slow, color) + 2 Delta-Badges */} + + + + {t('blocker.kpi_global_label')} + + + + + + + + + + + {/* SUBMISSIONS – Half Donut mit center-number + center-legend */} + + + + {t('blocker.kpi_submissions_title')} + + + {t('blocker.kpi_submissions_subtitle')} + + + + + + {/* Centered Legend */} + + + + + + + + {/* AVG KPIs – kleiner */} + + + + + + {/* FAQ-Banner: Heading-Row mit Help-Icon rechts (kein gestapeltes Layout) */} + + + + {t('blocker.faq_heading')} + + + + {[1, 2, 3, 4].map((n) => ( + + ))} + + + {/* MEHR INFO – outline button, Icon + Label nebeneinander (flex-row, NICHT col) */} + ({ + marginTop: 4, + paddingVertical: 14, + paddingHorizontal: 16, + borderRadius: 12, + borderWidth: 1.5, + borderColor: HERO_COLOR, + backgroundColor: pressed ? '#fed7aa' : '#fff7ed', + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 8, + })} + > + + + {t('blocker.more_info_title')} + + + + + + + ); +} + +// ─── Animated Counter ────────────────────────────────────────────────────── +function AnimatedCounter({ + value, + locale, + decimals = 0, + durationMs = 1200, + style, +}: { + value: number; + locale: string; + decimals?: number; + durationMs?: number; + style?: any; +}) { + const anim = useRef(new Animated.Value(0)).current; + const [display, setDisplay] = useState(0); + + useEffect(() => { + anim.setValue(0); + const listener = anim.addListener(({ value: v }) => { + setDisplay(v * value); + }); + Animated.timing(anim, { + toValue: 1, + duration: durationMs, + easing: Easing.out(Easing.cubic), + useNativeDriver: false, + }).start(); + return () => anim.removeListener(listener); + }, [value, anim, durationMs]); + + const formatted = display.toLocaleString(locale, { + minimumFractionDigits: decimals, + maximumFractionDigits: decimals, + }); + + return {formatted}; +} + +// ─── Delta Badge (e.g. "+25 diese Woche ↗") ──────────────────────────────── +function DeltaBadge({ + value, + label, + locale, +}: { + value: number; + label: string; + locale: string; +}) { + const formatted = `+${value.toLocaleString(locale)}`; + return ( + + + + + + + {formatted} + + + {label} + + + + ); +} + +// ─── KPI Card (small) ────────────────────────────────────────────────────── +function KpiCard({ + icon, + label, + value, + locale, + decimals = 0, + suffix, +}: { + icon: any; + label: string; + value: number; + locale: string; + decimals?: number; + suffix?: string; +}) { + return ( + + + + + {label} + + + + + {suffix ? ( + {suffix} + ) : null} + + + ); +} + +// ─── Legend Item (compact, centered row) ─────────────────────────────────── +function LegendItem({ + color, + label, + value, +}: { + color: string; + label: string; + value: number; +}) { + return ( + + + + {value} + + {label} + + ); +} + +// ─── Half Donut (multi-segment) ──────────────────────────────────────────── +function HalfDonut({ + segments, + centerValue, + centerLabel, +}: { + segments: { value: number; color: string }[]; + centerValue: number; + centerLabel: string; +}) { + const total = Math.max(1, segments.reduce((s, x) => s + x.value, 0)); + + const W = 220; + const H = 130; + const cx = W / 2; + const cy = H - 8; + const r = 90; + const stroke = 18; + + // Compute cumulative angles in [180, 360] + let cumAngle = 180; + const arcs = segments.map((seg) => { + const startAngle = cumAngle; + const endAngle = cumAngle + 180 * (seg.value / total); + cumAngle = endAngle; + return { ...seg, startAngle, endAngle }; + }); + + const animProgress = useRef(new Animated.Value(0)).current; + const [progress, setProgress] = useState(0); + + useEffect(() => { + animProgress.setValue(0); + const l = animProgress.addListener(({ value }) => setProgress(value)); + Animated.timing(animProgress, { + toValue: 1, + duration: 1100, + easing: Easing.out(Easing.cubic), + useNativeDriver: false, + }).start(); + return () => animProgress.removeListener(l); + }, [centerValue, animProgress]); + + return ( + + + {/* Background track */} + + {arcs.map((a, i) => { + const animatedEnd = + a.startAngle + (a.endAngle - a.startAngle) * progress; + if (animatedEnd <= a.startAngle + 0.5) return null; + return ( + + ); + })} + {centerValue === 0 && ( + + )} + + + {/* Center number — exactly centered horizontally + vertically inside semicircle */} + + + {centerValue} + + + {centerLabel} + + + + ); +} + +function arcPath(cx: number, cy: number, r: number, startDeg: number, endDeg: number) { + const start = polar(cx, cy, r, startDeg); + const end = polar(cx, cy, r, endDeg); + const largeArc = endDeg - startDeg > 180 ? 1 : 0; + return `M ${start.x} ${start.y} A ${r} ${r} 0 ${largeArc} 1 ${end.x} ${end.y}`; +} + +function polar(cx: number, cy: number, r: number, angleDeg: number) { + const rad = (angleDeg * Math.PI) / 180; + return { x: cx + r * Math.cos(rad), y: cy + r * Math.sin(rad) }; +} + +// ─── FAQ Item (chevron AT END of header row, on right) ───────────────────── +function FaqItem({ question, answer }: { question: string; answer: string }) { + const [open, setOpen] = useState(false); + const rotateAnim = useRef(new Animated.Value(0)).current; + + useEffect(() => { + Animated.timing(rotateAnim, { + toValue: open ? 1 : 0, + duration: 200, + useNativeDriver: true, + }).start(); + }, [open, rotateAnim]); + + const rotate = rotateAnim.interpolate({ + inputRange: [0, 1], + outputRange: ['0deg', '90deg'], + }); + + return ( + + setOpen((v) => !v)} + style={({ pressed }) => ({ + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 14, + paddingVertical: 14, + backgroundColor: pressed ? '#fafafa' : '#fff', + })} + > + + {question} + + + + + + {open && ( + + + {answer} + + + )} + + ); +} diff --git a/apps/rebreak-native/components/blocker/ProtectionLockedCard.tsx b/apps/rebreak-native/components/blocker/ProtectionLockedCard.tsx new file mode 100644 index 0000000..5d1f88c --- /dev/null +++ b/apps/rebreak-native/components/blocker/ProtectionLockedCard.tsx @@ -0,0 +1,130 @@ +import { View, Text, Pressable } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { useTranslation } from 'react-i18next'; +import type { ProtectionState } from '../../lib/protection'; + +type Props = { + state: ProtectionState; + /** Click-1 of 3-Click-Cooldown-Trigger — öffnet ProtectionDetailsSheet. */ + onPressSettings: () => void; +}; + +/** + * Wird gezeigt sobald Family Controls aktiv ist — der Schutz ist dann + * "locked in" und kann nur über den Cooldown-Flow deaktiviert werden. + * Daher: KEINE Switches mehr, nur ein Settings-Icon das den 3-Click-Flow startet. + */ +export function ProtectionLockedCard({ state, onPressSettings }: Props) { + const { t } = useTranslation(); + const isCooldown = state.phase === 'cooldownActive'; + const cardBg = isCooldown ? '#fef3c7' : '#dcfce7'; + const cardBorder = isCooldown ? '#fcd34d' : '#86efac'; + const iconBg = isCooldown ? '#fde68a' : '#bbf7d0'; + const iconColor = isCooldown ? '#d97706' : '#16a34a'; + + const subtitle = (() => { + if (isCooldown) return t('blocker.protection_subtitle_cooldown'); + if (state.plan === 'legend') { + return t('blocker.protection_subtitle_legend'); + } + if (state.plan === 'pro') { + return t('blocker.protection_subtitle_pro'); + } + return t('blocker.protection_subtitle_free', { count: state.blocklistCount }); + })(); + + return ( + + + + + + + + {t('blocker.protection_card_locked_title')} + + + {subtitle} + + + + ({ + width: 36, + height: 36, + borderRadius: 18, + backgroundColor: '#ffffff', + alignItems: 'center', + justifyContent: 'center', + opacity: pressed ? 0.6 : 1, + shadowColor: '#000', + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.05, + shadowRadius: 2, + })} + accessibilityLabel={t('blocker.protection_settings_a11y')} + > + + + + + {/* Stats nur wenn aktiv und kein Cooldown */} + {!isCooldown && ( + + + + + + )} + + ); +} + +function Stat({ label, value, valueColor = '#0a0a0a' }: { label: string; value: string; valueColor?: string }) { + return ( + + {value} + {label} + + ); +} + +function formatCount(n: number): string { + if (n >= 1000) return `${Math.floor(n / 1000)}k+`; + return String(n); +} diff --git a/apps/rebreak-native/components/chat/ChatBubble.tsx b/apps/rebreak-native/components/chat/ChatBubble.tsx new file mode 100644 index 0000000..0fa07a9 --- /dev/null +++ b/apps/rebreak-native/components/chat/ChatBubble.tsx @@ -0,0 +1,442 @@ +import { useState, useRef } from 'react'; +import { + View, + Text, + Pressable, + Image, + StyleSheet, + Modal, + Alert, + Platform, +} from 'react-native'; +import * as Clipboard from 'expo-clipboard'; +import { Ionicons } from '@expo/vector-icons'; +import { useTranslation } from 'react-i18next'; +import { resolveAvatar } from '../../lib/resolveAvatar'; + +export type ChatMsg = { + id: string; + userId: string; + nickname?: string | null; + avatar?: string | null; + content: string; + replyTo?: { + id: string; + userId: string; + nickname?: string | null; + content: string; + attachmentType?: string | null; + } | null; + attachmentUrl?: string | null; + attachmentType?: string | null; + attachmentName?: string | null; + likesCount: number; + likedByMe?: boolean; + createdAt: string; + isOwn: boolean; + readAt?: string | null; +}; + +type Props = { + msg: ChatMsg; + showName?: boolean; + isFirstInGroup?: boolean; + isLastInGroup?: boolean; + hideReadStatus?: boolean; + onReply: (msg: ChatMsg) => void; + onLike: (msg: ChatMsg) => void; + onOpenImage: (url: string) => void; +}; + +function formatTime(ts: string) { + return new Date(ts).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }); +} + +export function ChatBubble({ + msg, + showName = false, + isFirstInGroup = true, + isLastInGroup = true, + hideReadStatus = false, + onReply, + onLike, + onOpenImage, +}: Props) { + const { t } = useTranslation(); + const [actionsOpen, setActionsOpen] = useState(false); + const longPressTimer = useRef | null>(null); + + const isImageOnly = + !!msg.attachmentUrl && msg.attachmentType === 'image' && !msg.content && !msg.replyTo; + const replyHasAttachment = msg.replyTo?.attachmentType === 'image'; + const avatarUrl = msg.avatar ? msg.avatar : resolveAvatar(null, msg.nickname ?? '?'); + + const cornerStyle = msg.isOwn + ? isLastInGroup + ? { borderBottomRightRadius: 6 } + : { borderTopRightRadius: 6, borderBottomRightRadius: 6 } + : isLastInGroup + ? { borderBottomLeftRadius: 6 } + : { borderTopLeftRadius: 6, borderBottomLeftRadius: 6 }; + + function copyContent() { + if (msg.content) Clipboard.setStringAsync(msg.content); + setActionsOpen(false); + } + + return ( + <> + + {/* Avatar slot left (last of group, not own) */} + {!msg.isOwn && ( + + {isLastInGroup ? ( + + ) : null} + + )} + + + {showName && !msg.isOwn && isFirstInGroup && ( + + {msg.nickname ?? '?'} + + )} + + setActionsOpen(true)} + onPress={() => { + /* tap eats - keeps long-press primary */ + }} + style={[ + styles.bubble, + msg.isOwn ? styles.bubbleOwn : styles.bubbleOther, + cornerStyle, + isImageOnly && { padding: 4 }, + ]} + > + {/* Reply preview */} + {msg.replyTo && ( + { + /* could implement scroll-to */ + }} + style={[ + styles.replyPreview, + { + backgroundColor: msg.isOwn ? 'rgba(255,255,255,0.18)' : '#e5e5e5', + borderLeftColor: msg.isOwn ? '#fff' : '#007AFF', + }, + ]} + > + + {msg.replyTo.nickname ?? '?'} + + + {replyHasAttachment && ( + + )}{' '} + {msg.replyTo.content || (replyHasAttachment ? t('chat.image_attachment') : '…')} + + + )} + + {/* Image attachment */} + {msg.attachmentUrl && msg.attachmentType === 'image' && ( + onOpenImage(msg.attachmentUrl!)} + style={[styles.imageWrap, msg.content ? { marginBottom: 4 } : null]} + > + + {isImageOnly && ( + + {msg.likesCount > 0 && ( + + + + {msg.likesCount} + + + )} + {formatTime(msg.createdAt)} + + )} + + )} + + {/* File attachment */} + {msg.attachmentUrl && msg.attachmentType !== 'image' && ( + + + + {msg.attachmentName ?? t('chat.file_attachment')} + + + )} + + {/* Content */} + {msg.content !== '' && ( + + {msg.content} + + )} + + {/* Footer */} + {!isImageOnly && ( + + {msg.likesCount > 0 && ( + + + + {msg.likesCount} + + + )} + + {formatTime(msg.createdAt)} + + {msg.isOwn && !hideReadStatus && ( + + )} + + )} + + + + + {/* Long-press action sheet */} + setActionsOpen(false)} + > + setActionsOpen(false)}> + {}}> + + { + setActionsOpen(false); + onReply(msg); + }} + > + + {t('chat.reply')} + + { + setActionsOpen(false); + onLike(msg); + }} + > + + + {msg.likedByMe ? t('chat.unlike') : t('chat.like')} + + + {msg.content !== '' && ( + + + {t('chat.copy')} + + )} + + + + + ); +} + +const styles = StyleSheet.create({ + row: { + flexDirection: 'row', + paddingHorizontal: 8, + }, + avatarSlot: { + width: 30, + marginRight: 4, + justifyContent: 'flex-end', + }, + avatar: { + width: 26, + height: 26, + borderRadius: 13, + backgroundColor: '#e5e5e5', + }, + bubbleCol: { + maxWidth: '78%', + }, + nickname: { + fontSize: 10, + fontFamily: 'Nunito_700Bold', + color: '#007AFF', + marginBottom: 2, + marginLeft: 10, + }, + bubble: { + borderRadius: 18, + paddingHorizontal: 12, + paddingVertical: 6, + shadowColor: '#000', + shadowOpacity: 0.05, + shadowRadius: 1, + shadowOffset: { width: 0, height: 1 }, + }, + bubbleOwn: { + backgroundColor: '#007AFF', + }, + bubbleOther: { + backgroundColor: '#ffffff', + borderWidth: StyleSheet.hairlineWidth, + borderColor: '#e5e5e5', + }, + replyPreview: { + borderLeftWidth: 3, + borderRadius: 8, + paddingHorizontal: 8, + paddingVertical: 4, + marginBottom: 4, + }, + imageWrap: { + borderRadius: 12, + overflow: 'hidden', + position: 'relative', + }, + image: { + width: 220, + height: 220, + backgroundColor: '#f5f5f5', + }, + imageTimeOverlay: { + position: 'absolute', + bottom: 6, + right: 6, + backgroundColor: 'rgba(0,0,0,0.5)', + borderRadius: 10, + paddingHorizontal: 6, + paddingVertical: 2, + flexDirection: 'row', + alignItems: 'center', + }, + content: { + fontSize: 14, + lineHeight: 20, + fontFamily: 'Nunito_400Regular', + }, + footer: { + position: 'absolute', + bottom: 4, + right: 8, + flexDirection: 'row', + alignItems: 'center', + }, + sheetBackdrop: { + flex: 1, + backgroundColor: 'rgba(0,0,0,0.5)', + justifyContent: 'flex-end', + }, + sheet: { + backgroundColor: '#fff', + borderTopLeftRadius: 20, + borderTopRightRadius: 20, + padding: 8, + paddingBottom: Platform.OS === 'ios' ? 32 : 16, + }, + sheetGrabber: { + width: 36, + height: 4, + borderRadius: 2, + backgroundColor: '#d4d4d4', + alignSelf: 'center', + marginBottom: 10, + }, + sheetItem: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 16, + paddingVertical: 14, + borderRadius: 12, + }, + sheetText: { + fontSize: 15, + fontFamily: 'Nunito_600SemiBold', + color: '#171717', + marginLeft: 12, + }, +}); diff --git a/apps/rebreak-native/components/chat/ChatInput.tsx b/apps/rebreak-native/components/chat/ChatInput.tsx new file mode 100644 index 0000000..48263c5 --- /dev/null +++ b/apps/rebreak-native/components/chat/ChatInput.tsx @@ -0,0 +1,332 @@ +import { useState, useRef } from 'react'; +import { + View, + Text, + TextInput, + Pressable, + Image, + StyleSheet, + ActivityIndicator, + Platform, + Alert, +} from 'react-native'; +import * as ImagePicker from 'expo-image-picker'; +import * as FileSystem from 'expo-file-system'; +import { Ionicons } from '@expo/vector-icons'; +import { useTranslation } from 'react-i18next'; +import { supabase } from '../../lib/supabase'; + +type ReplyTo = { id: string; nickname: string; content: string }; + +export type SendPayload = { + content: string; + replyToId?: string; + attachmentUrl?: string; + attachmentType?: string; + attachmentName?: string; +}; + +type Props = { + replyTo: ReplyTo | null; + sending?: boolean; + placeholder?: string; + disabled?: boolean; + onSend: (data: SendPayload) => void; + onCancelReply: () => void; +}; + +export function ChatInput({ + replyTo, + sending, + placeholder, + disabled, + onSend, + onCancelReply, +}: Props) { + const { t } = useTranslation(); + const [text, setText] = useState(''); + const [attachment, setAttachment] = useState<{ + uri: string; + name: string; + isImage: boolean; + } | null>(null); + const [uploading, setUploading] = useState(false); + const inputRef = useRef(null); + + const hasContent = text.trim().length > 0 || attachment !== null; + + async function pickImage() { + const perm = await ImagePicker.requestMediaLibraryPermissionsAsync(); + if (!perm.granted) { + Alert.alert('Foto-Zugriff', 'Bitte Foto-Zugriff in den Einstellungen erlauben.'); + return; + } + const result = await ImagePicker.launchImageLibraryAsync({ + mediaTypes: ImagePicker.MediaTypeOptions.Images, + quality: 0.8, + }); + if (!result.canceled && result.assets[0]?.uri) { + const a = result.assets[0]; + setAttachment({ + uri: a.uri, + name: a.fileName ?? `image-${Date.now()}.jpg`, + isImage: true, + }); + } + } + + function clearAttachment() { + setAttachment(null); + } + + async function uploadAttachment(): Promise<{ + url: string; + type: string; + name: string; + } | null> { + if (!attachment) return null; + try { + setUploading(true); + const ext = attachment.name.split('.').pop() || 'jpg'; + const path = `chat/${Date.now()}_${Math.random().toString(36).slice(2, 8)}.${ext}`; + const base64 = await FileSystem.readAsStringAsync(attachment.uri, { + encoding: FileSystem.EncodingType.Base64, + }); + const arrayBuffer = decodeBase64(base64); + const { error } = await supabase.storage + .from('chat-attachments') + .upload(path, arrayBuffer, { + cacheControl: '3600', + upsert: false, + contentType: attachment.isImage ? 'image/jpeg' : 'application/octet-stream', + }); + if (error) throw error; + const { data } = supabase.storage.from('chat-attachments').getPublicUrl(path); + return { + url: data.publicUrl, + type: attachment.isImage ? 'image' : 'file', + name: attachment.name, + }; + } catch (err: any) { + Alert.alert(t('chat.upload_failed'), err?.message ?? ''); + return null; + } finally { + setUploading(false); + } + } + + async function handleSend() { + const content = text.trim(); + if (!content && !attachment) return; + + let attachmentMeta: { url: string; type: string; name: string } | null = null; + if (attachment) { + attachmentMeta = await uploadAttachment(); + if (!attachmentMeta) return; + } + + onSend({ + content, + replyToId: replyTo?.id, + attachmentUrl: attachmentMeta?.url, + attachmentType: attachmentMeta?.type, + attachmentName: attachmentMeta?.name, + }); + setText(''); + setAttachment(null); + } + + return ( + + {/* Reply preview */} + {replyTo && ( + + + + + {t('chat.reply_to')} {replyTo.nickname} + + + {replyTo.content || '…'} + + + + + + + )} + + {/* Attachment preview */} + {attachment && ( + + {attachment.isImage ? ( + + ) : ( + + + + )} + + {attachment.name} + + + + + + )} + + {/* Input row */} + + + + + + + + + + + {sending || uploading ? ( + + ) : ( + + )} + + + + ); +} + +// Base64 → Uint8Array (für Supabase Storage Upload) +function decodeBase64(base64: string): Uint8Array { + const binary = + typeof atob === 'function' + ? atob(base64) + : Buffer.from(base64, 'base64').toString('binary'); + const len = binary.length; + const bytes = new Uint8Array(len); + for (let i = 0; i < len; i++) bytes[i] = binary.charCodeAt(i); + return bytes; +} + +const styles = StyleSheet.create({ + container: { + backgroundColor: '#ffffff', + borderTopWidth: StyleSheet.hairlineWidth, + borderTopColor: '#e5e5e5', + }, + replyBar: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 12, + paddingVertical: 8, + backgroundColor: '#eff6ff', + borderLeftWidth: 3, + borderLeftColor: '#007AFF', + marginHorizontal: 8, + marginTop: 6, + borderRadius: 8, + }, + replyName: { + fontSize: 11, + fontFamily: 'Nunito_700Bold', + color: '#007AFF', + }, + replyContent: { + fontSize: 11, + fontFamily: 'Nunito_400Regular', + color: '#525252', + marginTop: 1, + }, + attachBar: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 12, + paddingVertical: 6, + backgroundColor: '#fafafa', + marginHorizontal: 8, + marginTop: 6, + borderRadius: 8, + }, + attachImg: { + width: 36, + height: 36, + borderRadius: 6, + marginRight: 8, + }, + attachFileIcon: { + width: 36, + height: 36, + borderRadius: 6, + backgroundColor: '#e5e5e5', + alignItems: 'center', + justifyContent: 'center', + marginRight: 8, + }, + attachName: { + flex: 1, + fontSize: 12, + fontFamily: 'Nunito_600SemiBold', + color: '#171717', + }, + row: { + flexDirection: 'row', + alignItems: 'flex-end', + paddingHorizontal: 8, + paddingTop: 8, + paddingBottom: 8, + }, + iconBtn: { + width: 36, + height: 36, + borderRadius: 18, + alignItems: 'center', + justifyContent: 'center', + marginRight: 4, + }, + inputWrap: { + flex: 1, + backgroundColor: '#f5f5f5', + borderRadius: 22, + paddingHorizontal: 14, + minHeight: 36, + maxHeight: 120, + justifyContent: 'center', + }, + input: { + fontSize: 14, + lineHeight: 19, + fontFamily: 'Nunito_400Regular', + color: '#171717', + paddingVertical: Platform.OS === 'ios' ? 8 : 4, + }, + sendBtn: { + width: 36, + height: 36, + borderRadius: 18, + alignItems: 'center', + justifyContent: 'center', + marginLeft: 6, + }, +}); diff --git a/apps/rebreak-native/components/chat/CreateRoomSheet.tsx b/apps/rebreak-native/components/chat/CreateRoomSheet.tsx new file mode 100644 index 0000000..5b9be05 --- /dev/null +++ b/apps/rebreak-native/components/chat/CreateRoomSheet.tsx @@ -0,0 +1,282 @@ +import { useState } from 'react'; +import { + Modal, + View, + Text, + TextInput, + Pressable, + StyleSheet, + ActivityIndicator, + Platform, +} from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { useTranslation } from 'react-i18next'; +import { apiFetch } from '../../lib/api'; + +type Props = { + visible: boolean; + onClose: () => void; + onCreated: (room: any) => void; +}; + +export function CreateRoomSheet({ visible, onClose, onCreated }: Props) { + const { t } = useTranslation(); + const [name, setName] = useState(''); + const [description, setDescription] = useState(''); + const [isPublic, setIsPublic] = useState(true); + const [joinMode, setJoinMode] = useState<'approval' | 'invite_only'>('approval'); + const [creating, setCreating] = useState(false); + + function reset() { + setName(''); + setDescription(''); + setIsPublic(true); + setJoinMode('approval'); + } + + async function create() { + const trimmed = name.trim(); + if (!trimmed || creating) return; + setCreating(true); + try { + const room = await apiFetch('/api/chat/rooms', { + method: 'POST', + body: { + name: trimmed, + description: description.trim() || undefined, + isPublic, + joinMode: isPublic ? 'open' : joinMode, + }, + }); + onCreated(room); + reset(); + onClose(); + } catch (err: any) { + console.error('Room erstellen fehlgeschlagen:', err.message); + } finally { + setCreating(false); + } + } + + return ( + + + {}}> + + {t('chat.create_group')} + + + + + {/* Public toggle */} + setIsPublic((v) => !v)} + > + {t('chat.public_room')} + + + + + + {/* Join mode (private only) */} + {!isPublic && ( + + {t('chat.join_mode')} + + {(['approval', 'invite_only'] as const).map((mode) => ( + setJoinMode(mode)} + > + + {t(`chat.join_mode_${mode === 'approval' ? 'approval' : 'invite'}`)} + + + ))} + + + )} + + {/* Actions */} + + + {t('common.cancel')} + + + {creating ? ( + + ) : ( + {t('chat.create')} + )} + + + + + + ); +} + +const styles = StyleSheet.create({ + backdrop: { + flex: 1, + backgroundColor: 'rgba(0,0,0,0.5)', + justifyContent: 'flex-end', + }, + sheet: { + backgroundColor: '#fff', + borderTopLeftRadius: 22, + borderTopRightRadius: 22, + padding: 18, + paddingBottom: Platform.OS === 'ios' ? 32 : 18, + }, + grabber: { + width: 36, + height: 4, + borderRadius: 2, + backgroundColor: '#d4d4d4', + alignSelf: 'center', + marginBottom: 12, + }, + title: { + fontSize: 17, + fontFamily: 'Nunito_700Bold', + color: '#171717', + marginBottom: 14, + }, + input: { + backgroundColor: '#f5f5f5', + borderRadius: 12, + paddingHorizontal: 14, + paddingVertical: 12, + fontSize: 14, + fontFamily: 'Nunito_400Regular', + color: '#171717', + marginBottom: 10, + }, + toggleRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingVertical: 6, + marginTop: 4, + }, + toggleLabel: { + fontSize: 14, + fontFamily: 'Nunito_600SemiBold', + color: '#171717', + }, + toggle: { + width: 46, + height: 28, + borderRadius: 14, + backgroundColor: '#e5e5e5', + padding: 2, + justifyContent: 'center', + }, + toggleOn: { + backgroundColor: '#007AFF', + }, + toggleKnob: { + width: 24, + height: 24, + borderRadius: 12, + backgroundColor: '#fff', + shadowColor: '#000', + shadowOpacity: 0.15, + shadowRadius: 2, + shadowOffset: { width: 0, height: 1 }, + elevation: 2, + }, + toggleKnobOn: { + transform: [{ translateX: 18 }], + }, + subLabel: { + fontSize: 12, + fontFamily: 'Nunito_600SemiBold', + color: '#737373', + marginBottom: 6, + }, + modeRow: { + flexDirection: 'row', + }, + modeBtn: { + flex: 1, + paddingVertical: 8, + borderRadius: 10, + borderWidth: 1, + borderColor: '#e5e5e5', + alignItems: 'center', + marginRight: 6, + }, + modeBtnActive: { + backgroundColor: '#eff6ff', + borderColor: '#007AFF', + }, + modeBtnText: { + fontSize: 12, + fontFamily: 'Nunito_600SemiBold', + color: '#737373', + }, + modeBtnTextActive: { + color: '#007AFF', + }, + actions: { + flexDirection: 'row', + marginTop: 20, + }, + cancelBtn: { + flex: 1, + backgroundColor: '#f5f5f5', + paddingVertical: 12, + borderRadius: 12, + alignItems: 'center', + marginRight: 6, + }, + cancelText: { + fontSize: 14, + fontFamily: 'Nunito_600SemiBold', + color: '#171717', + }, + createBtn: { + flex: 1, + backgroundColor: '#007AFF', + paddingVertical: 12, + borderRadius: 12, + alignItems: 'center', + marginLeft: 6, + }, + createText: { + fontSize: 14, + fontFamily: 'Nunito_700Bold', + color: '#fff', + }, +}); diff --git a/apps/rebreak-native/components/chat/RoomCard.tsx b/apps/rebreak-native/components/chat/RoomCard.tsx new file mode 100644 index 0000000..1afbac5 --- /dev/null +++ b/apps/rebreak-native/components/chat/RoomCard.tsx @@ -0,0 +1,217 @@ +import { View, Text, Pressable, Image, StyleSheet } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { useTranslation } from 'react-i18next'; + +export type Room = { + id: string; + name: string; + description?: string | null; + isPublic: boolean; + isDefault: boolean; + memberCount: number; + isMember: boolean; + avatarUrl?: string | null; + lastMessage?: { content: string; createdAt: string; senderName: string } | null; +}; + +type Props = { + room: Room; + onPress: () => void; +}; + +function formatTime(ts: string, justNow: string) { + const diff = Date.now() - new Date(ts).getTime(); + if (diff < 60_000) return justNow; + if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m`; + if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h`; + return new Date(ts).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' }); +} + +export function RoomCard({ room, onPress }: Props) { + const { t } = useTranslation(); + const initials = room.name + .split(' ') + .slice(0, 2) + .map((w) => w[0]?.toUpperCase() ?? '') + .join(''); + + return ( + + {({ pressed }) => ( + + + {room.avatarUrl ? ( + + ) : !room.isPublic ? ( + {initials} + ) : ( + + )} + + + + + + {room.name} + + {room.isDefault && ( + + Standard + + )} + {room.lastMessage && ( + + {formatTime(room.lastMessage.createdAt, t('chat.just_now'))} + + )} + + + + {room.lastMessage ? ( + + + {room.lastMessage.senderName}:{' '} + + {room.lastMessage.content} + + ) : room.description ? ( + + {room.description} + + ) : null} + + + + {room.memberCount} + + {!room.isMember && ( + + {t('chat.join')} + + )} + + + + )} + + ); +} + +const styles = StyleSheet.create({ + row: { + width: '100%', + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 14, + paddingVertical: 11, + backgroundColor: '#fff', + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: '#f5f5f5', + }, + avatar: { + width: 42, + height: 42, + borderRadius: 21, + alignItems: 'center', + justifyContent: 'center', + overflow: 'hidden', + marginRight: 10, + }, + avatarImg: { + width: 42, + height: 42, + }, + avatarInitials: { + fontSize: 13, + fontFamily: 'Nunito_700Bold', + color: '#525252', + }, + info: { + flex: 1, + minWidth: 0, + }, + headerRow: { + flexDirection: 'row', + alignItems: 'center', + }, + footerRow: { + flexDirection: 'row', + alignItems: 'center', + marginTop: 3, + }, + footerTextWrap: { + flex: 1, + minWidth: 0, + }, + metaPill: { + flexDirection: 'row', + alignItems: 'center', + marginLeft: 8, + paddingHorizontal: 6, + paddingVertical: 2, + borderRadius: 8, + backgroundColor: '#f5f5f5', + }, + name: { + fontSize: 14, + fontFamily: 'Nunito_700Bold', + color: '#171717', + flexShrink: 1, + }, + defaultBadge: { + marginLeft: 6, + paddingHorizontal: 6, + paddingVertical: 1, + backgroundColor: '#eff6ff', + borderRadius: 8, + }, + defaultBadgeText: { + fontSize: 9, + fontFamily: 'Nunito_700Bold', + color: '#007AFF', + }, + lastMessage: { + fontSize: 12, + fontFamily: 'Nunito_400Regular', + color: '#737373', + }, + description: { + fontSize: 12, + fontFamily: 'Nunito_400Regular', + color: '#a3a3a3', + }, + right: { + alignItems: 'flex-end', + marginLeft: 8, + }, + memberCount: { + fontSize: 11, + fontFamily: 'Nunito_700Bold', + color: '#737373', + marginLeft: 3, + }, + time: { + fontSize: 10, + fontFamily: 'Nunito_500Medium', + color: '#a3a3a3', + marginLeft: 'auto', + paddingLeft: 6, + }, + joinBadge: { + marginLeft: 6, + paddingHorizontal: 8, + paddingVertical: 3, + backgroundColor: '#eff6ff', + borderRadius: 10, + }, + joinBadgeText: { + fontSize: 10, + fontFamily: 'Nunito_700Bold', + color: '#007AFF', + }, +}); diff --git a/apps/rebreak-native/components/mail/ConnectMailSheet.tsx b/apps/rebreak-native/components/mail/ConnectMailSheet.tsx new file mode 100644 index 0000000..726dd8b --- /dev/null +++ b/apps/rebreak-native/components/mail/ConnectMailSheet.tsx @@ -0,0 +1,605 @@ +import { useEffect, useRef, useState } from 'react'; +import { + ActivityIndicator, + Animated, + Dimensions, + Easing, + KeyboardAvoidingView, + Linking, + Modal, + Platform, + Pressable, + ScrollView, + Text, + TextInput, + View, +} from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { Ionicons } from '@expo/vector-icons'; +import { useTranslation } from 'react-i18next'; +import { useMailConnect, detectProvider, type MailProvider } from '../../hooks/useMailConnect'; + +const SCREEN_HEIGHT = Dimensions.get('window').height; +const SHEET_HEIGHT = SCREEN_HEIGHT * 0.65; + +type Props = { + visible: boolean; + onClose: () => void; + onSuccess: () => void; +}; + +type ProviderConfig = { + id: MailProvider; + labelKey: string; + icon: React.ComponentProps['name']; + color: string; + guideKey: string; + guideUrl: string; +}; + +const PROVIDERS: ProviderConfig[] = [ + { + id: 'gmail', + labelKey: 'mail.provider_gmail', + icon: 'mail', + color: '#EA4335', + guideKey: 'mail.app_password_guide_gmail', + guideUrl: 'https://myaccount.google.com/apppasswords', + }, + { + id: 'icloud', + labelKey: 'mail.provider_icloud', + icon: 'cloud', + color: '#007AFF', + guideKey: 'mail.app_password_guide_icloud', + guideUrl: 'https://appleid.apple.com/account/manage', + }, + { + id: 'outlook', + labelKey: 'mail.provider_outlook', + icon: 'mail-open', + color: '#0078D4', + guideKey: 'mail.app_password_guide_outlook', + guideUrl: 'https://account.microsoft.com/security', + }, + { + id: 'yahoo', + labelKey: 'mail.provider_yahoo', + icon: 'at', + color: '#7C3AED', + guideKey: 'mail.app_password_guide_yahoo', + guideUrl: 'https://login.yahoo.com/account/security', + }, + { + id: 'gmx', + labelKey: 'mail.provider_gmx', + icon: 'mail-unread', + color: '#E87A22', + guideKey: 'mail.app_password_guide_gmx', + guideUrl: 'https://www.gmx.net/mail/security', + }, + { + id: 'other', + labelKey: 'mail.provider_other', + icon: 'server', + color: '#737373', + guideKey: 'mail.app_password_guide_other', + guideUrl: '', + }, +]; + +/** + * Bottom-Sheet (65% Screen-Höhe) zum Verbinden eines Postfachs. + * + * Zwei Ansichten im selben Sheet: + * 1. Provider-Grid (6 Tiles) + * 2. Formular-View: Email + App-Passwort + Guide-Link (nach Provider-Tap) + */ +export function ConnectMailSheet({ visible, onClose, onSuccess }: Props) { + const { t } = useTranslation(); + const insets = useSafeAreaInsets(); + const { connect, connecting, error: connectError } = useMailConnect(); + + const [view, setView] = useState<'grid' | 'form'>('grid'); + const [selectedProvider, setSelectedProvider] = useState(null); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [passwordVisible, setPasswordVisible] = useState(false); + const [formError, setFormError] = useState(null); + + const translateY = useRef(new Animated.Value(SHEET_HEIGHT)).current; + const backdropOpacity = useRef(new Animated.Value(0)).current; + + useEffect(() => { + if (visible) { + translateY.setValue(SHEET_HEIGHT); + backdropOpacity.setValue(0); + Animated.parallel([ + Animated.timing(translateY, { + toValue: 0, + duration: 280, + easing: Easing.out(Easing.cubic), + useNativeDriver: true, + }), + Animated.timing(backdropOpacity, { + toValue: 1, + duration: 220, + useNativeDriver: true, + }), + ]).start(); + } + }, [visible, translateY, backdropOpacity]); + + function handleClose() { + setView('grid'); + setSelectedProvider(null); + setEmail(''); + setPassword(''); + setPasswordVisible(false); + setFormError(null); + onClose(); + } + + function handleProviderSelect(provider: ProviderConfig) { + setSelectedProvider(provider); + setView('form'); + setFormError(null); + } + + function handleBack() { + setView('grid'); + setSelectedProvider(null); + setFormError(null); + } + + async function handleConnect() { + if (!email.trim() || !password.trim()) { + setFormError(t('mail.form_fields_required')); + return; + } + setFormError(null); + + const body: Parameters[0] = { email: email.trim(), password }; + + // Für "other" Provider: User muss imapHost selbst eingeben — aktuell nicht + // unterstützt in dieser Sheet-Version. Custom-IMAP bleibt TODO für Phase 11. + // Provider-Detection passiert server-seitig via Email-Domain. + + const result = await connect(body); + if (result.ok) { + handleClose(); + onSuccess(); + } else { + setFormError(result.error ?? t('mail.connect_failed')); + } + } + + // Wenn User Email tippt → Provider-Icon in Echtzeit updaten + const detectedProvider = email.includes('@') ? detectProvider(email) : null; + const currentProvider = selectedProvider ?? null; + + return ( + + {/* Backdrop */} + + + + + {/* Sheet */} + + + {/* Drag-Handle */} + + + + + {/* Header */} + + {view === 'form' ? ( + + + {t('common.back')} + + + ) : ( + + + {t('common.cancel')} + + + )} + + + {view === 'form' && currentProvider + ? t(currentProvider.labelKey) + : t('mail.connect_sheet_title')} + + + + + + {/* Content */} + {view === 'grid' ? ( + + ) : ( + { setEmail(v); setFormError(null); }} + password={password} + onPasswordChange={(v) => { setPassword(v); setFormError(null); }} + passwordVisible={passwordVisible} + onTogglePasswordVisible={() => setPasswordVisible((p) => !p)} + error={formError ?? connectError} + connecting={connecting} + onConnect={handleConnect} + insets={insets} + t={t} + /> + )} + + + + ); +} + +// --------------------------------------------------------------------------- +// Sub-View: Provider-Grid +// --------------------------------------------------------------------------- + +function ProviderGrid({ + providers, + onSelect, + t, +}: { + providers: ProviderConfig[]; + onSelect: (p: ProviderConfig) => void; + t: (key: string) => string; +}) { + return ( + + + {t('mail.connect_sheet_subtitle')} + + + + {providers.map((p) => ( + onSelect(p)} + style={({ pressed }) => ({ + width: '47%', + flexDirection: 'row', + alignItems: 'center', + gap: 10, + backgroundColor: '#f9f9f9', + borderWidth: 1, + borderColor: '#e5e5e5', + borderRadius: 14, + padding: 14, + opacity: pressed ? 0.7 : 1, + })} + > + + + + + + {t(p.labelKey)} + + + + + ))} + + + ); +} + +// --------------------------------------------------------------------------- +// Sub-View: Formular (Email + App-Passwort) +// --------------------------------------------------------------------------- + +type FormViewProps = { + provider: ProviderConfig | null; + detectedProvider: MailProvider | null; + email: string; + onEmailChange: (v: string) => void; + password: string; + onPasswordChange: (v: string) => void; + passwordVisible: boolean; + onTogglePasswordVisible: () => void; + error: string | null; + connecting: boolean; + onConnect: () => void; + insets: ReturnType; + t: (key: string) => string; +}; + +function FormView({ + provider, + email, + onEmailChange, + password, + onPasswordChange, + passwordVisible, + onTogglePasswordVisible, + error, + connecting, + onConnect, + insets, + t, +}: FormViewProps) { + const canConnect = email.trim().length > 0 && password.trim().length > 0 && !connecting; + + return ( + + {/* App-Password-Guide-Hinweis */} + {provider && provider.id !== 'other' && ( + + + + + {t('mail.app_password_required_title')} + + + {t(provider.guideKey)} + + {provider.guideUrl.length > 0 && ( + Linking.openURL(provider.guideUrl)}> + + {t('mail.app_password_open_link')} → + + + )} + + + )} + + {/* Email-Input */} + + + {t('mail.form_email_label')} + + + + + {/* Passwort-Input */} + + + {t('mail.form_password_label')} + + + + + + + + + + {/* Datenschutz-Hinweis */} + + + + {t('mail.form_privacy_note')} + + + + {/* Error */} + {error && ( + + {error} + + )} + + {/* Connect-Button */} + ({ + backgroundColor: canConnect ? '#007AFF' : '#d4d4d4', + borderRadius: 14, + paddingVertical: 14, + alignItems: 'center', + opacity: pressed ? 0.85 : 1, + marginTop: 4, + marginBottom: insets.bottom > 0 ? 8 : 12, + })} + > + {connecting ? ( + + ) : ( + + {t('mail.form_connect_btn')} + + )} + + + ); +} diff --git a/apps/rebreak-native/components/mail/EditMailAccountSheet.tsx b/apps/rebreak-native/components/mail/EditMailAccountSheet.tsx new file mode 100644 index 0000000..b0e6974 --- /dev/null +++ b/apps/rebreak-native/components/mail/EditMailAccountSheet.tsx @@ -0,0 +1,249 @@ +import { useEffect, useRef, useState } from 'react'; +import { + ActivityIndicator, + Animated, + Dimensions, + Easing, + KeyboardAvoidingView, + Modal, + Platform, + Pressable, + Text, + TextInput, + View, +} from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { Ionicons } from '@expo/vector-icons'; +import { useTranslation } from 'react-i18next'; +import { useMailConnect } from '../../hooks/useMailConnect'; + +const SCREEN_HEIGHT = Dimensions.get('window').height; +const SHEET_HEIGHT = SCREEN_HEIGHT * 0.5; + +type Props = { + visible: boolean; + email: string; + onClose: () => void; + onSuccess: () => void; +}; + +/** + * Sheet zum Aktualisieren des App-Passworts eines bereits verbundenen Postfachs. + * Nutzt POST /api/mail/connect (upsert) — Backend ersetzt verschlüsseltes Passwort. + */ +export function EditMailAccountSheet({ visible, email, onClose, onSuccess }: Props) { + const { t } = useTranslation(); + const insets = useSafeAreaInsets(); + const { connect, connecting, error: connectError } = useMailConnect(); + + const [password, setPassword] = useState(''); + const [passwordVisible, setPasswordVisible] = useState(false); + const [formError, setFormError] = useState(null); + + const translateY = useRef(new Animated.Value(SHEET_HEIGHT)).current; + const backdropOpacity = useRef(new Animated.Value(0)).current; + + useEffect(() => { + if (visible) { + setPassword(''); + setPasswordVisible(false); + setFormError(null); + translateY.setValue(SHEET_HEIGHT); + backdropOpacity.setValue(0); + Animated.parallel([ + Animated.timing(translateY, { + toValue: 0, + duration: 280, + easing: Easing.out(Easing.cubic), + useNativeDriver: true, + }), + Animated.timing(backdropOpacity, { + toValue: 1, + duration: 220, + useNativeDriver: true, + }), + ]).start(); + } + }, [visible, translateY, backdropOpacity]); + + async function handleSave() { + if (!password.trim()) { + setFormError(t('mail.form_fields_required')); + return; + } + setFormError(null); + const result = await connect({ email, password }); + if (result.ok) { + onClose(); + onSuccess(); + } else { + setFormError(result.error ?? t('mail.connect_failed')); + } + } + + return ( + + + + + + + + {/* Drag-Handle */} + + + + + {/* Header */} + + + + {t('common.cancel')} + + + + {t('mail.edit_account_title')} + + + + + + + {t('mail.edit_account_subtitle', { email })} + + + + + { + setPassword(v); + setFormError(null); + }} + placeholder={t('mail.app_password_placeholder')} + placeholderTextColor="#a3a3a3" + secureTextEntry={!passwordVisible} + autoCapitalize="none" + autoCorrect={false} + style={{ + flex: 1, + paddingVertical: 14, + fontSize: 15, + fontFamily: 'Nunito_400Regular', + color: '#0a0a0a', + }} + /> + setPasswordVisible((p) => !p)} hitSlop={8}> + + + + + {(formError ?? connectError) && ( + + + + {formError ?? connectError} + + + )} + + ({ + marginTop: 4, + paddingVertical: 14, + borderRadius: 12, + backgroundColor: !password.trim() || connecting ? '#bfdbfe' : '#007AFF', + alignItems: 'center', + opacity: pressed ? 0.85 : 1, + })} + > + {connecting ? ( + + ) : ( + + {t('mail.edit_account_save')} + + )} + + + + + + + + ); +} diff --git a/apps/rebreak-native/components/mail/MailAccountCard.tsx b/apps/rebreak-native/components/mail/MailAccountCard.tsx new file mode 100644 index 0000000..c856aea --- /dev/null +++ b/apps/rebreak-native/components/mail/MailAccountCard.tsx @@ -0,0 +1,391 @@ +import { useState } from 'react'; +import { + ActivityIndicator, + LayoutAnimation, + Platform, + Pressable, + Text, + UIManager, + View, +} from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { useTranslation } from 'react-i18next'; +import { ConfirmAlert } from '../ConfirmAlert'; +import { EditMailAccountSheet } from './EditMailAccountSheet'; +import { useMailInterval } from '../../hooks/useMailInterval'; +import type { MailAccount } from '../../hooks/useMailStatus'; + +if (Platform.OS === 'android' && UIManager.setLayoutAnimationEnabledExperimental) { + UIManager.setLayoutAnimationEnabledExperimental(true); +} + +type Props = { + account: MailAccount; + plan: 'free' | 'pro' | 'legend'; + expanded: boolean; + onToggle: () => void; + onDisconnect: (id: string) => Promise; + onIntervalChanged: () => void; + onEditSuccess: () => void; + disconnecting?: boolean; +}; + +function resolveProviderIcon(provider: string): { + icon: React.ComponentProps['name']; + color: string; +} { + const p = provider.toLowerCase(); + if (p.includes('gmail') || p.includes('google')) return { icon: 'mail', color: '#EA4335' }; + if (p.includes('icloud') || p.includes('apple')) return { icon: 'cloud', color: '#007AFF' }; + if (p.includes('outlook') || p.includes('hotmail') || p.includes('microsoft')) + return { icon: 'mail-open', color: '#0078D4' }; + if (p.includes('yahoo')) return { icon: 'at', color: '#7C3AED' }; + if (p.includes('gmx') || p.includes('web.de')) + return { icon: 'mail-unread', color: '#E87A22' }; + return { icon: 'server', color: '#737373' }; +} + +function formatRelativeTime(iso: string | null, t: (k: string) => string): string { + if (!iso) return t('mail.account_never_scanned'); + const diff = Date.now() - new Date(iso).getTime(); + const mins = Math.floor(diff / 60_000); + if (mins < 2) return t('mail.account_just_now'); + if (mins < 60) return `${mins} min`; + const hours = Math.floor(mins / 60); + if (hours < 24) return `${hours}h`; + return `${Math.floor(hours / 24)}d`; +} + +const INTERVAL_OPTIONS_BY_PLAN: Record<'free' | 'pro' | 'legend', number[]> = { + free: [4], + pro: [1, 4, 8], + legend: [1, 4, 8], +}; + +// Solid styles outside of render — no gap, no callback layout. +const HEADER_ROW = { + flexDirection: 'row' as const, + alignItems: 'center' as const, + paddingHorizontal: 14, + paddingVertical: 14, +}; + +const ACTION_BTN_BASE = { + flex: 1, + flexDirection: 'row' as const, + alignItems: 'center' as const, + justifyContent: 'center' as const, + paddingVertical: 12, + borderRadius: 10, +}; + +export function MailAccountCard({ + account, + plan, + expanded, + onToggle, + onDisconnect, + onIntervalChanged, + onEditSuccess, + disconnecting, +}: Props) { + const { t } = useTranslation(); + const [confirmVisible, setConfirmVisible] = useState(false); + const [editVisible, setEditVisible] = useState(false); + const { setInterval, updating } = useMailInterval(); + const { icon, color } = resolveProviderIcon(account.provider); + + const isLegend = plan === 'legend'; + const intervalOptions = INTERVAL_OPTIONS_BY_PLAN[plan]; + + function handleToggle() { + LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut); + onToggle(); + } + + async function handleSetInterval(value: number) { + const res = await setInterval(account.id, value); + if (res.ok) onIntervalChanged(); + } + + return ( + <> + + {/* ── Header ── */} + + + + + + + + + {account.email} + + + + + {account.isActive + ? isLegend + ? t('mail.live') + : t('mail.account_active') + : t('mail.account_inactive')} + + + · {formatRelativeTime(account.lastScannedAt, t)} + + + + + + + + + {/* ── Body ── */} + {expanded && ( + + {/* Big stat: Blocked */} + + + + + {t('mail.account_stat_blocked')} + + + {account.totalBlocked.toLocaleString()} + + + + {t('mail.account_of_scanned', { + scanned: account.totalScanned.toLocaleString(), + })} + + + + {/* Scan Mode */} + {isLegend ? ( + + + + {t('mail.realtime_desc')} + + + ) : ( + + + {t('mail.scan_interval_label')} + + + {intervalOptions.map((opt, idx) => { + const active = account.scanInterval === opt; + const disabled = plan === 'free' || updating === account.id; + return ( + handleSetInterval(opt)} + style={{ + flex: 1, + paddingVertical: 9, + borderRadius: 10, + alignItems: 'center', + backgroundColor: active ? '#007AFF' : '#f5f5f5', + marginLeft: idx === 0 ? 0 : 6, + opacity: disabled && !active ? 0.5 : 1, + }} + > + + {opt}h + + + ); + })} + + {plan === 'free' && ( + + {t('mail.free_scan_interval_hint')} + + )} + + )} + + {/* Action Row */} + + setEditVisible(true)} + style={{ ...ACTION_BTN_BASE, backgroundColor: '#f5f5f5', marginRight: 6 }} + > + + + {t('mail.account_change_password')} + + + setConfirmVisible(true)} + disabled={disconnecting} + style={{ + ...ACTION_BTN_BASE, + backgroundColor: '#fef2f2', + marginLeft: 6, + opacity: disconnecting ? 0.6 : 1, + }} + > + {disconnecting ? ( + + ) : ( + <> + + + {t('mail.disconnect')} + + + )} + + + + )} + + + { + setConfirmVisible(false); + await onDisconnect(account.id); + }} + onCancel={() => setConfirmVisible(false)} + /> + + setEditVisible(false)} + onSuccess={onEditSuccess} + /> + + ); +} diff --git a/apps/rebreak-native/components/mail/MailActivityLog.tsx b/apps/rebreak-native/components/mail/MailActivityLog.tsx new file mode 100644 index 0000000..de75ffe --- /dev/null +++ b/apps/rebreak-native/components/mail/MailActivityLog.tsx @@ -0,0 +1,218 @@ +import { + LayoutAnimation, + Platform, + Pressable, + Text, + UIManager, + View, +} from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { useTranslation } from 'react-i18next'; +import { useMailResults, type MailBlockedItem } from '../../hooks/useMailResults'; + +if (Platform.OS === 'android' && UIManager.setLayoutAnimationEnabledExperimental) { + UIManager.setLayoutAnimationEnabledExperimental(true); +} + +type Props = { + expanded: boolean; + onToggle: () => void; +}; + +function formatDate(iso: string, t: (k: string) => string): string { + const diff = Date.now() - new Date(iso).getTime(); + const mins = Math.floor(diff / 60_000); + if (mins < 2) return t('mail.account_just_now'); + if (mins < 60) return `${mins} min`; + const hours = Math.floor(mins / 60); + if (hours < 24) return `${hours}h`; + return `${Math.floor(hours / 24)}d`; +} + +export function MailActivityLog({ expanded, onToggle }: Props) { + const { t } = useTranslation(); + const { results, total, loading, refresh } = useMailResults(expanded); + + function handleToggle() { + LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut); + onToggle(); + } + + return ( + + + + + + + + + {t('mail.activity_log_title')} + + + {t('mail.activity_log_subtitle')} + + + + + + + {expanded && ( + + {loading && results.length === 0 ? ( + + + {t('mail.loading')} + + + ) : results.length === 0 ? ( + + + + {t('mail.activity_log_empty')} + + + ) : ( + <> + {results.slice(0, 10).map((item) => ( + + ))} + + + {total > 10 + ? t('mail.activity_log_more', { count: total - 10 }) + : t('mail.activity_log_count', { count: total })} + + + + + + + )} + + )} + + ); +} + +function ActivityItem({ + item, + t, +}: { + item: MailBlockedItem; + t: (k: string, opts?: any) => string; +}) { + return ( + + + + + + + {item.subject || t('mail.activity_no_subject')} + + + {item.sender_name || item.sender_email} + + + + {formatDate(item.received_at, t)} + + + ); +} diff --git a/apps/rebreak-native/components/mail/MailEmptyState.tsx b/apps/rebreak-native/components/mail/MailEmptyState.tsx new file mode 100644 index 0000000..ee64e0c --- /dev/null +++ b/apps/rebreak-native/components/mail/MailEmptyState.tsx @@ -0,0 +1,100 @@ +import { Pressable, Text, View } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { useTranslation } from 'react-i18next'; + +type Props = { + onConnectPress: () => void; +}; + +/** + * Leerer Zustand wenn kein Postfach verbunden ist. + * Hero-CTA öffnet ConnectMailSheet. + */ +export function MailEmptyState({ onConnectPress }: Props) { + const { t } = useTranslation(); + + return ( + + {/* Icon-Circle */} + + + + + + {t('mail.empty_state_title')} + + + + {t('mail.empty_state_subtitle')} + + + {/* Privacy-Punkte */} + + {(['privacy_1', 'privacy_2', 'privacy_3'] as const).map((key) => ( + + + + {t(`mail.${key}`)} + + + ))} + + + {/* CTA */} + ({ + backgroundColor: '#007AFF', + borderRadius: 14, + paddingVertical: 14, + paddingHorizontal: 28, + alignSelf: 'stretch', + alignItems: 'center', + opacity: pressed ? 0.85 : 1, + })} + > + + {t('mail.empty_state_cta')} + + + + ); +} diff --git a/apps/rebreak-native/components/mail/MailStatsRow.tsx b/apps/rebreak-native/components/mail/MailStatsRow.tsx new file mode 100644 index 0000000..9c84d25 --- /dev/null +++ b/apps/rebreak-native/components/mail/MailStatsRow.tsx @@ -0,0 +1,95 @@ +import { Text, View } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { useTranslation } from 'react-i18next'; + +type Props = { + totalBlocked: number; + accountCount: number; + isLegend?: boolean; +}; + +/** + * Kompakte Stats: nur "Blockiert gesamt" prominent + kleines Status-Pill. + * Kein Block-Rate, kein Next-Scan — Mode steht auf den Cards. + */ +export function MailStatsRow({ totalBlocked, accountCount, isLegend }: Props) { + const { t } = useTranslation(); + + return ( + + + + + {t('mail.stats_blocked')} + + + {totalBlocked.toLocaleString()} + + + {t('mail.stats_account_summary', { count: accountCount })} + + + + {/* Mode pill */} + + + + {isLegend ? t('mail.live') : t('mail.scheduled')} + + + + + ); +} diff --git a/apps/rebreak-native/components/urge/Breathing.tsx b/apps/rebreak-native/components/urge/Breathing.tsx new file mode 100644 index 0000000..2312075 --- /dev/null +++ b/apps/rebreak-native/components/urge/Breathing.tsx @@ -0,0 +1,145 @@ +// 4-7-8 Atemübung: Card (in-chat) + Drawer (bottom sheet). +import { useEffect, useRef, useState } from 'react'; +import { View, Text, Pressable, Animated, StyleSheet } from 'react-native'; +import { BREATH_PHASES, TOTAL_ROUNDS, type BreathState } from '../../lib/sosConstants'; +import { colors } from '../../lib/theme'; + +type Props = { onDone: () => void; onSpeak?: (text: string) => Promise | void }; + +export function BreathingCard({ onDone, onSpeak }: Props) { + const [breathState, setBreathState] = useState('idle'); + const [countdown, setCountdown] = useState(3); + const [round, setRound] = useState(1); + const [phaseIndex, setPhaseIndex] = useState(0); + const [count, setCount] = useState(BREATH_PHASES[0]!.duration); + const pulse = useRef(new Animated.Value(1)).current; + const timerRef = useRef | null>(null); + const animRef = useRef(null); + const currentPhase = BREATH_PHASES[phaseIndex]!; + + function runPulse(target: number, seconds: number) { + animRef.current?.stop(); + animRef.current = Animated.timing(pulse, { toValue: target, duration: seconds * 1000, useNativeDriver: true }); + animRef.current.start(); + } + + // Countdown: visually 3 → 2 → 1 → 0 ("Los!"), then transition to active + useEffect(() => { + if (breathState !== 'countdown') return; + if (countdown > 0) { + const t = setTimeout(() => setCountdown((c) => c - 1), 1000); + return () => clearTimeout(t); + } else { + // Kein "Los!" sprechen — würde laufende Lyra-Antwort abbrechen + const t = setTimeout(() => setBreathState('active'), 500); + return () => clearTimeout(t); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [breathState, countdown]); + + // Breathing phases with TTS guidance + useEffect(() => { + if (breathState !== 'active') return; + let remaining = currentPhase.duration; + setCount(remaining); + runPulse(currentPhase.phase === 'exhale' ? 1 : 1.22, currentPhase.duration); + + // Speak only if speakLine defined — short words avoid overlap (inhale=4s, exhale=8s) + let speakTimer: ReturnType | null = null; + if (currentPhase.speakLine) { + speakTimer = setTimeout(() => onSpeak?.(currentPhase.speakLine!), 350); + } + + timerRef.current = setInterval(() => { + remaining -= 1; + setCount(remaining); + if (remaining <= 0) { + clearInterval(timerRef.current!); + const next = phaseIndex + 1; + if (next >= BREATH_PHASES.length) { + if (round >= TOTAL_ROUNDS) { + // Lob ZUERST komplett ausspielen, DANN onDone (das triggert Lyras nächste Frage) + (async () => { + try { await onSpeak?.('Sehr gut! Du hast alle drei Runden geschafft. Wunderbar gemacht!'); } catch {} + onDone(); + })(); + return; + } + // Nächste Runde still starten + setRound((r) => r + 1); + setPhaseIndex(0); + } else { + setPhaseIndex(next); + } + } + }, 1000); + return () => { + if (timerRef.current) clearInterval(timerRef.current); + if (speakTimer) clearTimeout(speakTimer); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [breathState, phaseIndex, round]); + + return ( + + {breathState === 'idle' ? ( + + 4-7-8 Atemübung + 3 Runden · beruhigt dein Nervensystem + { setCountdown(3); setBreathState('countdown'); }}> + Starten + + + ) : breathState === 'countdown' ? ( + + Gleich geht's los... + + {countdown > 0 ? countdown : '✓'} + + + ) : ( + + Runde {round} / {TOTAL_ROUNDS} + + + {count} + {currentPhase.label} + + + + )} + + ); +} + +// ── BreathingDrawer (bottom sheet, covers input, slides up) ─────────────────── +export function BreathingDrawer({ onDone, onSpeak }: Props) { + const slideAnim = useRef(new Animated.Value(500)).current; + useEffect(() => { + Animated.spring(slideAnim, { toValue: 0, useNativeDriver: true, damping: 22, mass: 1, stiffness: 200 }).start(); + }, []); + return ( + <> + + + + + + + ); +} + +const st = StyleSheet.create({ + breathBackdrop: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: 'rgba(0,0,0,0.28)', zIndex: 20 }, + breathDrawerContainer: { position: 'absolute', bottom: 0, left: 0, right: 0, zIndex: 21, backgroundColor: '#ffffff', borderTopLeftRadius: 28, borderTopRightRadius: 28, paddingBottom: 36, shadowColor: '#000', shadowOffset: { width: 0, height: -4 }, shadowOpacity: 0.18, shadowRadius: 20, elevation: 24 }, + breathDrawerHandle: { width: 40, height: 4, borderRadius: 2, backgroundColor: '#d1d5db', alignSelf: 'center', marginTop: 14, marginBottom: 4 }, + breathCardInner: { paddingHorizontal: 24, paddingTop: 20, paddingBottom: 8, alignItems: 'center', gap: 16 }, + breathCircleLg: { width: 190, height: 190, borderRadius: 95, alignItems: 'center', justifyContent: 'center', borderWidth: 5 }, + breathCountLg: { fontFamily: 'Nunito_800ExtraBold', fontSize: 60, color: '#111827', lineHeight: 68 }, + breathTitle: { fontFamily: 'Nunito_700Bold', fontSize: 15, color: '#111827' }, + breathSub: { fontFamily: 'Nunito_400Regular', fontSize: 13, color: '#6b7280', textAlign: 'center' }, + breathStartBtn: { borderRadius: 12, backgroundColor: colors.brandOrange, paddingHorizontal: 28, paddingVertical: 10, marginTop: 4 }, + breathStartTxt: { color: '#fff', fontFamily: 'Nunito_700Bold', fontSize: 14 }, + breathRound: { fontFamily: 'Nunito_600SemiBold', fontSize: 12, color: '#9ca3af' }, + breathPhaseLabel: { fontFamily: 'Nunito_700Bold', fontSize: 13 }, +}); diff --git a/apps/rebreak-native/components/urge/GamePickerDrawer.tsx b/apps/rebreak-native/components/urge/GamePickerDrawer.tsx new file mode 100644 index 0000000..1b46e9b --- /dev/null +++ b/apps/rebreak-native/components/urge/GamePickerDrawer.tsx @@ -0,0 +1,38 @@ +// Bottom-Sheet mit 4 Mini-Spielen zur Ablenkung im SOS-Flow. +import { useEffect, useRef } from 'react'; +import { View, Text, Pressable, Animated, StyleSheet } from 'react-native'; +import { type GameType, GamePickerGrid } from './UrgeGames'; + +type Props = { onSelect: (game: GameType) => void; onClose: () => void }; + +export default function GamePickerDrawer({ onSelect, onClose }: Props) { + const slideAnim = useRef(new Animated.Value(500)).current; + useEffect(() => { + Animated.spring(slideAnim, { toValue: 0, useNativeDriver: true, damping: 22, mass: 1, stiffness: 200 }).start(); + }, []); + return ( + <> + + + + + + Wähl ein Spiel + + + Lenk deinen Kopf ab — nur ein paar Minuten + + + + + + + + ); +} + +const st = StyleSheet.create({ + backdrop: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: 'rgba(0,0,0,0.28)', zIndex: 20 }, + drawerContainer: { position: 'absolute', bottom: 0, left: 0, right: 0, zIndex: 21, backgroundColor: '#ffffff', borderTopLeftRadius: 28, borderTopRightRadius: 28, paddingBottom: 36, shadowColor: '#000', shadowOffset: { width: 0, height: -4 }, shadowOpacity: 0.18, shadowRadius: 20, elevation: 24 }, + drawerHandle: { width: 40, height: 4, borderRadius: 2, backgroundColor: '#d1d5db', alignSelf: 'center', marginTop: 14, marginBottom: 4 }, +}); diff --git a/apps/rebreak-native/components/urge/InlineIndicators.tsx b/apps/rebreak-native/components/urge/InlineIndicators.tsx new file mode 100644 index 0000000..0c1ac13 --- /dev/null +++ b/apps/rebreak-native/components/urge/InlineIndicators.tsx @@ -0,0 +1,53 @@ +// Kleine Indikator-Komponenten für den SOS-Header / Chat: +// - ThinkingDots: 3 hüpfende Punkte ("Lyra denkt nach") +// - VoiceBars: animierte Balken für Sprach-Aktivität +import { useEffect, useRef } from 'react'; +import { View, Animated, StyleSheet } from 'react-native'; + +export function ThinkingDots() { + const anim = useRef([new Animated.Value(0), new Animated.Value(0), new Animated.Value(0)]).current; + useEffect(() => { + const animations = anim.map((a, i) => + Animated.loop(Animated.sequence([ + Animated.delay(i * 160), + Animated.timing(a, { toValue: 1, duration: 300, useNativeDriver: true }), + Animated.timing(a, { toValue: 0, duration: 300, useNativeDriver: true }), + ])), + ); + animations.forEach((a) => a.start()); + return () => animations.forEach((a) => a.stop()); + }, []); + return ( + + {anim.map((a, i) => ( + + ))} + + ); +} + +export function VoiceBars({ count, baseColor }: { count: number; baseColor: string }) { + const anims = useRef(Array.from({ length: count }, () => new Animated.Value(4))).current; + useEffect(() => { + const animations = anims.map((a, i) => + Animated.loop(Animated.sequence([ + Animated.timing(a, { toValue: 4 + Math.random() * 14, duration: 450 + (i % 5) * 80, useNativeDriver: false }), + Animated.timing(a, { toValue: 4, duration: 450 + (i % 5) * 80, useNativeDriver: false }), + ])), + ); + animations.forEach((a) => a.start()); + return () => animations.forEach((a) => a.stop()); + }, []); + return ( + + {anims.map((a, i) => ( + + ))} + + ); +} + +const st = StyleSheet.create({ + thinkingRow: { flexDirection: 'row', gap: 4, paddingHorizontal: 4, paddingVertical: 2, alignItems: 'center' }, + thinkingDot: { width: 7, height: 7, borderRadius: 3.5, backgroundColor: '#9ca3af' }, +}); diff --git a/apps/rebreak-native/components/urge/InlineRatingDrawer.tsx b/apps/rebreak-native/components/urge/InlineRatingDrawer.tsx new file mode 100644 index 0000000..da2d657 --- /dev/null +++ b/apps/rebreak-native/components/urge/InlineRatingDrawer.tsx @@ -0,0 +1,205 @@ +import { useEffect, useRef, useState } from 'react'; +import { + View, + Text, + Pressable, + TextInput, + StyleSheet, + Animated, + KeyboardAvoidingView, + Platform, + ScrollView, +} from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { colors } from '../../lib/theme'; +import type { SosFeedback } from './SosFeedbackModal'; + +/** + * Inline-Bewertungs-Drawer (Bottom-Sheet) als Alternative zum Exit-Modal. + * Wenn der User hier bewertet, soll der Exit-Modal nicht mehr erscheinen. + */ +export function InlineRatingDrawer({ + onSubmit, + onClose, +}: { + onSubmit: (feedback: SosFeedback) => Promise | void; + onClose: () => void; +}) { + const slide = useRef(new Animated.Value(600)).current; + const [better, setBetter] = useState(null); + const [rating, setRating] = useState(0); + const [text, setText] = useState(''); + const [submitting, setSubmitting] = useState(false); + + useEffect(() => { + Animated.spring(slide, { + toValue: 0, + useNativeDriver: true, + damping: 22, + mass: 1, + stiffness: 200, + }).start(); + }, []); + + async function submit() { + if (submitting) return; + setSubmitting(true); + try { + await onSubmit({ + better, + rating: rating > 0 ? rating : null, + text: text.trim(), + }); + } finally { + setSubmitting(false); + } + } + + return ( + <> + + + + + + + + Bewerte diese Session + + + Dein Feedback hilft uns, Lyra besser zu machen. + + + Fühlst du dich besser? + + setBetter(true)} + > + + Ja + + setBetter(false)} + > + + Nein + + + + Bewertung + + {[1, 2, 3, 4, 5].map((n) => ( + setRating(n)} hitSlop={6}> + + + ))} + + + Bemerkung (optional) + + + + + Abbrechen + + + {submitting ? 'Sende…' : 'Senden'} + + + + + + + ); +} + +const s = StyleSheet.create({ + backdrop: { + position: 'absolute', + top: 0, left: 0, right: 0, bottom: 0, + backgroundColor: 'rgba(0,0,0,0.35)', + }, + drawer: { + position: 'absolute', + left: 0, right: 0, bottom: 0, + backgroundColor: '#fff', + borderTopLeftRadius: 24, borderTopRightRadius: 24, + paddingHorizontal: 18, paddingTop: 8, paddingBottom: 22, + maxHeight: '88%', + ...Platform.select({ + ios: { shadowColor: '#000', shadowOffset: { width: 0, height: -4 }, shadowOpacity: 0.18, shadowRadius: 12 }, + android: { elevation: 12 }, + }), + }, + handle: { + width: 40, height: 4, borderRadius: 2, + backgroundColor: '#e2e8f0', + alignSelf: 'center', + marginBottom: 12, + }, + header: { flexDirection: 'row', alignItems: 'center', gap: 8 }, + title: { fontFamily: 'Nunito_800ExtraBold', fontSize: 19, color: '#111827' }, + sub: { fontFamily: 'Nunito_400Regular', fontSize: 13, color: '#6b7280', marginTop: 4 }, + q: { fontFamily: 'Nunito_700Bold', fontSize: 13, color: '#374151', marginTop: 14 }, + btnRow: { flexDirection: 'row', gap: 10, marginTop: 6 }, + choiceBtn: { + flex: 1, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 6, + borderWidth: 1, borderColor: '#cbd5e1', backgroundColor: '#f1f5f9', + borderRadius: 12, paddingVertical: 11, + }, + choiceBtnYes: { backgroundColor: '#16a34a', borderColor: '#16a34a' }, + choiceBtnNo: { backgroundColor: '#dc2626', borderColor: '#dc2626' }, + choiceTxt: { fontFamily: 'Nunito_700Bold', fontSize: 14, color: '#374151' }, + starsRow: { flexDirection: 'row', justifyContent: 'center', gap: 8, marginTop: 8 }, + textArea: { + marginTop: 6, backgroundColor: '#f8fafc', borderRadius: 12, + borderWidth: 1, borderColor: '#e2e8f0', + paddingHorizontal: 12, paddingVertical: 10, minHeight: 80, + fontFamily: 'Nunito_400Regular', fontSize: 14, color: '#111827', + textAlignVertical: 'top', + }, + actions: { flexDirection: 'row', gap: 10, marginTop: 18 }, + cancelBtn: { + flex: 1, paddingVertical: 12, borderRadius: 12, + alignItems: 'center', backgroundColor: '#f1f5f9', + }, + cancelTxt: { fontFamily: 'Nunito_700Bold', fontSize: 14, color: '#475569' }, + submitBtn: { + flex: 2, paddingVertical: 12, borderRadius: 12, + alignItems: 'center', backgroundColor: colors.brandOrange, + }, + submitTxt: { fontFamily: 'Nunito_800ExtraBold', fontSize: 14, color: '#fff' }, +}); diff --git a/apps/rebreak-native/components/urge/MessageRow.tsx b/apps/rebreak-native/components/urge/MessageRow.tsx new file mode 100644 index 0000000..421e303 --- /dev/null +++ b/apps/rebreak-native/components/urge/MessageRow.tsx @@ -0,0 +1,90 @@ +// Chat-Bubble + Spezial-Cards (Spiele/Überwunden) für den SOS-Chat-Stream +// sowie GameHeader für die aktive Spiel-Session. +import { View, Text, Pressable, StyleSheet } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { type GameType, GAME_META, GamePickerGrid } from './UrgeGames'; +import { RiveAvatar, type Emotion as LyraEmotion } from '../RiveAvatar'; + +export type CardType = 'games' | 'overcome'; +export type SosMsg = { id: string; role: 'user' | 'assistant'; content: string; cardType?: CardType; timestamp: Date }; + +// ── GameCard ───────────────────────────────────────────────────────────────── +export function GameCard({ onSelect }: { onSelect: (game: GameType) => void }) { + return ( + + Welches Spiel? + + + ); +} + +// ── OvercomeCard ───────────────────────────────────────────────────────────── +export function OvercomeCard() { + return ( + + 🎉 + Gut gemacht. + Du hast den Impuls überwunden. + + ); +} + +// ── MessageRow ─────────────────────────────────────────────────────────────── +type MessageRowProps = { + item: SosMsg; + onGameSelect: (game: GameType) => void; + onBreathingDone: () => void; + onSpeak?: (text: string) => Promise | void; +}; + +export default function MessageRow({ item, onGameSelect }: MessageRowProps) { + const isUser = item.role === 'user'; + if (item.cardType === 'games') return ; + if (item.cardType === 'overcome') return ; + return ( + + + + {item.content} + + + + ); +} + +// ── GameHeader ─────────────────────────────────────────────────────────────── +export function GameHeader({ game, emotion, onBack }: { game: GameType; emotion: LyraEmotion; onBack: () => void }) { + const meta = GAME_META.find((g) => g.id === game); + return ( + + + + + {meta?.id ?? game} + + + + ); +} + +const st = StyleSheet.create({ + msgRow: { flexDirection: 'row', marginBottom: 4, alignItems: 'flex-end' }, + msgRowUser: { justifyContent: 'flex-end' }, + msgRowAssistant: { justifyContent: 'flex-start', marginBottom: 6 }, + bubbleCol: { maxWidth: '75%', gap: 2 }, + bubbleColUser: { alignItems: 'flex-end' }, + bubbleColAssistant: { alignItems: 'flex-start' }, + bubble: { borderRadius: 20, paddingHorizontal: 14, paddingVertical: 9 }, + bubbleUser: { backgroundColor: '#007AFF', borderBottomRightRadius: 4 }, + bubbleAssistant: { backgroundColor: '#f0f0f0', borderBottomLeftRadius: 4 }, + bubbleText: { fontSize: 15, lineHeight: 22 }, + bubbleTextUser: { color: '#ffffff', fontFamily: 'Nunito_400Regular' }, + bubbleTextAssistant: { color: '#1a1a1a', fontFamily: 'Nunito_400Regular' }, + gameCard: { backgroundColor: '#f0f9ff', borderRadius: 16, borderWidth: 1, borderColor: '#bae6fd', padding: 12, maxWidth: '92%' }, + gameCardTitle: { fontFamily: 'Nunito_700Bold', color: '#0369a1', fontSize: 13, marginBottom: 10 }, + overcomeCard: { backgroundColor: '#f0fdf4', borderRadius: 16, borderWidth: 1, borderColor: '#86efac', padding: 16, alignItems: 'center', maxWidth: '88%', gap: 4 }, + overcomeTitle: { fontFamily: 'Nunito_800ExtraBold', fontSize: 18, color: '#15803d' }, + overcomeSub: { fontFamily: 'Nunito_400Regular', fontSize: 13, color: '#166534', textAlign: 'center' }, + gameHeader: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 12, paddingVertical: 8, borderBottomWidth: 1, borderBottomColor: '#f3f4f6', backgroundColor: '#fff' }, + backBtn: { width: 40, height: 40, borderRadius: 20, backgroundColor: '#f3f4f6', alignItems: 'center', justifyContent: 'center' }, +}); diff --git a/apps/rebreak-native/components/urge/ShareSuccessDrawer.tsx b/apps/rebreak-native/components/urge/ShareSuccessDrawer.tsx new file mode 100644 index 0000000..2e00899 --- /dev/null +++ b/apps/rebreak-native/components/urge/ShareSuccessDrawer.tsx @@ -0,0 +1,211 @@ +import { useEffect, useRef, useState } from 'react'; +import { + View, + Text, + Pressable, + TextInput, + StyleSheet, + Animated, + KeyboardAvoidingView, + Platform, + ActivityIndicator, + ScrollView, +} from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { colors } from '../../lib/theme'; + +export interface ShareSuccessPayload { + text: string; +} + +/** + * Bottom-Sheet zum Teilen einer Erfolgs-Story in der Community. + * AI-generierter Vorschlagstext (vom Parent via prop), editierbar. + */ +export function ShareSuccessDrawer({ + initialText, + generating, + onShare, + onClose, + onRegenerate, +}: { + initialText: string; + generating: boolean; + onShare: (text: string) => Promise | void; + onClose: () => void; + onRegenerate?: () => void; +}) { + const slide = useRef(new Animated.Value(600)).current; + const [text, setText] = useState(initialText); + const [submitting, setSubmitting] = useState(false); + + useEffect(() => { + Animated.spring(slide, { + toValue: 0, + useNativeDriver: true, + damping: 22, + mass: 1, + stiffness: 200, + }).start(); + }, []); + + // Sync wenn initialText neu generiert wird + useEffect(() => { + setText(initialText); + }, [initialText]); + + async function handleShare() { + if (!text.trim() || submitting) return; + setSubmitting(true); + try { + await onShare(text.trim()); + } finally { + setSubmitting(false); + } + } + + return ( + <> + + + + + + + + Erfolg teilen + + + Inspiriere andere — dein Beitrag wird anonym in der Community gepostet. + + + {generating ? ( + + + Lyra schreibt einen Vorschlag… + + ) : ( + + )} + + + {onRegenerate && ( + + + Neu generieren + + )} + + Abbrechen + + + {submitting ? ( + + ) : ( + <> + + Teilen + + )} + + + + + + + ); +} + +const s = StyleSheet.create({ + backdrop: { + position: 'absolute', + top: 0, left: 0, right: 0, bottom: 0, + backgroundColor: 'rgba(0,0,0,0.35)', + }, + drawer: { + position: 'absolute', + left: 0, right: 0, bottom: 0, + backgroundColor: '#fff', + borderTopLeftRadius: 24, + borderTopRightRadius: 24, + paddingHorizontal: 18, + paddingTop: 8, + paddingBottom: 22, + maxHeight: '85%', + ...Platform.select({ + ios: { shadowColor: '#000', shadowOffset: { width: 0, height: -4 }, shadowOpacity: 0.18, shadowRadius: 12 }, + android: { elevation: 12 }, + }), + }, + handle: { + width: 40, height: 4, borderRadius: 2, + backgroundColor: '#e2e8f0', + alignSelf: 'center', + marginBottom: 12, + }, + header: { flexDirection: 'row', alignItems: 'center', gap: 8 }, + title: { fontFamily: 'Nunito_800ExtraBold', fontSize: 19, color: '#111827' }, + sub: { fontFamily: 'Nunito_400Regular', fontSize: 13, color: '#6b7280', marginTop: 4, marginBottom: 12 }, + textArea: { + backgroundColor: '#f8fafc', + borderRadius: 14, + borderWidth: 1, borderColor: '#e2e8f0', + paddingHorizontal: 14, paddingVertical: 12, + minHeight: 110, maxHeight: 220, + fontFamily: 'Nunito_400Regular', fontSize: 15, color: '#111827', + textAlignVertical: 'top', + }, + loadingBox: { + backgroundColor: '#f8fafc', + borderRadius: 14, borderWidth: 1, borderColor: '#e2e8f0', + paddingVertical: 28, paddingHorizontal: 14, + alignItems: 'center', gap: 10, + minHeight: 110, + justifyContent: 'center', + }, + loadingTxt: { fontFamily: 'Nunito_500Medium', fontSize: 13, color: '#64748b' }, + row: { flexDirection: 'row', alignItems: 'center', gap: 8, marginTop: 14, flexWrap: 'wrap' }, + secondaryBtn: { + flexDirection: 'row', alignItems: 'center', gap: 6, + paddingHorizontal: 12, paddingVertical: 10, + borderRadius: 12, backgroundColor: '#f1f5f9', + borderWidth: 1, borderColor: '#cbd5e1', + }, + secondaryTxt: { fontFamily: 'Nunito_700Bold', fontSize: 13, color: '#475569' }, + cancelBtn: { + paddingHorizontal: 14, paddingVertical: 10, + borderRadius: 12, backgroundColor: '#f1f5f9', + }, + cancelTxt: { fontFamily: 'Nunito_700Bold', fontSize: 13, color: '#475569' }, + shareBtn: { + flex: 1, minWidth: 110, + flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 6, + backgroundColor: colors.brandOrange, + borderRadius: 12, paddingVertical: 12, + }, + shareBtnDisabled: { opacity: 0.5 }, + shareTxt: { fontFamily: 'Nunito_800ExtraBold', fontSize: 14, color: '#fff' }, +}); diff --git a/apps/rebreak-native/components/urge/SosFeedbackModal.tsx b/apps/rebreak-native/components/urge/SosFeedbackModal.tsx new file mode 100644 index 0000000..85f57fe --- /dev/null +++ b/apps/rebreak-native/components/urge/SosFeedbackModal.tsx @@ -0,0 +1,141 @@ +import { useState } from 'react'; +import { View, Text, Pressable, TextInput, Modal, StyleSheet, Platform, KeyboardAvoidingView, ScrollView } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { colors } from '../../lib/theme'; + +export interface SosFeedback { + better: boolean | null; + rating: number | null; + text: string; +} + +export function SosFeedbackModal({ + visible, + onSubmit, + onSkip, +}: { + visible: boolean; + onSubmit: (feedback: SosFeedback) => void; + onSkip: () => void; +}) { + const [better, setBetter] = useState(null); + const [rating, setRating] = useState(0); + const [text, setText] = useState(''); + + function reset() { + setBetter(null); setRating(0); setText(''); + } + + function submit() { + onSubmit({ better, rating: rating > 0 ? rating : null, text: text.trim() }); + reset(); + } + function skip() { onSkip(); reset(); } + + return ( + + + + + Wie war diese Session? + Dein Feedback hilft Lyra besser zu werden. + + {/* Better Yes/No */} + Fühlst du dich besser? + + setBetter(true)} + > + + Ja + + setBetter(false)} + > + + Nein + + + + {/* Stars */} + Bewertung + + {[1, 2, 3, 4, 5].map((n) => ( + setRating(n)} hitSlop={6}> + + + ))} + + + {/* Comment */} + Bemerkung (optional) + + + {/* Actions */} + + + Überspringen + + + Senden + + + + + + + ); +} + +const s = StyleSheet.create({ + backdrop: { flex: 1, backgroundColor: 'rgba(0,0,0,0.45)' }, + scrollContent: { flexGrow: 1, alignItems: 'center', justifyContent: 'center', padding: 20 }, + card: { width: '100%', maxWidth: 420, backgroundColor: '#fff', borderRadius: 24, padding: 22, gap: 8, ...Platform.select({ ios: { shadowColor: '#000', shadowOffset: { width: 0, height: 10 }, shadowOpacity: 0.25, shadowRadius: 20 }, android: { elevation: 10 } }) }, + title: { fontFamily: 'Nunito_800ExtraBold', fontSize: 19, color: '#111827' }, + sub: { fontFamily: 'Nunito_400Regular', fontSize: 13, color: '#6b7280', marginBottom: 8 }, + q: { fontFamily: 'Nunito_700Bold', fontSize: 13, color: '#374151', marginTop: 12 }, + btnRow: { flexDirection: 'row', gap: 10, marginTop: 6 }, + choiceBtn: { + flex: 1, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 6, + borderWidth: 1, borderColor: '#cbd5e1', backgroundColor: '#f1f5f9', + borderRadius: 12, paddingVertical: 11, + }, + choiceBtnYes: { backgroundColor: '#16a34a', borderColor: '#16a34a' }, + choiceBtnNo: { backgroundColor: '#dc2626', borderColor: '#dc2626' }, + choiceTxt: { fontFamily: 'Nunito_700Bold', fontSize: 14, color: '#374151' }, + starsRow: { flexDirection: 'row', justifyContent: 'center', gap: 8, marginTop: 8 }, + textArea: { + marginTop: 6, backgroundColor: '#f8fafc', borderRadius: 12, + borderWidth: 1, borderColor: '#e2e8f0', + paddingHorizontal: 12, paddingVertical: 10, minHeight: 70, + fontFamily: 'Nunito_400Regular', fontSize: 14, color: '#111827', + textAlignVertical: 'top', + }, + actions: { flexDirection: 'row', gap: 10, marginTop: 18 }, + skipBtn: { flex: 1, paddingVertical: 12, borderRadius: 12, alignItems: 'center', backgroundColor: '#f1f5f9' }, + skipTxt: { fontFamily: 'Nunito_700Bold', fontSize: 14, color: '#475569' }, + submitBtn: { flex: 2, paddingVertical: 12, borderRadius: 12, alignItems: 'center', backgroundColor: colors.brandOrange }, + submitTxt: { fontFamily: 'Nunito_800ExtraBold', fontSize: 14, color: '#fff' }, +}); diff --git a/apps/rebreak-native/components/urge/TtsProviderToggle.tsx b/apps/rebreak-native/components/urge/TtsProviderToggle.tsx new file mode 100644 index 0000000..9797b0a --- /dev/null +++ b/apps/rebreak-native/components/urge/TtsProviderToggle.tsx @@ -0,0 +1,60 @@ +import { Pressable, Text, View } from 'react-native'; +import { TTS_PROVIDER_LABEL, type TtsProvider, useTtsProvider } from '../../lib/ttsProvider'; + +const PROVIDERS: TtsProvider[] = ['openai', 'gemini', 'google-cloud']; + +export function TtsProviderToggle() { + const [current, set] = useTtsProvider(); + return ( + + + TTS + + {PROVIDERS.map((p) => { + const active = p === current; + return ( + { void set(p); }} + hitSlop={6} + style={{ + paddingHorizontal: 10, + paddingVertical: 4, + borderRadius: 999, + backgroundColor: active ? '#1f2937' : '#f9fafb', + borderWidth: 1, + borderColor: active ? '#1f2937' : '#e5e7eb', + }} + > + + {TTS_PROVIDER_LABEL[p]} + + + ); + })} + + ); +} diff --git a/apps/rebreak-native/components/urge/UrgeGames.tsx b/apps/rebreak-native/components/urge/UrgeGames.tsx new file mode 100644 index 0000000..dfeabbb --- /dev/null +++ b/apps/rebreak-native/components/urge/UrgeGames.tsx @@ -0,0 +1,1056 @@ +import { useEffect, useMemo, useRef, useState, useCallback } from 'react'; +import { View, Text, Pressable, Dimensions, PanResponder, Platform } from 'react-native'; +import Svg, { Defs, Pattern, Path, Rect, Polyline, Circle, Line } from 'react-native-svg'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { SvgXml } from 'react-native-svg'; +import { Ionicons } from '@expo/vector-icons'; +import { useTranslation } from 'react-i18next'; +import * as Haptics from 'expo-haptics'; +import Slider from '@react-native-community/slider'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { memorySvg, snakeSvg, tetrisSvg, tictactoeSvg } from './gameSvgs'; + +// Haptic helper — fire-and-forget, swallow errors on platforms without taptic engine +function tapHaptic() { + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch(() => {}); +} +function mediumHaptic() { + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium).catch(() => {}); +} + +export type GameType = 'memory' | 'tictactoe' | 'snake' | 'tetris'; + +export const GAME_META: Array<{ id: GameType; svg: string; titleKey: string; descKey: string }> = [ + { id: 'memory', svg: memorySvg, titleKey: 'urge.game_memory', descKey: 'urge.game_memory_desc' }, + { id: 'tictactoe', svg: tictactoeSvg, titleKey: 'urge.game_tictactoe', descKey: 'urge.game_tictactoe_desc' }, + { id: 'snake', svg: snakeSvg, titleKey: 'urge.game_snake', descKey: 'urge.game_snake_desc' }, + { id: 'tetris', svg: tetrisSvg, titleKey: 'urge.game_tetris', descKey: 'urge.game_tetris_desc' }, +]; + +// ── Game picker grid ────────────────────────────────────────────────────────── + +export function GamePickerGrid({ onSelect }: { onSelect: (game: GameType) => void }) { + const { t } = useTranslation(); + return ( + + {GAME_META.map((game) => ( + onSelect(game.id)} + style={({ pressed }) => ({ + width: '47%', + aspectRatio: 1, + borderRadius: 16, + borderWidth: 1, + borderColor: '#e5e7eb', + backgroundColor: pressed ? '#f0f9ff' : '#f9fafb', + alignItems: 'center', + justifyContent: 'center', + padding: 12, + })} + > + + + {t(game.titleKey)} + + + {t(game.descKey)} + + + ))} + + ); +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function shuffle(arr: T[]): T[] { + const out = [...arr]; + for (let i = out.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + const tmp = out[i]!; + out[i] = out[j]!; + out[j] = tmp; + } + return out; +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// SNAKE — 1:1 Port von apps/rebreak/app/components/sos/GameSnake.vue +// ═══════════════════════════════════════════════════════════════════════════════ + +const SNAKE_ROWS = 20; +const SNAKE_COLS = 15; +const SNAKE_TICK_MS = 180; +type Dir = 'up' | 'down' | 'left' | 'right'; +interface Pos { row: number; col: number } +const OPPOSITES: Record = { up: 'down', down: 'up', left: 'right', right: 'left' }; + +export function SnakeGame({ + onComplete, + onAbandon, +}: { + onComplete: (score: number) => void; + onAbandon: () => void; +}) { + const insets = useSafeAreaInsets(); + // Cell size aus Bildschirmgröße — Header(80) + Drawer-Padding(40) + DPad(220) + Spacing(40) + Home-Indicator + const cell = useMemo(() => { + const win = Dimensions.get('window'); + const maxW = Math.min(win.width - 32, 400); + const maxH = win.height - 80 - 40 - 220 - 40 - Math.max(insets.bottom, 16); + const cellByW = Math.floor(maxW / SNAKE_COLS); + const cellByH = Math.floor(maxH / SNAKE_ROWS); + return Math.max(12, Math.min(cellByW, cellByH)); + }, [insets.bottom]); + const boardW = SNAKE_COLS * cell; + const boardH = SNAKE_ROWS * cell; + + const [snake, setSnake] = useState([ + { row: 10, col: 7 }, { row: 10, col: 6 }, { row: 10, col: 5 }, + ]); + const [food, setFood] = useState({ row: 3, col: 10 }); + const dirRef = useRef('right'); + const nextDirRef = useRef('right'); + const [score, setScore] = useState(0); + const [highScore, setHighScore] = useState(0); + const [gameOver, setGameOver] = useState(false); + const [activeDPad, setActiveDPad] = useState('right'); + const [, forceRender] = useState(0); + const intervalRef = useRef | null>(null); + + // Load high score + useEffect(() => { + AsyncStorage.getItem('rebreak-snake-highscore').then((v) => { + if (v) setHighScore(parseInt(v) || 0); + }); + }, []); + + function setDir(d: Dir) { + if (OPPOSITES[d] !== dirRef.current) nextDirRef.current = d; + } + function onDPad(d: Dir) { + setDir(d); + setActiveDPad(d); + } + + function randomFood(currentSnake: Pos[]): Pos { + const occupied = new Set(currentSnake.map((p) => p.row * SNAKE_COLS + p.col)); + let pos: Pos; + do { + pos = { row: Math.floor(Math.random() * SNAKE_ROWS), col: Math.floor(Math.random() * SNAKE_COLS) }; + } while (occupied.has(pos.row * SNAKE_COLS + pos.col)); + return pos; + } + + function endGame(finalScore: number) { + if (intervalRef.current) { clearInterval(intervalRef.current); intervalRef.current = null; } + setGameOver(true); + if (finalScore > highScore) { + setHighScore(finalScore); + AsyncStorage.setItem('rebreak-snake-highscore', String(finalScore)).catch(() => {}); + } + setTimeout(() => onComplete(finalScore), 500); + } + + // Game tick loop + useEffect(() => { + if (gameOver) return; + intervalRef.current = setInterval(() => { + dirRef.current = nextDirRef.current; + setSnake((prev) => { + const head = prev[0]; + if (!head) return prev; + const next: Pos = { row: head.row, col: head.col }; + if (dirRef.current === 'up') next.row--; + else if (dirRef.current === 'down') next.row++; + else if (dirRef.current === 'left') next.col--; + else if (dirRef.current === 'right') next.col++; + if (next.row < 0 || next.row >= SNAKE_ROWS || next.col < 0 || next.col >= SNAKE_COLS) { + setTimeout(() => endGame(score), 0); + return prev; + } + if (prev.some((s) => s.row === next.row && s.col === next.col)) { + setTimeout(() => endGame(score), 0); + return prev; + } + const ate = next.row === food.row && next.col === food.col; + const newSnake = [next, ...prev]; + if (!ate) newSnake.pop(); + else { + setScore((s) => s + 1); + setFood(randomFood(newSnake)); + } + return newSnake; + }); + forceRender((x) => x + 1); + }, SNAKE_TICK_MS); + return () => { + if (intervalRef.current) clearInterval(intervalRef.current); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [gameOver, food, score, highScore]); + + // Swipe gestures + const panResponder = useMemo( + () => + PanResponder.create({ + onStartShouldSetPanResponder: () => true, + onMoveShouldSetPanResponder: () => true, + onPanResponderRelease: (_, g) => { + const dx = g.dx, dy = g.dy; + if (Math.abs(dx) < 10 && Math.abs(dy) < 10) return; + if (Math.abs(dx) > Math.abs(dy)) onDPad(dx > 0 ? 'right' : 'left'); + else onDPad(dy > 0 ? 'down' : 'up'); + }, + }), + [], + ); + + // Lyra message based on score + const lyraMessage = + score >= 8 ? 'Voll im Flow – der Impuls hat keine Chance!' : + score >= 5 ? 'Sehr gut! Bleib konzentriert.' : + score >= 3 ? 'Super! Weiter so.' : + 'Sammle die roten Äpfel.'; + + // SVG geometry + const snakePoints = snake + .map((s) => `${s.col * cell + cell / 2},${s.row * cell + cell / 2}`) + .join(' '); + + const head = snake[0]; + const eyePositions = head + ? (() => { + const cx = head.col * cell + cell / 2; + const cy = head.row * cell + cell / 2; + const off = cell * 0.22; + switch (dirRef.current) { + case 'right': return [{ x: cx + off * 0.8, y: cy - off }, { x: cx + off * 0.8, y: cy + off }]; + case 'left': return [{ x: cx - off * 0.8, y: cy - off }, { x: cx - off * 0.8, y: cy + off }]; + case 'up': return [{ x: cx - off, y: cy - off * 0.8 }, { x: cx + off, y: cy - off * 0.8 }]; + case 'down': return [{ x: cx - off, y: cy + off * 0.8 }, { x: cx + off, y: cy + off * 0.8 }]; + } + })() + : []; + const pupilPositions = eyePositions.map((e) => { + const d = cell * 0.04; + switch (dirRef.current) { + case 'right': return { x: e.x + d, y: e.y }; + case 'left': return { x: e.x - d, y: e.y }; + case 'up': return { x: e.x, y: e.y - d }; + case 'down': return { x: e.x, y: e.y + d }; + } + }); + const tongue = head + ? (() => { + const cx = head.col * cell + cell / 2; + const cy = head.row * cell + cell / 2; + const len = cell * 0.45; + const base = cell * 0.4; + switch (dirRef.current) { + case 'right': return { x1: cx + base, y1: cy, x2: cx + base + len, y2: cy }; + case 'left': return { x1: cx - base, y1: cy, x2: cx - base - len, y2: cy }; + case 'up': return { x1: cx, y1: cy - base, x2: cx, y2: cy - base - len }; + case 'down': return { x1: cx, y1: cy + base, x2: cx, y2: cy + base + len }; + } + })() + : null; + + return ( + + {/* Header */} + + {lyraMessage} + + + Score + {score} + + + Best + {highScore} + + + + + + + + {/* Board */} + + + + + + + + + + {snake.length >= 2 && ( + + )} + {head && } + {eyePositions.map((eye, ei) => ( + + ))} + {pupilPositions.map((p, pi) => ( + + ))} + {tongue && !gameOver && ( + + )} + + + + + + {/* D-Pad */} + {!gameOver && ( + + onDPad('up')} /> + + onDPad('left')} /> + + + + onDPad('right')} /> + + onDPad('down')} /> + + )} + + {gameOver && ( + + Game Over + {score} {score === 1 ? 'Apfel' : 'Äpfel'} gesammelt + + )} + + ); +} + +function DPadBtn({ dir, active, onPress }: { dir: Dir; active: boolean; onPress: () => void }) { + const icons: Record = { + up: 'chevron-up', down: 'chevron-down', left: 'chevron-back', right: 'chevron-forward', + }; + // FIX 1 (prev agent): icon color follows pressed-OR-active so it stays visible against dark pressed-bg. + // FIX 2 (this agent): idle button was #ffffff on a #ffffff screen → invisible. Idle is now light-gray + // with stronger border, pressed becomes mid-gray, active stays dark. Guarantees ≥ 3:1 contrast in all states. + const isHighlighted = active; + return ( + { tapHaptic(); onPress(); }} + hitSlop={12} + android_ripple={{ color: 'rgba(31,41,55,0.18)', borderless: true, radius: 36 }} + style={({ pressed }) => ({ + width: 64, height: 64, borderRadius: 32, + backgroundColor: isHighlighted ? '#1f2937' : (pressed ? '#d1d5db' : '#f3f4f6'), + borderWidth: 1.5, + borderColor: isHighlighted ? '#1f2937' : (pressed ? '#6b7280' : '#9ca3af'), + alignItems: 'center', justifyContent: 'center', + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: pressed ? 0.06 : 0.12, + shadowRadius: 4, + elevation: pressed ? 1 : 3, + transform: [{ scale: pressed ? 0.94 : 1 }], + })} + > + {({ pressed }) => ( + + )} + + ); +} + +// Action button für Tetris (Rotate, Drop) — größer & mit Label. +// Idle: tönt sich leicht in accent-Farbe ein (kein white-on-white-Verlust auf weißem Screen). +function TetrisActionBtn({ + icon, label, onPress, accent, +}: { + icon: 'sync' | 'arrow-down'; + label: string; + onPress: () => void; + accent?: string; +}) { + const accentColor = accent || '#1f2937'; + return ( + { mediumHaptic(); onPress(); }} + hitSlop={12} + android_ripple={{ color: accentColor + '33', borderless: false }} + style={({ pressed }) => ({ + width: 72, height: 72, borderRadius: 20, + // accent + '14' = ~8% Tönung im Idle-State, accent solid auf Press + backgroundColor: pressed ? accentColor : accentColor + '14', + borderWidth: 1.5, + borderColor: accentColor, + alignItems: 'center', justifyContent: 'center', + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: pressed ? 0.05 : 0.12, + shadowRadius: 5, + elevation: pressed ? 1 : 3, + transform: [{ scale: pressed ? 0.95 : 1 }], + })} + > + {({ pressed }) => ( + <> + + {label} + + )} + + ); +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// MEMORY — 1:1 Port von apps/rebreak/app/components/sos/GameMemory.vue +// ═══════════════════════════════════════════════════════════════════════════════ + +const MEMORY_PAIRS = 8; +const MEMORY_EMOJIS = ['🛡️', '💪', '🌟', '🧠', '🌊', '🎯', '🌱', '🔑']; + +export function MemoryGame({ + onComplete, + onAbandon, +}: { + onComplete: (score: number) => void; + onAbandon: () => void; +}) { + type Card = { id: number; emoji: string; matched: boolean; revealed: boolean }; + const [cards, setCards] = useState([]); + const [flipped, setFlipped] = useState([]); + const [moveCount, setMoveCount] = useState(0); + const [matchedCount, setMatchedCount] = useState(0); + const [blocked, setBlocked] = useState(false); + + function init() { + const pairs = shuffle([...MEMORY_EMOJIS, ...MEMORY_EMOJIS]); + setCards(pairs.map((emoji, id) => ({ id, emoji, matched: false, revealed: false }))); + setFlipped([]); + setMoveCount(0); + setMatchedCount(0); + setBlocked(false); + } + useEffect(() => { init(); }, []); + + const lyraMessage = (() => { + const r = matchedCount / MEMORY_PAIRS; + if (r === 0) return 'Dreh die erste Karte um – nimm dir Zeit.'; + if (r < 0.25) return 'Gut. Merk dir die Positionen genau.'; + if (r < 0.5) return 'Du machst das super! Konzentrier dich weiter.'; + if (r < 0.75) return 'Mehr als die Hälfte! Du bist fast da.'; + return 'Wow – nur noch wenige Paare! 🔥'; + })(); + + function flip(id: number) { + if (blocked) return; + const card = cards[id]; + if (!card || card.matched || card.revealed) return; + if (flipped.length >= 2) return; + const next = cards.slice(); + next[id] = { ...next[id]!, revealed: true }; + setCards(next); + const nextFlipped = [...flipped, id]; + setFlipped(nextFlipped); + if (nextFlipped.length === 2) { + const newMoveCount = moveCount + 1; + setMoveCount(newMoveCount); + const [a, b] = nextFlipped; + const ca = next[a]!, cb = next[b]!; + if (ca.emoji === cb.emoji) { + const matched = next.slice(); + matched[a] = { ...ca, matched: true }; + matched[b] = { ...cb, matched: true }; + setCards(matched); + setFlipped([]); + const newMatched = matchedCount + 1; + setMatchedCount(newMatched); + if (newMatched === MEMORY_PAIRS) { + setTimeout(() => onComplete(newMoveCount), 600); + } + } else { + setBlocked(true); + setTimeout(() => { + const reverted = next.slice(); + reverted[a] = { ...reverted[a]!, revealed: false }; + reverted[b] = { ...reverted[b]!, revealed: false }; + setCards(reverted); + setFlipped([]); + setBlocked(false); + }, 900); + } + } + } + + return ( + + {/* Lyra Header */} + + + Lyra + {lyraMessage} + + + Züge + {moveCount} + + + + + + + {/* Progress */} + + + + + {/* Card Grid 4x4 */} + + {cards.map((card) => { + const showFace = card.revealed || card.matched; + return ( + flip(card.id)} + style={{ + width: '22.7%', aspectRatio: 1, borderRadius: 12, borderWidth: 1.5, + borderColor: card.matched ? '#86efac' : showFace ? '#93c5fd' : '#e5e7eb', + backgroundColor: card.matched ? '#f0fdf4' : showFace ? '#eff6ff' : '#f9fafb', + alignItems: 'center', justifyContent: 'center', + opacity: blocked && !showFace ? 0.6 : 1, + transform: [{ scale: card.matched ? 0.95 : 1 }], + }} + > + {showFace ? card.emoji : '🛡️'} + + ); + })} + + + ); +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// TIC-TAC-TOE — 1:1 Port von apps/rebreak/app/components/sos/GameTicTacToe.vue +// ═══════════════════════════════════════════════════════════════════════════════ + +type TTCell = 'X' | 'O' | null; +type TTResult = 'player' | 'lyra' | 'draw' | null; +const TT_WIN_LINES = [ + [0, 1, 2], [3, 4, 5], [6, 7, 8], + [0, 3, 6], [1, 4, 7], [2, 5, 8], + [0, 4, 8], [2, 4, 6], +]; + +function ttCheckWinner(b: TTCell[]): { winner: 'X' | 'O' | null; line: number[] } { + for (const line of TT_WIN_LINES) { + const [a, c, d] = line as [number, number, number]; + if (b[a] && b[a] === b[c] && b[a] === b[d]) return { winner: b[a]!, line }; + } + return { winner: null, line: [] }; +} +function ttEmpty(b: TTCell[]) { return b.map((c, i) => c === null ? i : -1).filter((i) => i !== -1); } +function ttWouldWin(b: TTCell[], idx: number, mark: 'X' | 'O') { + const next = [...b]; next[idx] = mark; + return ttCheckWinner(next).winner === mark; +} +function ttLyraAI(b: TTCell[]): number { + const empty = ttEmpty(b); + for (const i of empty) if (ttWouldWin(b, i, 'O')) return i; // win + for (const i of empty) if (ttWouldWin(b, i, 'X')) return i; // block + if (b[4] === null) return 4; // center + const corners = [0, 2, 6, 8].filter((i) => b[i] === null); + if (corners.length) return corners[Math.floor(Math.random() * corners.length)]!; + return empty[Math.floor(Math.random() * empty.length)]!; +} + +export function TicTacToeGame({ + onComplete, + onAbandon, +}: { + onComplete: (score: number) => void; + onAbandon: () => void; +}) { + const [board, setBoard] = useState(Array(9).fill(null)); + const [gameOver, setGameOver] = useState(false); + const [result, setResult] = useState(null); + const [winLine, setWinLine] = useState([]); + const [lyraThinking, setLyraThinking] = useState(false); + const [playerScore, setPlayerScore] = useState(0); + const [lyraScore, setLyraScore] = useState(0); + const [round, setRound] = useState(1); + + const resultText = + result === 'player' ? '🎉 Du gewinnst diese Runde!' : + result === 'lyra' ? '🤖 Lyra gewinnt diese Runde' : + '🤝 Unentschieden'; + const resultColor = result === 'player' ? '#16a34a' : result === 'lyra' ? '#f43f5e' : '#eab308'; + + const lyraMessage = (() => { + if (lyraThinking) return 'Hmm, lass mich kurz nachdenken…'; + if (result === 'player') return 'Gut gespielt! Du hast diese Runde gewonnen. 👏'; + if (result === 'lyra') return 'Diesmal war ich schneller. Versuch es nochmal!'; + if (result === 'draw') return 'Unentschieden! Gut gekämpft.'; + const empty = board.filter((c) => c === null).length; + if (empty === 9) return 'Du fängst an – ich bin Lyra, du spielst X.'; + if (empty <= 3) return 'Das wird spannend – noch wenige Felder frei!'; + return 'Dein Zug. Ich beobachte genau.'; + })(); + + function applyResult(mark: 'X' | 'O', line: number[]) { + setWinLine(line); + setGameOver(true); + if (mark === 'X') { setResult('player'); setPlayerScore((s) => s + 1); } + else { setResult('lyra'); setLyraScore((s) => s + 1); } + } + + function playerMove(i: number) { + if (board[i] || gameOver || lyraThinking) return; + const next = [...board]; next[i] = 'X'; + setBoard(next); + const r = ttCheckWinner(next); + if (r.winner) { applyResult('X', r.line); return; } + if (ttEmpty(next).length === 0) { setGameOver(true); setResult('draw'); return; } + + setLyraThinking(true); + setTimeout(() => { + const idx = ttLyraAI(next); + const after = [...next]; after[idx] = 'O'; + setBoard(after); + const r2 = ttCheckWinner(after); + if (r2.winner) applyResult('O', r2.line); + else if (ttEmpty(after).length === 0) { setGameOver(true); setResult('draw'); } + setLyraThinking(false); + }, 600); + } + + function newRound() { + setBoard(Array(9).fill(null)); + setGameOver(false); + setResult(null); + setWinLine([]); + setRound((r) => r + 1); + } + + return ( + + {/* Lyra Header */} + + + Lyra + {lyraMessage} + + + + {playerScore} + Du + + : + + {lyraScore} + Lyra + + + + + {/* Board */} + + {board.map((cell, i) => { + const isWin = winLine.includes(i); + return ( + playerMove(i)} + disabled={!!cell || gameOver || lyraThinking} + style={{ + width: '31%', aspectRatio: 1, borderRadius: 16, borderWidth: 1.5, + borderColor: isWin ? '#facc15' : cell === 'X' ? '#3b82f6' : cell === 'O' ? '#f43f5e' : '#e5e7eb', + backgroundColor: isWin ? '#fef9c3' : cell === 'X' ? '#eff6ff' : cell === 'O' ? '#fee2e2' : '#f9fafb', + alignItems: 'center', justifyContent: 'center', + }} + > + {cell ?? ''} + + ); + })} + + + {/* Status */} + + {lyraThinking ? ( + Lyra denkt nach… + ) : gameOver ? ( + <> + {resultText} + Runde {round} + + ) : ( + Dein Zug – du spielst X + )} + + + {/* Actions */} + {gameOver ? ( + + + Nochmal + + onComplete(playerScore)} style={{ flex: 1, paddingVertical: 12, borderRadius: 12, backgroundColor: '#16a34a', alignItems: 'center' }}> + Fertig → + + + ) : ( + + Abbrechen + + )} + + ); +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// TETRIS — 1:1 Port von apps/rebreak/app/components/sos/GameTetris.vue +// ═══════════════════════════════════════════════════════════════════════════════ + +const TETRIS_COLS = 10; +const TETRIS_ROWS = 20; +const TETRIS_SPEED_BASES = [1400, 1000, 700, 450, 250] as const; +const TETRIS_PIECES = [ + { shape: [[1, 1, 1, 1]], color: '#22d3ee' }, // I + { shape: [[1, 1], [1, 1]], color: '#fbbf24' }, // O + { shape: [[0, 1, 0], [1, 1, 1]], color: '#a78bfa' }, // T + { shape: [[0, 1, 1], [1, 1, 0]], color: '#34d399' }, // S + { shape: [[1, 1, 0], [0, 1, 1]], color: '#f87171' }, // Z + { shape: [[1, 0, 0], [1, 1, 1]], color: '#60a5fa' }, // J + { shape: [[0, 0, 1], [1, 1, 1]], color: '#fb923c' }, // L +]; +type TetrisPiece = { shape: number[][]; color: string; x: number; y: number }; + +function tetrisEmptyBoard(): string[][] { + return Array.from({ length: TETRIS_ROWS }, () => Array(TETRIS_COLS).fill('')); +} +function tetrisRandomPiece() { + const p = TETRIS_PIECES[Math.floor(Math.random() * TETRIS_PIECES.length)]!; + return { shape: p.shape.map((r) => [...r]), color: p.color }; +} +function tetrisRotate(shape: number[][]) { + return shape[0]!.map((_, i) => shape.map((row) => row[i]!).reverse()); +} + +export function TetrisGame({ + onComplete, + onAbandon, +}: { + onComplete: (score: number) => void; + onAbandon: () => void; +}) { + const insets = useSafeAreaInsets(); + // CELL aus Bildschirmgröße — Header(80) + Padding(40) + Speed-Stepper(50) + Controls(110) + Spacing(20) + Home-Indicator + const CELL = useMemo(() => { + const win = Dimensions.get('window'); + const maxW = Math.min(win.width - 32, 400); + const maxH = win.height - 80 - 40 - 50 - 110 - 20 - Math.max(insets.bottom, 16); + const cellByW = Math.floor(maxW / TETRIS_COLS); + const cellByH = Math.floor(maxH / TETRIS_ROWS); + return Math.max(12, Math.min(cellByW, cellByH)); + }, [insets.bottom]); + + const [board, setBoard] = useState(tetrisEmptyBoard()); + const [current, setCurrent] = useState(null); + const nextPieceRef = useRef(tetrisRandomPiece()); + const [score, setScore] = useState(0); + const [level, setLevel] = useState(1); + const [lines, setLines] = useState(0); + const [gameOver, setGameOver] = useState(false); + const [highScore, setHighScore] = useState(0); + const [speedLevel, setSpeedLevel] = useState(3); + const tickTimerRef = useRef | null>(null); + + const boardRef = useRef(board); + const currentRef = useRef(current); + useEffect(() => { boardRef.current = board; }, [board]); + useEffect(() => { currentRef.current = current; }, [current]); + + // Load high score + useEffect(() => { + AsyncStorage.getItem('rebreak-tetris-highscore').then((v) => { + if (v) setHighScore(parseInt(v) || 0); + }); + }, []); + + function isValid(piece: TetrisPiece, px: number, py: number, shape = piece.shape): boolean { + const b = boardRef.current; + for (let r = 0; r < shape.length; r++) { + for (let c = 0; c < shape[r]!.length; c++) { + if (!shape[r]![c]) continue; + const nx = px + c, ny = py + r; + if (nx < 0 || nx >= TETRIS_COLS || ny >= TETRIS_ROWS) return false; + if (ny >= 0 && b[ny]![nx]) return false; + } + } + return true; + } + + const spawnPiece = useCallback(() => { + const p = nextPieceRef.current; + nextPieceRef.current = tetrisRandomPiece(); + const x = Math.floor((TETRIS_COLS - p.shape[0]!.length) / 2); + const newPiece: TetrisPiece = { ...p, x, y: 0 }; + if (!isValid(newPiece, newPiece.x, newPiece.y)) { + setGameOver(true); + stopTick(); + const finalScore = score; + if (finalScore > highScore) { + setHighScore(finalScore); + AsyncStorage.setItem('rebreak-tetris-highscore', String(finalScore)).catch(() => {}); + } + setTimeout(() => onComplete(finalScore), 500); + return; + } + setCurrent(newPiece); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [score, highScore, onComplete]); + + function lockPiece() { + const piece = currentRef.current; + if (!piece) return; + const b = boardRef.current.map((r) => [...r]); + for (let r = 0; r < piece.shape.length; r++) { + for (let c = 0; c < piece.shape[r]!.length; c++) { + if (!piece.shape[r]![c]) continue; + const ny = piece.y + r, nx = piece.x + c; + if (ny >= 0) b[ny]![nx] = piece.color; + } + } + const cleared = b.filter((row) => row.every((c) => c !== '')); + const kept = b.filter((row) => row.some((c) => c === '')); + const newLines = cleared.length; + if (newLines > 0) { + setLines((l) => l + newLines); + const pts = [0, 100, 300, 500, 800][newLines] ?? 800; + setScore((s) => s + pts * level); + setLevel((lv) => Math.floor((lines + newLines) / 10) + 1); + resetTick(); + } + const nextBoard = [ + ...Array.from({ length: newLines }, () => Array(TETRIS_COLS).fill('')), + ...kept, + ]; + setBoard(nextBoard); + boardRef.current = nextBoard; + setCurrent(null); + setTimeout(() => spawnPiece(), 0); + } + + function moveLeft() { + const p = currentRef.current; if (!p || gameOver) return; + if (isValid(p, p.x - 1, p.y)) setCurrent({ ...p, x: p.x - 1 }); + } + function moveRight() { + const p = currentRef.current; if (!p || gameOver) return; + if (isValid(p, p.x + 1, p.y)) setCurrent({ ...p, x: p.x + 1 }); + } + function rotatePiece() { + const p = currentRef.current; if (!p || gameOver) return; + const rotated = tetrisRotate(p.shape); + for (const kick of [0, -1, 1, -2, 2]) { + if (isValid(p, p.x + kick, p.y, rotated)) { + setCurrent({ ...p, shape: rotated, x: p.x + kick }); + return; + } + } + } + function softDrop() { + const p = currentRef.current; if (!p || gameOver) return; + if (isValid(p, p.x, p.y + 1)) { + setCurrent({ ...p, y: p.y + 1 }); + setScore((s) => s + 1); + } else { + lockPiece(); + } + } + + function tickInterval() { + const base = TETRIS_SPEED_BASES[speedLevel - 1]!; + return Math.max(80, base - (level - 1) * 40); + } + function startTick() { + tickTimerRef.current = setInterval(() => { + const p = currentRef.current; + if (!p || gameOver) return; + if (isValid(p, p.x, p.y + 1)) setCurrent({ ...p, y: p.y + 1 }); + else lockPiece(); + }, tickInterval()); + } + function stopTick() { + if (tickTimerRef.current) { clearInterval(tickTimerRef.current); tickTimerRef.current = null; } + } + function resetTick() { stopTick(); startTick(); } + + // Init + useEffect(() => { + spawnPiece(); + startTick(); + return () => stopTick(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + // Re-tick when speed/level changes + useEffect(() => { if (!gameOver) resetTick(); /* eslint-disable-next-line */ }, [speedLevel, level]); + + // Display board (board + current piece) + const displayBoard = useMemo(() => { + const b = board.map((r) => [...r]); + if (current) { + for (let r = 0; r < current.shape.length; r++) { + for (let c = 0; c < current.shape[r]!.length; c++) { + if (!current.shape[r]![c]) continue; + const ny = current.y + r, nx = current.x + c; + if (ny >= 0 && ny < TETRIS_ROWS && nx >= 0 && nx < TETRIS_COLS) b[ny]![nx] = current.color; + } + } + } + return b; + }, [board, current]); + + // Ghost piece + const ghostCells = useMemo(() => { + if (!current) return []; + let gy = current.y; + while (isValid(current, current.x, gy + 1)) gy++; + if (gy === current.y) return []; + const cells: [number, number][] = []; + for (let r = 0; r < current.shape.length; r++) + for (let c = 0; c < current.shape[r]!.length; c++) + if (current.shape[r]![c]) cells.push([current.x + c, gy + r]); + return cells; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [current, board]); + + const lyraMessage = + lines >= 10 ? 'Unglaublich! Du bist voll im Flow! 🔥' : + lines >= 3 ? 'Super Linie! Weiter so.' : + 'Stapel die Blöcke – du schaffst das!'; + + const speedColors = ['#22c55e', '#84cc16', '#eab308', '#f97316', '#ef4444']; + + return ( + + {/* Header */} + + {lyraMessage} + + + {highScore > 0 && } + + + + + + + {/* Board */} + + + {displayBoard.map((row, y) => ( + + {row.map((color, x) => ( + + + + ))} + + ))} + {/* Ghost piece overlay */} + {ghostCells.map(([gx, gy], i) => ( + + ))} + + + + {/* Speed — native rendered slider (UISlider on iOS, SeekBar on Android) */} + + + + + Tempo + + + Speed {speedLevel} + + + { + const nv = Math.round(v); + if (nv !== speedLevel) { + tapHaptic(); + setSpeedLevel(nv); + } + }} + minimumTrackTintColor={speedColors[speedLevel - 1]} + maximumTrackTintColor="#e5e7eb" + thumbTintColor={Platform.OS === 'android' ? speedColors[speedLevel - 1] : undefined} + /> + + + {/* Controls — Move Pad (links) + Action Pad (rechts) */} + + {/* Move Pad */} + + + + + {/* Action Pad */} + + + + + + + {gameOver && ( + + Game Over + {score} Punkte · {lines} Linien + + )} + + ); +} + +function Stat({ label, value, color }: { label: string; value: number; color: string }) { + return ( + + {label} + {value} + + ); +} diff --git a/apps/rebreak-native/components/urge/UrgeStats.tsx b/apps/rebreak-native/components/urge/UrgeStats.tsx new file mode 100644 index 0000000..783a51b --- /dev/null +++ b/apps/rebreak-native/components/urge/UrgeStats.tsx @@ -0,0 +1,367 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { View, Text, ActivityIndicator } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { useTranslation } from 'react-i18next'; +import { apiFetch } from '../../lib/api'; +import { colors } from '../../lib/theme'; + +type Emotion = 'stress' | 'sadness' | 'anger' | 'empty' | 'boredom' | 'other'; + +type UrgeLog = { + id: string; + timestamp: string; + emotion: Emotion; + wasOvercome: boolean; + breathingDone: boolean; +}; + +const WEEKDAY_LABELS = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']; + +function emotionLabel(key: string, t: (k: string) => string): string { + const map: Record = { + stress: t('urge.emotion_stress'), + sadness: t('urge.emotion_sadness'), + anger: t('urge.emotion_anger'), + empty: t('urge.emotion_empty'), + boredom: t('urge.emotion_boredom'), + other: t('urge.emotion_other'), + }; + return map[key] ?? key; +} + +function StatCard({ label, value, color }: { label: string; value: string; color: string }) { + return ( + + {value} + + {label} + + + ); +} + +export function UrgeStats() { + const { t } = useTranslation(); + const [logs, setLogs] = useState([]); + const [loading, setLoading] = useState(true); + + const load = useCallback(async () => { + try { + setLoading(true); + const data = await apiFetch('/api/urge?limit=100'); + setLogs(Array.isArray(data) ? data : []); + } catch { + setLogs([]); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + load(); + }, [load]); + + const weeklyStats = useMemo(() => { + const weekAgo = Date.now() - 7 * 24 * 60 * 60 * 1000; + const thisWeek = logs.filter((log) => new Date(log.timestamp).getTime() > weekAgo); + return { + total: thisWeek.length, + overcome: thisWeek.filter((log) => log.wasOvercome).length, + breathingDone: thisWeek.filter((log) => log.breathingDone).length, + }; + }, [logs]); + + const patterns = useMemo(() => { + if (logs.length < 5) return null; + + const weekday = new Array(7).fill(0) as number[]; + const timeBlockCounts = [0, 0, 0, 0] as number[]; + const emotionCount: Record = {}; + + for (const log of logs) { + const d = new Date(log.timestamp); + const jsDay = d.getDay(); + const mondayIndex = jsDay === 0 ? 6 : jsDay - 1; + weekday[mondayIndex]!++; + + const hour = d.getHours(); + if (hour >= 6 && hour < 12) timeBlockCounts[0]!++; + else if (hour >= 12 && hour < 18) timeBlockCounts[1]!++; + else if (hour >= 18 && hour < 23) timeBlockCounts[2]!++; + else timeBlockCounts[3]!++; + + emotionCount[log.emotion] = (emotionCount[log.emotion] ?? 0) + 1; + } + + const maxWeekday = Math.max(...weekday, 1); + const maxTime = Math.max(...timeBlockCounts, 1); + const topEmotions = Object.entries(emotionCount) + .sort((a, b) => b[1] - a[1]) + .slice(0, 3); + + const peakDayIdx = weekday.indexOf(Math.max(...weekday)); + const peakTimeIdx = timeBlockCounts.indexOf(Math.max(...timeBlockCounts)); + const peakTimeLabel = ['morgens', 'mittags', 'abends', 'nachts'][peakTimeIdx] ?? ''; + const peakDayLabel = peakDayIdx >= 5 ? 'am Wochenende' : `${WEEKDAY_LABELS[peakDayIdx]}s`; + + return { + weekday: weekday.map((countValue, i) => ({ + label: WEEKDAY_LABELS[i]!, + count: countValue, + pct: Math.round((countValue / maxWeekday) * 100), + })), + timeBlocks: [ + { + emoji: '🌅', + label: t('urge.block_morning'), + count: timeBlockCounts[0]!, + pct: Math.round((timeBlockCounts[0]! / maxTime) * 100), + }, + { + emoji: '☀️', + label: t('urge.block_noon'), + count: timeBlockCounts[1]!, + pct: Math.round((timeBlockCounts[1]! / maxTime) * 100), + }, + { + emoji: '🌆', + label: t('urge.block_evening'), + count: timeBlockCounts[2]!, + pct: Math.round((timeBlockCounts[2]! / maxTime) * 100), + }, + { + emoji: '🌙', + label: t('urge.block_night'), + count: timeBlockCounts[3]!, + pct: Math.round((timeBlockCounts[3]! / maxTime) * 100), + }, + ], + topEmotions, + insight: `${t('urge.pattern_insight_prefix')} ${peakDayLabel} ${peakTimeLabel}.`, + }; + }, [logs, t]); + + if (loading) { + return ( + + + + ); + } + + return ( + + {/* Weekly counters */} + + + {t('urge.this_week')} + + + + + + + + + {patterns && ( + <> + {/* Insight */} + + + + {patterns.insight} + + + + {/* Weekday chart */} + + + {t('urge.chart_weekday_title')} + + + {patterns.weekday.map((day) => ( + + 0 ? Math.max(6, day.pct * 0.5) : 4, + borderRadius: 5, + backgroundColor: + day.pct >= 80 ? '#fb7185' : day.pct >= 50 ? '#f59e0b' : '#60a5fa', + marginBottom: 6, + }} + /> + + {day.label} + + + ))} + + + + {/* Time blocks */} + + + {t('urge.chart_time_title')} + + + {patterns.timeBlocks.map((b) => ( + + {b.emoji} + + {b.label} + + + + + + {b.count} + + + ))} + + + + {/* Top emotions */} + + + {t('urge.chart_top_emotions')} + + + {patterns.topEmotions.map(([emo, c]) => ( + + + {emotionLabel(emo, t)} x{c} + + + ))} + + + + )} + + ); +} diff --git a/apps/rebreak-native/components/urge/gameSvgs.ts b/apps/rebreak-native/components/urge/gameSvgs.ts new file mode 100644 index 0000000..f034d40 --- /dev/null +++ b/apps/rebreak-native/components/urge/gameSvgs.ts @@ -0,0 +1,7 @@ +export const memorySvg = ``; + +export const tictactoeSvg = ``; + +export const snakeSvg = ``; + +export const tetrisSvg = ``; diff --git a/apps/rebreak-native/dev-ios.sh b/apps/rebreak-native/dev-ios.sh new file mode 100755 index 0000000..bdf8e6d --- /dev/null +++ b/apps/rebreak-native/dev-ios.sh @@ -0,0 +1,67 @@ +#!/bin/bash +# Rebreak Native: Dev-Server (Metro) + iOS Build & Run +# Pendant zu apps/rebreak/dev-ios.sh, aber für React Native + Expo statt Capacitor. +set -e + +cd "$(dirname "$0")" + +echo "🧠 Rebreak Native iOS Dev" +echo "=========================" + +# Modi: +# ./dev-ios.sh → öffnet Xcode-Workspace (Default — User baut auf iPhone) +# ./dev-ios.sh --device → physisches iPhone via USB (CLI-Build + Auto-Launch) +# ./dev-ios.sh --simulator → iOS Simulator (schnellster Test-Loop, weniger Auth/Push-Test) +MODE="${1:-xcode}" + +# Metro-Port wird NICHT mehr automatisch gekillt — falls du Metro in einem +# anderen Terminal laufen hast, würde das hier die Session zerstören. +# Falls Metro hängt: manuell `lsof -ti:8081 | xargs kill -9` ausführen. + +# Cocoapods: läuft beim ersten Run automatisch via expo run:ios. +# Falls "objectVersion 70 not supported" Fehler unter Xcode 26 → CocoaPods updaten: +# sudo gem install cocoapods --pre +# +# Podfile-Fixes werden durch Config-Plugins automatisch reinpatcht: +# - plugins/with-fmt-consteval-fix.js → FMT_USE_CONSTEVAL=0 (Xcode 16 + RN 0.79) +# - plugins/with-rebreak-protection-ios.js → NEFilter Extension Target +# → expo prebuild --clean ist daher SAFE (Plugins regenerieren die Patches). +# +# Für radikalen Cache-Reset: ./clean-ios.sh +# Bei Build-Errors aus dem Nichts: ./clean-ios.sh --build + +# 3. Run je nach Mode +case "$MODE" in + --xcode|xcode|"") + # Falls Xcode bereits mit Rebreak.xcodeproj (statt .xcworkspace) offen ist, + # schließe ihn erst. Sonst kriegst du zwei Project-Windows. + osascript -e 'tell application "Xcode" to close every window whose name contains "Rebreak.xcodeproj"' 2>/dev/null || true + + echo "🔨 Opening Xcode-Workspace..." + open -a Xcode ios/Rebreak.xcworkspace + echo "" + echo "✅ Xcode offen — In Xcode:" + echo " 1. iPhone via USB anschließen (falls nicht schon)" + echo " 2. Top-Bar: iPhone als Run-Target wählen (links neben Play-Button)" + echo " 3. Cmd+R für Build & Run auf iPhone" + echo "" + echo "ℹ️ Metro: separat starten via 'pnpm expo start --dev-client' falls noch nicht läuft" + ;; + + --device|device) + echo "📱 Building für physisches iPhone (USB)..." + echo "ℹ️ Erste Mal: Xcode wird geöffnet zum Signing-Setup." + pnpm expo run:ios --device + ;; + + --simulator|simulator) + echo "📱 Building für iOS Simulator..." + pnpm expo run:ios + ;; + + *) + echo "Unknown mode: $MODE" + echo "Usage: ./dev-ios.sh [--xcode|--device|--simulator]" + exit 1 + ;; +esac diff --git a/apps/rebreak-native/dev-iphone.sh b/apps/rebreak-native/dev-iphone.sh new file mode 100755 index 0000000..4003d0b --- /dev/null +++ b/apps/rebreak-native/dev-iphone.sh @@ -0,0 +1,34 @@ +#!/bin/bash +# Rebreak Native — Dev auf physischem iPhone (kein Simulator). +# +# Was es macht: +# - Killt alte Metro-Instanzen auf 8081 (saubere Session) +# - Startet Metro mit --host lan damit iPhone via WiFi connecten kann +# - Druckt deine LAN-IP zum manuellen Eintragen falls Bonjour failt +# +# Auf iPhone: +# 1. Mac + iPhone müssen im SELBEN WiFi sein +# 2. App komplett killen (App-Switcher → swipe up) +# 3. App neu öffnen — dev-client sollte Metro automatisch finden +# 4. Falls nicht: dev-launcher → "Enter URL manually" → http://:8081 +# +# WICHTIG: Im Metro-Terminal NICHT `i` drücken — sonst startet Simulator! +# Nur `r` für Reload. +set -e +cd "$(dirname "$0")" + +echo "🧹 Killing old Metro on port 8081..." +lsof -ti:8081 | xargs kill -9 2>/dev/null || true + +echo "" +echo "📡 Mac LAN-IP für iPhone:" +ipconfig getifaddr en0 2>/dev/null || ipconfig getifaddr en1 2>/dev/null || echo " (kein WiFi/Ethernet detected)" +echo "" +echo "ℹ️ Falls dev-client Metro nicht automatisch findet:" +echo " im iPhone-Launcher → 'Enter URL manually' → http://:8081" +echo "" +echo "🚀 Starting Metro with --host lan..." +echo " (Drücke 'r' für Reload, NICHT 'i' — sonst startet Simulator!)" +echo "" + +exec pnpm expo start --host lan --clear --dev-client diff --git a/apps/rebreak-native/global.css b/apps/rebreak-native/global.css new file mode 100644 index 0000000..b5c61c9 --- /dev/null +++ b/apps/rebreak-native/global.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/apps/rebreak-native/hooks/useBlocklistSync.ts b/apps/rebreak-native/hooks/useBlocklistSync.ts new file mode 100644 index 0000000..fea8d16 --- /dev/null +++ b/apps/rebreak-native/hooks/useBlocklistSync.ts @@ -0,0 +1,54 @@ +import { useCallback, useState } from 'react'; +import Constants from 'expo-constants'; +import { supabase } from '../lib/supabase'; +import { protection } from '../lib/protection'; + +type SyncResult = { ok: boolean; count?: number; plan?: string; error?: string }; + +/** + * Synct die binary Blocklist (`blocklist.bin`) vom Server in die App-Group. + * Die NEFilter-Extension memory-mapped diese Datei — ohne Sync = leere + * Blocklist = nichts wird geblockt. + * + * Triggers: + * - direkt nach activateUrlFilter() success + * - nach Domain-Add/-Submit/-Delete + * - bei App-Resume (in case Server-Updates kamen) + * + * Backend respondet 304 wenn ETag matched → kein Re-Download. + */ +export function useBlocklistSync() { + const [syncing, setSyncing] = useState(false); + const [lastResult, setLastResult] = useState(null); + + const sync = useCallback(async (): Promise => { + if (syncing) return { ok: false, error: 'already_syncing' }; + setSyncing(true); + try { + const baseURL = Constants.expoConfig?.extra?.apiUrl as string; + const session = (await supabase.auth.getSession()).data.session; + const authToken = session?.access_token; + + if (!baseURL || !authToken) { + const result = { ok: false, error: 'missing_baseURL_or_token' }; + setLastResult(result); + return result; + } + + const res = await protection.syncBlocklist({ baseURL, authToken }); + const result = { ok: true, count: res.count, plan: res.plan }; + setLastResult(result); + console.log('[blocklist-sync] ok:', res); + return result; + } catch (e: any) { + const result = { ok: false, error: e?.message ?? 'sync_failed' }; + setLastResult(result); + console.error('[blocklist-sync] failed:', e); + return result; + } finally { + setSyncing(false); + } + }, [syncing]); + + return { sync, syncing, lastResult }; +} diff --git a/apps/rebreak-native/hooks/useChatRealtime.ts b/apps/rebreak-native/hooks/useChatRealtime.ts new file mode 100644 index 0000000..e0b9934 --- /dev/null +++ b/apps/rebreak-native/hooks/useChatRealtime.ts @@ -0,0 +1,129 @@ +import { useEffect } from "react"; +import { supabase } from "../lib/supabase"; +import type { RealtimeChannel } from "@supabase/supabase-js"; + +/** + * Realtime-Subscription für DM-Konversation: + * Lauscht auf INSERT in rebreak.direct_messages mit sender_id=eq.{partnerId}. + * Filter: Wir bekommen nur Nachrichten DES Partners (eigene werden lokal optimistisch + * hinzugefügt). callback erhält die rohe Postgres-Row. + */ +export function useDmRealtime( + partnerId: string | undefined, + onInsert: (row: any) => void, + enabled: boolean = true, +) { + useEffect(() => { + if (!enabled || !partnerId) return; + let channel: RealtimeChannel | null = null; + let cancelled = false; + let reconnectTimer: ReturnType | null = null; + + async function subscribe() { + const { data } = await supabase.auth.getSession(); + if (cancelled || !data.session?.access_token) return; + supabase.realtime.setAuth(data.session.access_token); + + channel = supabase + .channel(`dm:${partnerId}:${Date.now()}`) + .on( + "postgres_changes", + { + event: "INSERT", + schema: "rebreak", + table: "direct_messages", + filter: `sender_id=eq.${partnerId}`, + }, + (payload: any) => { + onInsert(payload.new); + }, + ) + .subscribe((status) => { + if (status === "CHANNEL_ERROR" || status === "TIMED_OUT") { + cleanup(); + if (reconnectTimer) clearTimeout(reconnectTimer); + reconnectTimer = setTimeout(() => { + if (!cancelled) subscribe(); + }, 3000); + } + }); + } + + function cleanup() { + if (channel) { + supabase.removeChannel(channel); + channel = null; + } + } + + subscribe(); + return () => { + cancelled = true; + if (reconnectTimer) clearTimeout(reconnectTimer); + cleanup(); + }; + }, [partnerId, enabled, onInsert]); +} + +/** + * Realtime für Gruppen-Chat: lauscht auf INSERT in rebreak.chat_messages mit room_id=eq.{roomId}. + */ +export function useRoomRealtime( + roomId: string | undefined, + myUserId: string | undefined, + onInsert: (row: any) => void, + enabled: boolean = true, +) { + useEffect(() => { + if (!enabled || !roomId) return; + let channel: RealtimeChannel | null = null; + let cancelled = false; + let reconnectTimer: ReturnType | null = null; + + async function subscribe() { + const { data } = await supabase.auth.getSession(); + if (cancelled || !data.session?.access_token) return; + supabase.realtime.setAuth(data.session.access_token); + + channel = supabase + .channel(`room:${roomId}:${Date.now()}`) + .on( + "postgres_changes", + { + event: "INSERT", + schema: "rebreak", + table: "chat_messages", + filter: `room_id=eq.${roomId}`, + }, + (payload: any) => { + // Eigene Nachrichten überspringen (lokal optimistisch hinzugefügt) + if (payload.new?.user_id === myUserId) return; + onInsert(payload.new); + }, + ) + .subscribe((status) => { + if (status === "CHANNEL_ERROR" || status === "TIMED_OUT") { + cleanup(); + if (reconnectTimer) clearTimeout(reconnectTimer); + reconnectTimer = setTimeout(() => { + if (!cancelled) subscribe(); + }, 3000); + } + }); + } + + function cleanup() { + if (channel) { + supabase.removeChannel(channel); + channel = null; + } + } + + subscribe(); + return () => { + cancelled = true; + if (reconnectTimer) clearTimeout(reconnectTimer); + cleanup(); + }; + }, [roomId, myUserId, enabled, onInsert]); +} diff --git a/apps/rebreak-native/hooks/useCommunityRealtime.ts b/apps/rebreak-native/hooks/useCommunityRealtime.ts new file mode 100644 index 0000000..4238cda --- /dev/null +++ b/apps/rebreak-native/hooks/useCommunityRealtime.ts @@ -0,0 +1,153 @@ +import { useEffect } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { supabase } from "../lib/supabase"; +import type { RealtimeChannel } from "@supabase/supabase-js"; +import type { CommunityPost } from "../stores/community"; + +/** + * Realtime-Subscription für die Community-Feed-Page. + * Lauscht auf: + * - INSERT auf community_posts → invalidiert die Feed-Query (frischer Refetch) + * - UPDATE auf community_posts → patcht likes/comments-Counts inline + * - UPDATE auf domain_submissions → patcht domain_vote-Posts mit neuem Status + * - UPDATE auf game_challenges → patcht challenge-Status + * + * Pendant zum Nuxt-`communityStore.startRealtime()` aus apps/rebreak/. + */ +export function useCommunityRealtime(enabled: boolean = true) { + const queryClient = useQueryClient(); + + useEffect(() => { + if (!enabled) return; + let channel: RealtimeChannel | null = null; + let cancelled = false; + let reconnectTimer: ReturnType | null = null; + + async function subscribe() { + const { data } = await supabase.auth.getSession(); + const session = data.session; + if (!session?.access_token) return; + if (cancelled) return; + + supabase.realtime.setAuth(session.access_token); + const myId = session.user.id; + + channel = supabase + .channel(`community:posts:${Date.now()}`) + .on( + "postgres_changes", + { event: "INSERT", schema: "rebreak", table: "community_posts" }, + (payload: any) => { + const r = payload.new; + if (r.user_id === myId) return; // eigene Posts schon optimistisch hinzugefügt + if (r.is_moderated) return; + // Einfacher als Detail-Fetch: alle Feed-Queries invalidieren + queryClient.invalidateQueries({ queryKey: ["community-posts"] }); + }, + ) + .on( + "postgres_changes", + { event: "UPDATE", schema: "rebreak", table: "community_posts" }, + (payload: any) => { + const r = payload.new; + patchPostInAllQueries(queryClient, r.id, (p) => ({ + ...p, + likesCount: r.likes_count ?? p.likesCount, + dislikesCount: r.dislikes_count ?? p.dislikesCount, + commentsCount: r.comments_count ?? p.commentsCount, + repostsCount: r.reposts_count ?? p.repostsCount, + })); + }, + ) + .on( + "postgres_changes", + { event: "UPDATE", schema: "rebreak", table: "domain_submissions" }, + (payload: any) => { + const r = payload.new; + if ( + r.status !== "approved" && + r.status !== "rejected" && + r.status !== "in_review" + ) { + return; + } + patchPostInAllQueries(queryClient, null, (p) => { + if (p.submission?.domain == null) return p; + // Wir kennen die submissionId nicht direkt am Post, also matche per domain. + // Realistisch unique pro user_id, aber Feed enthält Post mit submission-Objekt. + // Wenn der Post diese submission referenziert, patchen. + if (!p.submission || (p as any).submissionId !== r.id) { + // Falls du submissionId an Post-Schema hängst, hier nutzen. + // Solange nicht: invalidate fallback unten. + return p; + } + return { + ...p, + submission: { + ...p.submission, + status: r.status, + yesVotes: r.yes_votes ?? p.submission.yesVotes, + noVotes: r.no_votes ?? p.submission.noVotes, + reviewedAt: r.reviewed_at ?? p.submission.reviewedAt, + }, + }; + }); + // Sicherheitshalber auch invalidieren — domain_vote ist selten genug. + queryClient.invalidateQueries({ queryKey: ["community-posts"] }); + }, + ) + .on( + "postgres_changes", + { event: "UPDATE", schema: "rebreak", table: "game_challenges" }, + (payload: any) => { + const r = payload.new; + patchPostInAllQueries(queryClient, null, (p) => + p.challengeId === r.id ? { ...p, challengeStatus: r.status } : p, + ); + }, + ) + .subscribe((status) => { + if (status === "CHANNEL_ERROR" || status === "TIMED_OUT") { + cleanup(); + if (reconnectTimer) clearTimeout(reconnectTimer); + reconnectTimer = setTimeout(() => { + if (!cancelled) subscribe(); + }, 3000); + } + }); + } + + function cleanup() { + if (channel) { + supabase.removeChannel(channel); + channel = null; + } + } + + subscribe(); + + return () => { + cancelled = true; + if (reconnectTimer) clearTimeout(reconnectTimer); + cleanup(); + }; + }, [enabled, queryClient]); +} + +function patchPostInAllQueries( + queryClient: ReturnType, + postId: string | null, + patcher: (p: CommunityPost) => CommunityPost, +) { + const queries = queryClient.getQueriesData({ + queryKey: ["community-posts"], + }); + for (const [key, data] of queries) { + if (!Array.isArray(data)) continue; + const next = data.map((p) => { + if (postId !== null && p.id !== postId) return p; + return patcher(p); + }); + queryClient.setQueryData(key, next); + } +} diff --git a/apps/rebreak-native/hooks/useCustomDomains.ts b/apps/rebreak-native/hooks/useCustomDomains.ts new file mode 100644 index 0000000..6faabf4 --- /dev/null +++ b/apps/rebreak-native/hooks/useCustomDomains.ts @@ -0,0 +1,178 @@ +import { useCallback, useEffect, useState } from 'react'; +import { apiFetch } from '../lib/api'; + +export type DomainStatus = 'active' | 'submitted' | 'approved' | 'rejected'; + +export type CustomDomain = { + id: string; + domain: string; + status: DomainStatus; + addedAt?: string; + postId?: string | null; + submission?: { id: string; yesVotes: number; noVotes: number; status: string } | null; +}; + +export type Plan = 'free' | 'pro' | 'legend'; + +export type Tier = { + plan: Plan; + domainLimit: number; // free=5, pro=5, legend=10 + refillEnabled: boolean; // free=false, pro/legend=true + globalBlocklist: boolean; // free=false, pro/legend=true + canSubmit: boolean; // free=false, pro/legend=true + usedSlots: number; // active+submitted (NICHT approved/rejected) + atLimit: boolean; +}; + +function deriveTier(plan: Plan, domains: CustomDomain[]): Tier { + const limit = plan === 'legend' ? 10 : 5; + const refill = plan !== 'free'; + const usedSlots = domains.filter((d) => d.status === 'active' || d.status === 'submitted').length; + return { + plan, + domainLimit: limit, + refillEnabled: refill, + globalBlocklist: refill, + canSubmit: refill, + usedSlots, + atLimit: usedSlots >= limit, + }; +} + +export type UseCustomDomainsReturn = { + domains: CustomDomain[]; + tier: Tier; + loading: boolean; + error: string | null; + refresh: () => Promise; + addDomain: (domain: string) => Promise<{ ok: boolean; error?: string; alreadyGlobal?: boolean }>; + submitDomain: (id: string) => Promise<{ ok: boolean; error?: string }>; + removeDomain: (id: string) => Promise<{ ok: boolean; error?: string }>; + /** Live-Validate (regex) ob string gültiger Domain-Name ist. */ + isValidDomain: (s: string) => boolean; + /** Normalize: lowercase, http(s)://, /path stripping, www. weg. */ + normalizeDomain: (s: string) => string; +}; + +const DOMAIN_REGEX = /^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$/i; + +export function normalizeDomain(input: string): string { + let s = input.trim().toLowerCase(); + if (s.startsWith('https://')) s = s.slice(8); + else if (s.startsWith('http://')) s = s.slice(7); + const slash = s.indexOf('/'); + if (slash >= 0) s = s.slice(0, slash); + if (s.startsWith('www.')) s = s.slice(4); + return s; +} + +export function isValidDomain(input: string): boolean { + const n = normalizeDomain(input); + if (!n || n.length > 253) return false; + return DOMAIN_REGEX.test(n); +} + +/** + * Custom-Domain CRUD gegen `/api/custom-domains/*` mit Tier-aware Limits. + * + * Tier-Logik (Single-Source-of-Truth: User.plan): + * Free → 5 Slots, kein Refill, keine Submit + * Pro → 5 Slots, Refill bei approved/rejected, Submit erlaubt + * Legend → 10 Slots, Refill, Submit + */ +export function useCustomDomains(plan: Plan): UseCustomDomainsReturn { + const [domains, setDomains] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchDomains = useCallback(async () => { + try { + // Backend (`server/api/custom-domains/index.get.ts`) gibt Array DIREKT zurück, + // kein { domains: [...] }-Wrapper. + const res = await apiFetch( + '/api/custom-domains', + ); + const arr = Array.isArray(res) ? res : (res?.domains ?? []); + console.log('[useCustomDomains] fetched:', arr.length, 'domains', arr.slice(0, 3)); + setDomains(arr); + setError(null); + } catch (e: any) { + console.error('[useCustomDomains] fetch failed:', e?.message ?? e); + setError(e?.message ?? 'unknown'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchDomains(); + }, [fetchDomains]); + + const addDomain = useCallback( + async (input: string) => { + if (!isValidDomain(input)) return { ok: false, error: 'invalid_domain' }; + const tier = deriveTier(plan, domains); + if (tier.atLimit) return { ok: false, error: 'limit_reached' }; + const normalized = normalizeDomain(input); + try { + // Backend könnte einen `alreadyGlobal`-Flag setzen wenn die Domain + // bereits in der globalen Blocklist ist (Slot wird nicht verbraucht). + const res = await apiFetch('/api/custom-domains', { + method: 'POST', + body: { domain: normalized }, + }); + if (res?.alreadyGlobal) { + return { ok: false, alreadyGlobal: true }; + } + await fetchDomains(); + return { ok: true }; + } catch (e: any) { + return { ok: false, error: e?.message ?? 'add_failed' }; + } + }, + [plan, domains, fetchDomains], + ); + + const submitDomain = useCallback( + async (id: string) => { + const tier = deriveTier(plan, domains); + if (!tier.canSubmit) return { ok: false, error: 'plan_does_not_support_submit' }; + try { + await apiFetch(`/api/custom-domains/${id}/submit`, { method: 'POST', body: {} }); + await fetchDomains(); + return { ok: true }; + } catch (e: any) { + return { ok: false, error: e?.message ?? 'submit_failed' }; + } + }, + [plan, domains, fetchDomains], + ); + + const removeDomain = useCallback( + async (id: string) => { + try { + await apiFetch(`/api/custom-domains/${id}`, { method: 'DELETE' }); + await fetchDomains(); + return { ok: true }; + } catch (e: any) { + return { ok: false, error: e?.message ?? 'remove_failed' }; + } + }, + [fetchDomains], + ); + + const tier = deriveTier(plan, domains); + + return { + domains, + tier, + loading, + error, + refresh: fetchDomains, + addDomain, + submitDomain, + removeDomain, + isValidDomain, + normalizeDomain, + }; +} diff --git a/apps/rebreak-native/hooks/useDomainSubmissionRealtime.ts b/apps/rebreak-native/hooks/useDomainSubmissionRealtime.ts new file mode 100644 index 0000000..7557f7e --- /dev/null +++ b/apps/rebreak-native/hooks/useDomainSubmissionRealtime.ts @@ -0,0 +1,96 @@ +import { useEffect } from "react"; +import { supabase } from "../lib/supabase"; +import type { RealtimeChannel } from "@supabase/supabase-js"; + +/** + * Realtime-Subscription für die Blocker-Page. + * Lauscht auf: + * - UPDATE auf rebreak.domain_submissions → ruft `onChange()` (refetch) + * - INSERT auf rebreak.notifications mit type=domain_accepted für eigene recipient_id → refetch + * + * Pendant zum Nuxt-Code in apps/rebreak/app/pages/app/blocker/index.vue. + */ +export function useDomainSubmissionRealtime( + onChange: () => void, + enabled: boolean = true, +) { + useEffect(() => { + if (!enabled) return; + let channel: RealtimeChannel | null = null; + let cancelled = false; + let reconnectTimer: ReturnType | null = null; + + async function subscribe() { + const { data } = await supabase.auth.getSession(); + const session = data.session; + if (!session?.access_token) return; + if (cancelled) return; + + supabase.realtime.setAuth(session.access_token); + const myId = session.user.id; + + channel = supabase + .channel(`blocker:domains:${myId}:${Date.now()}`) + .on( + "postgres_changes", + { + event: "*", + schema: "rebreak", + table: "domain_submissions", + filter: `submitter_id=eq.${myId}`, + }, + () => onChange(), + ) + .on( + "postgres_changes", + { + event: "*", + schema: "rebreak", + table: "user_custom_domains", + filter: `user_id=eq.${myId}`, + }, + () => onChange(), + ) + .on( + "postgres_changes", + { + event: "INSERT", + schema: "rebreak", + table: "notifications", + filter: `recipient_id=eq.${myId}`, + }, + (payload: any) => { + const t = payload.new?.type; + if (t === "domain_accepted" || t === "domain_rejected") { + onChange(); + } + }, + ) + .subscribe((status, err) => { + if (status === "CHANNEL_ERROR" || status === "TIMED_OUT") { + console.warn("[domainRealtime] error:", status, err ?? ""); + cleanup(); + if (reconnectTimer) clearTimeout(reconnectTimer); + reconnectTimer = setTimeout(() => { + if (!cancelled) subscribe(); + }, 3000); + } + }); + } + + function cleanup() { + if (channel) { + supabase.removeChannel(channel); + channel = null; + } + } + + subscribe(); + + return () => { + cancelled = true; + if (reconnectTimer) clearTimeout(reconnectTimer); + cleanup(); + }; + }, [enabled, onChange]); +} diff --git a/apps/rebreak-native/hooks/useMailConnect.ts b/apps/rebreak-native/hooks/useMailConnect.ts new file mode 100644 index 0000000..cfa4968 --- /dev/null +++ b/apps/rebreak-native/hooks/useMailConnect.ts @@ -0,0 +1,94 @@ +import { useCallback, useState } from 'react'; +import { apiFetch } from '../lib/api'; + +export type MailProvider = + | 'gmail' + | 'icloud' + | 'outlook' + | 'yahoo' + | 'gmx' + | 'other'; + +type ConnectBody = { + email: string; + password: string; + // Provider-Feld wird NICHT an das Backend gesendet — der Server erkennt + // den Provider automatisch via Email-Domain (detectImapProvider in connect.post.ts). + // Optionale Custom-IMAP-Felder für "other": + imapHost?: string; + imapPort?: number; + useTls?: boolean; + rejectUnauthorized?: boolean; +}; + +type ConnectResult = { + connected: boolean; + email: string; + provider: string; + custom: boolean; +}; + +export type UseMailConnectReturn = { + connect: (params: ConnectBody) => Promise<{ ok: boolean; error?: string }>; + connecting: boolean; + error: string | null; + /** Leitet aus der Email-Domain den Provider ab (rein client-seitig, zur UI-Hilfe). */ + detectProvider: (email: string) => MailProvider; +}; + +const PROVIDER_DOMAIN_MAP: Record = { + 'gmail.com': 'gmail', + 'googlemail.com': 'gmail', + 'icloud.com': 'icloud', + 'me.com': 'icloud', + 'mac.com': 'icloud', + 'outlook.com': 'outlook', + 'hotmail.com': 'outlook', + 'live.com': 'outlook', + 'msn.com': 'outlook', + 'yahoo.com': 'yahoo', + 'yahoo.de': 'yahoo', + 'yahoo.co.uk': 'yahoo', + 'ymail.com': 'yahoo', + 'gmx.de': 'gmx', + 'gmx.net': 'gmx', + 'gmx.at': 'gmx', + 'gmx.ch': 'gmx', + 'web.de': 'gmx', +}; + +export function detectProvider(email: string): MailProvider { + const domain = email.trim().toLowerCase().split('@')[1] ?? ''; + return PROVIDER_DOMAIN_MAP[domain] ?? 'other'; +} + +/** + * Kapselt POST /api/mail/connect. + * + * Backend erwartet: { email, password, imapHost?, imapPort?, useTls?, rejectUnauthorized? } + * Provider-Detection passiert server-seitig — wir senden keinen provider-Key. + */ +export function useMailConnect(): UseMailConnectReturn { + const [connecting, setConnecting] = useState(false); + const [error, setError] = useState(null); + + const connect = useCallback(async (params: ConnectBody) => { + setConnecting(true); + setError(null); + try { + await apiFetch('/api/mail/connect', { + method: 'POST', + body: params, + }); + return { ok: true }; + } catch (e: any) { + const msg = e?.message ?? 'Verbindung fehlgeschlagen'; + setError(msg); + return { ok: false, error: msg }; + } finally { + setConnecting(false); + } + }, []); + + return { connect, connecting, error, detectProvider }; +} diff --git a/apps/rebreak-native/hooks/useMailDisconnect.ts b/apps/rebreak-native/hooks/useMailDisconnect.ts new file mode 100644 index 0000000..14a8f8a --- /dev/null +++ b/apps/rebreak-native/hooks/useMailDisconnect.ts @@ -0,0 +1,39 @@ +import { useCallback, useState } from 'react'; +import { apiFetch } from '../lib/api'; + +export type UseMailDisconnectReturn = { + disconnect: (connectionId: string) => Promise<{ ok: boolean; error?: string }>; + disconnecting: boolean; + error: string | null; +}; + +/** + * Kapselt DELETE /api/mail/disconnect für ein einzelnes Konto. + * + * Backend erwartet: Body { connectionId } (nicht als URL-Param). + * Gibt { ok: true } zurück wenn erfolgreich. + */ +export function useMailDisconnect(): UseMailDisconnectReturn { + const [disconnecting, setDisconnecting] = useState(false); + const [error, setError] = useState(null); + + const disconnect = useCallback(async (connectionId: string) => { + setDisconnecting(true); + setError(null); + try { + await apiFetch<{ ok: boolean }>('/api/mail/disconnect', { + method: 'DELETE', + body: { connectionId }, + }); + return { ok: true }; + } catch (e: any) { + const msg = e?.message ?? 'Trennen fehlgeschlagen'; + setError(msg); + return { ok: false, error: msg }; + } finally { + setDisconnecting(false); + } + }, []); + + return { disconnect, disconnecting, error }; +} diff --git a/apps/rebreak-native/hooks/useMailInterval.ts b/apps/rebreak-native/hooks/useMailInterval.ts new file mode 100644 index 0000000..f28dcca --- /dev/null +++ b/apps/rebreak-native/hooks/useMailInterval.ts @@ -0,0 +1,37 @@ +import { useCallback, useState } from "react"; +import { apiFetch } from "../lib/api"; + +/** + * PATCH /api/mail/interval — Setzt das Scan-Intervall (in Stunden) für eine + * bestimmte Mail-Connection. Plan-Limits werden serverseitig geprüft. + */ +export function useMailInterval() { + const [updating, setUpdating] = useState(null); + const [error, setError] = useState(null); + + const setInterval = useCallback( + async (connectionId: string, interval: number) => { + setUpdating(connectionId); + setError(null); + try { + await apiFetch<{ ok: boolean; interval: number }>( + "/api/mail/interval", + { + method: "PATCH", + body: { connectionId, interval }, + }, + ); + return { ok: true }; + } catch (e: any) { + const msg = e?.message ?? "unknown"; + setError(msg); + return { ok: false, error: msg }; + } finally { + setUpdating(null); + } + }, + [], + ); + + return { setInterval, updating, error }; +} diff --git a/apps/rebreak-native/hooks/useMailResults.ts b/apps/rebreak-native/hooks/useMailResults.ts new file mode 100644 index 0000000..3f0cd4f --- /dev/null +++ b/apps/rebreak-native/hooks/useMailResults.ts @@ -0,0 +1,52 @@ +import { useCallback, useEffect, useState } from "react"; +import { apiFetch } from "../lib/api"; + +export type MailBlockedItem = { + id: string; + subject: string; + sender_email: string; + sender_name: string | null; + received_at: string; + connection_id: string; +}; + +export type MailResultsResponse = { + results: MailBlockedItem[]; + total: number; + page: number; + pages: number; +}; + +/** + * GET /api/mail/results — Liste der in den letzten 24h gelöschten Mails. + * Backend räumt selbst nach 24h auf (deleteOldMailBlocked). + */ +export function useMailResults(enabled: boolean = true) { + const [results, setResults] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const refresh = useCallback(async () => { + if (!enabled) return; + setLoading(true); + try { + const res = await apiFetch( + "/api/mail/results?page=1", + ); + setResults(res.results ?? []); + setTotal(res.total ?? 0); + setError(null); + } catch (e: any) { + setError(e?.message ?? "unknown"); + } finally { + setLoading(false); + } + }, [enabled]); + + useEffect(() => { + if (enabled) refresh(); + }, [enabled, refresh]); + + return { results, total, loading, error, refresh }; +} diff --git a/apps/rebreak-native/hooks/useMailStatus.ts b/apps/rebreak-native/hooks/useMailStatus.ts new file mode 100644 index 0000000..509a03f --- /dev/null +++ b/apps/rebreak-native/hooks/useMailStatus.ts @@ -0,0 +1,137 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { AppState, type AppStateStatus } from 'react-native'; +import { apiFetch } from '../lib/api'; + +export type MailAccount = { + id: string; + email: string; + provider: string; + isActive: boolean; + lastScannedAt: string | null; + nextScanAt: string | null; + totalBlocked: number; + totalScanned: number; + scanInterval: number; + blockRate: number; +}; + +export type DailyStat = { + date: string; + label: string; + count: number; +}; + +export type MailStatusResponse = { + connected: boolean; + accounts: MailAccount[]; + totalBlocked: number; + totalScanned: number; + dailyStats: DailyStat[]; +}; + +export type Plan = 'free' | 'pro' | 'legend'; + +export type UseMailStatusReturn = { + connected: boolean; + accounts: MailAccount[]; + totalBlocked: number; + totalScanned: number; + dailyStats: DailyStat[]; + /** Plan-derived account limit: free=1, pro=3, legend=Infinity */ + maxAccounts: number; + loading: boolean; + error: string | null; + refresh: () => Promise; +}; + +const POLL_INTERVAL_MS = 30_000; + +function deriveMaxAccounts(plan: Plan): number { + if (plan === 'free') return 1; + if (plan === 'pro') return 3; + return Infinity; +} + +/** + * Fetched GET /api/mail/status mit: + * - initialem Fetch on mount + * - 30s-Polling solange App im Vordergrund (AppState === 'active') + * - manuell triggerbar via refresh() + * + * TODO: Ersetze Polling durch IDLE-Realtime-Websocket wenn Phase-10-Backend fertig ist. + */ +export function useMailStatus(plan: Plan): UseMailStatusReturn { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const intervalRef = useRef | null>(null); + const appStateRef = useRef(AppState.currentState); + + const fetchStatus = useCallback(async () => { + try { + const res = await apiFetch('/api/mail/status'); + setData(res); + setError(null); + } catch (e: any) { + console.error('[useMailStatus] fetch failed:', e?.message ?? e); + setError(e?.message ?? 'unknown'); + } finally { + setLoading(false); + } + }, []); + + // Polling starten / stoppen je nach AppState + const startPolling = useCallback(() => { + if (intervalRef.current) return; + intervalRef.current = setInterval(() => { + fetchStatus(); + }, POLL_INTERVAL_MS); + }, [fetchStatus]); + + const stopPolling = useCallback(() => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + }, []); + + useEffect(() => { + fetchStatus(); + startPolling(); + + const sub = AppState.addEventListener('change', (nextState: AppStateStatus) => { + const wasActive = appStateRef.current === 'active'; + const isNowActive = nextState === 'active'; + appStateRef.current = nextState; + + if (!wasActive && isNowActive) { + // App kommt in Vordergrund — sofort refreshen + Polling neu starten + fetchStatus(); + startPolling(); + } else if (wasActive && !isNowActive) { + // App geht in Hintergrund — Polling stoppen + stopPolling(); + } + }); + + return () => { + stopPolling(); + sub.remove(); + }; + }, [fetchStatus, startPolling, stopPolling]); + + const maxAccounts = deriveMaxAccounts(plan); + + return { + connected: data?.connected ?? false, + accounts: data?.accounts ?? [], + totalBlocked: data?.totalBlocked ?? 0, + totalScanned: data?.totalScanned ?? 0, + dailyStats: data?.dailyStats ?? [], + maxAccounts, + loading, + error, + refresh: fetchStatus, + }; +} diff --git a/apps/rebreak-native/hooks/useMe.ts b/apps/rebreak-native/hooks/useMe.ts new file mode 100644 index 0000000..c04b3c2 --- /dev/null +++ b/apps/rebreak-native/hooks/useMe.ts @@ -0,0 +1,62 @@ +import { useEffect, useState } from 'react'; +import { apiFetch } from '../lib/api'; + +export type Plan = 'free' | 'pro' | 'legend'; + +/** + * Single source of truth für den eingeloggten User. /api/auth/me joint + * `auth.users` mit `rebreak.profiles` server-side — wir bekommen alles in + * einem Request: plan, avatar, nickname, streak. + * + * WICHTIG: nicht aus `supabase.auth.getUser().user_metadata` lesen — das + * sind nur die JWT-Claims vom Signup-Zeitpunkt, NICHT der aktuelle Profile- + * Stand (Avatar/Nickname/Plan werden via Profile-Edit-API geupdated, landen + * in der DB, NICHT zurück ins JWT-Claim). + */ +export type Me = { + id: string; + email: string; + username: string; + nickname: string | null; + avatar: string | null; + plan: Plan; + streak: number; + created_at?: string; +}; + +let cachedMe: Me | null = null; + +export function useMe(): { me: Me | null; loading: boolean; reload: () => void } { + const [me, setMe] = useState(cachedMe); + const [loading, setLoading] = useState(cachedMe === null); + const [version, setVersion] = useState(0); + + useEffect(() => { + let cancelled = false; + (async () => { + try { + const res = await apiFetch('/api/auth/me'); + if (cancelled) return; + cachedMe = res; + setMe(res); + } catch (e) { + console.warn('[useMe] fetch failed:', e); + } finally { + if (!cancelled) setLoading(false); + } + })(); + return () => { + cancelled = true; + }; + }, [version]); + + return { + me, + loading, + reload: () => { + cachedMe = null; + setLoading(true); + setVersion((v) => v + 1); + }, + }; +} diff --git a/apps/rebreak-native/hooks/useProtectionState.ts b/apps/rebreak-native/hooks/useProtectionState.ts new file mode 100644 index 0000000..23be816 --- /dev/null +++ b/apps/rebreak-native/hooks/useProtectionState.ts @@ -0,0 +1,163 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { AppState, type AppStateStatus } from 'react-native'; +import { + protection, + type ProtectionState, + type ProtectionPhase, + formatCooldownRemaining, +} from '../lib/protection'; + +const POLL_MS_ACTIVE_COOLDOWN = 5_000; +const POLL_MS_NORMAL = 30_000; + +type UseProtectionStateReturn = { + state: ProtectionState | null; + loading: boolean; + error: string | null; + /** Live Countdown-String "23:59:42" während Cooldown läuft. */ + cooldownRemainingFormatted: string; + /** Refetch ohne loading-flicker. */ + refresh: () => Promise; + /** Aktiviert ALLE Layers (legacy, beide Dialoge nacheinander). */ + activate: () => Promise<{ allLayersOn: boolean; missingLayers: string[] }>; + /** Aktiviert NUR den URL-Filter (NEFilter). */ + activateUrlFilter: () => Promise<{ enabled: boolean; error?: string }>; + /** Aktiviert NUR Family Controls (= der Lock — danach nur per Cooldown abschaltbar). */ + activateFamilyControls: () => Promise<{ enabled: boolean; error?: string }>; + /** Startet 24h Cooldown via Backend. UI muss Friction-Flow vorher durchlaufen. */ + requestDeactivation: (reason?: string) => Promise; + /** Bricht laufenden Cooldown ab. Schutz bleibt aktiv. */ + cancelDeactivation: () => Promise; +}; + +/** + * Single-Source-of-Truth-Hook für Protection-State. + * + * - Initial-Fetch on mount + * - Polling: alle 30s normal, 5s während aktivem Cooldown (Live-Countdown) + * - Refresh on AppState 'active' (User kommt aus Background zurück) + * - Layer-Change-Listener vom Native-Modul (Bypass-Detection) + */ +export function useProtectionState(): UseProtectionStateReturn { + const [state, setState] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [tickSeconds, setTickSeconds] = useState(0); + + const pollTimer = useRef | null>(null); + const tickTimer = useRef | null>(null); + + const fetchState = useCallback(async (showLoading = false) => { + if (showLoading) setLoading(true); + try { + const next = await protection.getCombinedState(); + setState(next); + setTickSeconds(next.cooldown.remainingSeconds); + setError(null); + } catch (e: any) { + setError(e?.message ?? 'unknown'); + } finally { + if (showLoading) setLoading(false); + } + }, []); + + // Initial fetch + useEffect(() => { + fetchState(true); + }, [fetchState]); + + // Adaptive poll-rate: 5s während Cooldown, 30s sonst + useEffect(() => { + const interval = state?.cooldown.active ? POLL_MS_ACTIVE_COOLDOWN : POLL_MS_NORMAL; + if (pollTimer.current) clearInterval(pollTimer.current); + pollTimer.current = setInterval(() => fetchState(false), interval); + return () => { + if (pollTimer.current) clearInterval(pollTimer.current); + }; + }, [state?.cooldown.active, fetchState]); + + // Live-Countdown-Tick (nur während Cooldown — 1s-Decrement client-side) + useEffect(() => { + if (!state?.cooldown.active) { + if (tickTimer.current) { + clearInterval(tickTimer.current); + tickTimer.current = null; + } + return; + } + tickTimer.current = setInterval(() => { + setTickSeconds((s) => Math.max(0, s - 1)); + }, 1000); + return () => { + if (tickTimer.current) clearInterval(tickTimer.current); + }; + }, [state?.cooldown.active]); + + // AppState-Listener: Refresh wenn App aus Background zurückkommt. + // KEIN auto-disable hier — Backend's canDisableProtection-Flag ist auch + // in initial-state true, würde sonst den Filter killen ohne dass User + // jemals einen Cooldown gestartet hat. Auto-Disable nur über expliziten + // UI-Pfad nach Cooldown-Ablauf (kommt in Step 5b). + useEffect(() => { + const sub = AppState.addEventListener('change', (status: AppStateStatus) => { + if (status === 'active') { + fetchState(false); + } + }); + return () => sub.remove(); + }, [fetchState]); + + // Native Layer-Change-Listener (User schaltet VPN extern aus etc.) + useEffect(() => { + const sub = protection.addLayerChangeListener(() => fetchState(false)); + return () => sub?.remove(); + }, [fetchState]); + + // ─── Public Actions ──────────────────────────────────────────────── + + const activate = useCallback(async () => { + const result = await protection.activate(); + await fetchState(false); + return result; + }, [fetchState]); + + const activateUrlFilter = useCallback(async () => { + const result = await protection.activateUrlFilter(); + await fetchState(false); + return result; + }, [fetchState]); + + const activateFamilyControls = useCallback(async () => { + const result = await protection.activateFamilyControls(); + await fetchState(false); + return result; + }, [fetchState]); + + const requestDeactivation = useCallback( + async (reason?: string) => { + await protection.requestDeactivation(reason); + await fetchState(false); + }, + [fetchState], + ); + + const cancelDeactivation = useCallback(async () => { + await protection.cancelDeactivation(); + await fetchState(false); + }, [fetchState]); + + return { + state, + loading, + error, + cooldownRemainingFormatted: formatCooldownRemaining(tickSeconds), + refresh: () => fetchState(false), + activate, + activateUrlFilter, + activateFamilyControls, + requestDeactivation, + cancelDeactivation, + }; +} + +export type { ProtectionPhase }; diff --git a/apps/rebreak-native/hooks/useUserPlan.ts b/apps/rebreak-native/hooks/useUserPlan.ts new file mode 100644 index 0000000..3fd1275 --- /dev/null +++ b/apps/rebreak-native/hooks/useUserPlan.ts @@ -0,0 +1,43 @@ +import { useEffect, useState } from "react"; +import { apiFetch } from "../lib/api"; + +export type Plan = "free" | "pro" | "legend"; + +type MeResponse = { + id: string; + email: string; + username: string; + plan: Plan; +}; + +let cachedPlan: Plan | null = null; + +/** + * Holt den User-Plan vom Backend (/api/auth/me). + * Plan wird in DB gespeichert (nicht in user_metadata) — daher BFF-Call nötig. + */ +export function useUserPlan(): { plan: Plan; loading: boolean } { + const [plan, setPlan] = useState(cachedPlan ?? "free"); + const [loading, setLoading] = useState(cachedPlan === null); + + useEffect(() => { + let cancelled = false; + (async () => { + try { + const res = await apiFetch("/api/auth/me"); + if (cancelled) return; + cachedPlan = res.plan ?? "free"; + setPlan(cachedPlan); + } catch (e) { + console.warn("[useUserPlan] failed:", e); + } finally { + if (!cancelled) setLoading(false); + } + })(); + return () => { + cancelled = true; + }; + }, []); + + return { plan, loading }; +} diff --git a/apps/rebreak-native/install-android.sh b/apps/rebreak-native/install-android.sh new file mode 100755 index 0000000..f78cc88 --- /dev/null +++ b/apps/rebreak-native/install-android.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +# Rebreak Native — Build debug APK + install on connected Android device. +# +# Usage: +# ./install-android.sh # build + install + launch +# ./install-android.sh --no-build # skip Gradle build, just install last APK +# ./install-android.sh --no-launch # install but don't auto-launch +# +# Multi-Device: +# ANDROID_SERIAL= ./install-android.sh +# +# Wireless-ADB (einmalig): +# adb pair : # PIN vom Phone-Display eingeben +# adb connect : +# +# Phone-Setup: +# Einstellungen → Entwickleroptionen → USB-Debugging an +# Beim ersten Connect: "Diesem Computer vertrauen?" → Erlauben +set -euo pipefail + +cd "$(dirname "$0")" + +PACKAGE="org.rebreak.app" +APK="android/app/build/outputs/apk/debug/app-debug.apk" +SKIP_BUILD=0 +LAUNCH=1 + +while [[ $# -gt 0 ]]; do + case "$1" in + --no-build) SKIP_BUILD=1; shift ;; + --no-launch) LAUNCH=0; shift ;; + -h|--help) + awk '/^#!/{next} /^#/{sub(/^# ?/, ""); print; next} {exit}' "$0" + exit 0 ;; + *) echo "Unbekanntes Flag: $1"; echo " --help für Hilfe"; exit 1 ;; + esac +done + +if ! command -v adb >/dev/null 2>&1; then + echo "adb nicht im PATH. Android Platform-Tools installieren:" + echo " brew install --cask android-platform-tools" + exit 1 +fi + +# Lines that end with literal "device" (excludes "unauthorized", "offline", header). +DEVICE_LINES=$(adb devices | grep -E '[[:space:]]device$' || true) +DEVICE_COUNT=$(printf '%s\n' "$DEVICE_LINES" | grep -c '.' || true) + +if [[ "$DEVICE_COUNT" -eq 0 ]]; then + echo "Kein verfügbares Android-Gerät via ADB." + echo "" + adb devices + echo "" + echo "Mögliche Ursachen:" + echo " - USB nicht angeschlossen / Kabel nur Strom (nicht Daten)" + echo " - USB-Debugging auf dem Phone aus" + echo " - 'Diesem Computer vertrauen?' Dialog noch nicht bestätigt → 'unauthorized'" + echo " - Wireless-ADB nicht verbunden → adb connect :" + exit 1 +fi + +if [[ "$DEVICE_COUNT" -gt 1 && -z "${ANDROID_SERIAL:-}" ]]; then + echo "Mehrere Geräte verbunden:" + adb devices + echo "" + echo "Eines auswählen via ANDROID_SERIAL:" + echo " ANDROID_SERIAL= $0" + exit 1 +fi + +if [[ "$SKIP_BUILD" -eq 0 ]]; then + echo "→ Building debug APK (gradlew assembleDebug)..." + ( cd android && ./gradlew assembleDebug --console=plain ) +fi + +if [[ ! -f "$APK" ]]; then + echo "APK nicht gefunden: $APK" + echo " --no-build weglassen oder Build-Fehler oben prüfen." + exit 1 +fi + +echo "" +echo "→ Installing $APK..." +adb install -r -d "$APK" + +if [[ "$LAUNCH" -eq 1 ]]; then + echo "" + echo "→ Launching $PACKAGE..." + adb shell monkey -p "$PACKAGE" -c android.intent.category.LAUNCHER 1 >/dev/null 2>&1 || { + echo "Launch via monkey schlug fehl — App ist installiert, du kannst sie manuell öffnen." + } +fi + +echo "" +echo "Fertig." diff --git a/apps/rebreak-native/install-ios.sh b/apps/rebreak-native/install-ios.sh new file mode 100755 index 0000000..9de5f2c --- /dev/null +++ b/apps/rebreak-native/install-ios.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +# Rebreak Native — Build standalone iOS Release on connected iPhone. +# Bundle ist eingebettet → läuft OHNE Metro / OHNE WiFi-zum-Mac. +# Backend zeigt auf staging.rebreak.org (siehe extra.apiUrl in app.config.ts). +# +# Usage: +# ./install-ios.sh # Release-Build + Install + Launch +# ./install-ios.sh --debug # Debug-Build (braucht Metro!) — nur fürs Testen +# +# Voraussetzungen (einmalig): +# - Xcode installiert + Apple-ID in Xcode → Settings → Accounts hinzugefügt +# - iPhone via USB angeschlossen, "Diesem Computer vertrauen?" bestätigt +# - In Xcode: Window → Devices and Simulators → iPhone → 'Use for Development' +# - Auf iPhone (nach erstem Install): Einstellungen → Allgemein → VPN & Geräteverwaltung +# → Apple-Dev-Profil "Vertrauen" +# +# Free-Apple-Account-Hinweis: Release-Build läuft 7 Tage, danach muss neu installiert werden. +# Mit Paid Developer Account: 1 Jahr. +set -euo pipefail + +cd "$(dirname "$0")" + +CONFIGURATION="Release" +while [[ $# -gt 0 ]]; do + case "$1" in + --debug) CONFIGURATION="Debug"; shift ;; + -h|--help) awk '/^#!/{next} /^#/{sub(/^# ?/, ""); print; next} {exit}' "$0"; exit 0 ;; + *) echo "Unbekanntes Flag: $1"; exit 1 ;; + esac +done + +if ! command -v xcrun >/dev/null 2>&1; then + echo "Xcode Command-Line-Tools fehlen. Installieren:" + echo " xcode-select --install" + exit 1 +fi + +# Device-Detection via xctrace. +# == Devices == → physisch connected + entsperrt + trusted +# == Devices Offline == → schon mal gepairt, aber gerade nicht erreichbar +# (iPhone gesperrt, abgesteckt, oder Trust-Dialog wartet) +XCTRACE_OUT=$(xcrun xctrace list devices 2>&1) + +ONLINE=$(printf '%s\n' "$XCTRACE_OUT" \ + | awk '/^== Devices ==/{f=1; next} /^== /{f=0} f' \ + | grep -E "iPhone|iPad" || true) + +OFFLINE=$(printf '%s\n' "$XCTRACE_OUT" \ + | awk '/^== Devices Offline ==/{f=1; next} /^== /{f=0} f' \ + | grep -E "iPhone|iPad" || true) + +if [[ -z "$ONLINE" ]]; then + echo "Kein iPhone/iPad ONLINE." + if [[ -n "$OFFLINE" ]]; then + echo "" + echo "Aber diese Geräte sind gepairt aber offline:" + printf '%s\n' "$OFFLINE" | sed 's/^/ /' + echo "" + echo "Häufigste Ursachen:" + echo " 1. iPhone ist gesperrt → entsperren, Mac wartet darauf" + echo " 2. Kabel nur Strom, keine Daten → anderes Kabel probieren" + echo " 3. 'Diesem Computer vertrauen?'-Dialog → bestätigen" + echo " 4. Erst kürzlich angesteckt → 5-10 Sek warten und erneut probieren" + else + echo "" + echo "Setup nötig:" + echo " - iPhone via USB-Kabel anschließen + entsperren" + echo " - 'Diesem Computer vertrauen?' am iPhone bestätigen" + echo " - Xcode öffnen, dort einmalig 'Use for Development' aktivieren" + fi + exit 1 +fi +echo "→ Gerät online: $(printf '%s\n' "$ONLINE" | head -1)" + +echo "→ Building iOS $CONFIGURATION bundle + installing on device..." +echo " (erster Release-Build dauert 5-10 min wegen Pod-Install + Bundle)" +echo "" + +# expo run:ios kümmert sich um Pods + xcodebuild + Code-Signing + Install + Launch. +# --device wählt ein USB-Gerät statt Simulator. +# --configuration Release embeddet das JS-Bundle → keine Metro-Verbindung nötig. +npx expo run:ios --device --configuration "$CONFIGURATION" + +echo "" +echo "Fertig — App läuft jetzt standalone auf deinem iPhone." +if [[ "$CONFIGURATION" == "Release" ]]; then + echo "Backend: https://staging.rebreak.org (siehe extra.apiUrl in app.config.ts)" + echo "Free-Account: 7 Tage gültig, danach Skript erneut laufen lassen." +fi diff --git a/apps/rebreak-native/lib/api.ts b/apps/rebreak-native/lib/api.ts new file mode 100644 index 0000000..0776a4d --- /dev/null +++ b/apps/rebreak-native/lib/api.ts @@ -0,0 +1,49 @@ +import Constants from 'expo-constants'; +import { supabase } from './supabase'; + +const apiUrl = Constants.expoConfig?.extra?.apiUrl as string; + +type FetchOptions = Omit & { + body?: any; +}; + +/** + * Wrapper für Backend-API-Calls mit automatischem Auth-Token. + * Pendant zum Nuxt-`useSafeFetch` aus apps/rebreak/. + * + * Backend antwortet mit { success, data, status } — wir entpacken `data`. + */ +export async function apiFetch( + path: string, + options: FetchOptions = {} +): Promise { + const session = (await supabase.auth.getSession()).data.session; + + const headers: Record = { + 'Content-Type': 'application/json', + ...(options.headers as Record), + }; + + if (session?.access_token) { + headers.Authorization = `Bearer ${session.access_token}`; + } + + const res = await fetch(`${apiUrl}${path}`, { + ...options, + headers, + body: options.body ? JSON.stringify(options.body) : undefined, + }); + + if (!res.ok) { + const text = await res.text(); + throw new Error(`API ${res.status}: ${text}`); + } + + const json = await res.json(); + + // Unwrap { success, data, status } — siehe useSafeFetch-Pattern in der Vue-App + if (json && typeof json === 'object' && 'success' in json && 'data' in json) { + return json.data as T; + } + return json as T; +} diff --git a/apps/rebreak-native/lib/avatars.ts b/apps/rebreak-native/lib/avatars.ts new file mode 100644 index 0000000..9caf10d --- /dev/null +++ b/apps/rebreak-native/lib/avatars.ts @@ -0,0 +1,31 @@ +export interface HeroAvatar { + id: string; + name: string; + color: string; // NativeWind border color class + url: string; +} + +const DICEBEAR_BASE = 'https://api.dicebear.com/9.x/adventurer/svg'; + +export const HERO_AVATARS: HeroAvatar[] = [ + { id: 'spider', name: 'Spider', color: 'border-red-500', url: `${DICEBEAR_BASE}?seed=spiderman&backgroundColor=b71c1c` }, + { id: 'hulk', name: 'Hulk', color: 'border-green-500', url: `${DICEBEAR_BASE}?seed=hulk&backgroundColor=1b5e20` }, + { id: 'iron', name: 'Iron', color: 'border-yellow-500', url: `${DICEBEAR_BASE}?seed=ironman&backgroundColor=e65100` }, + { id: 'cap', name: 'Captain', color: 'border-blue-500', url: `${DICEBEAR_BASE}?seed=captain&backgroundColor=0d47a1` }, + { id: 'storm', name: 'Storm', color: 'border-purple-500', url: `${DICEBEAR_BASE}?seed=storm&backgroundColor=4a148c` }, + { id: 'wolf', name: 'Wolf', color: 'border-gray-400', url: `${DICEBEAR_BASE}?seed=wolverine&backgroundColor=37474f` }, + { id: 'flash', name: 'Flash', color: 'border-red-400', url: `${DICEBEAR_BASE}?seed=flash&backgroundColor=c62828` }, + { id: 'panther', name: 'Panther', color: 'border-indigo-500', url: `${DICEBEAR_BASE}?seed=panther&backgroundColor=1a237e` }, + { id: 'phoenix', name: 'Phoenix', color: 'border-orange-500', url: `${DICEBEAR_BASE}?seed=phoenix&backgroundColor=bf360c` }, + { id: 'frost', name: 'Frost', color: 'border-cyan-400', url: `${DICEBEAR_BASE}?seed=frost&backgroundColor=006064` }, + { id: 'shadow', name: 'Shadow', color: 'border-gray-600', url: `${DICEBEAR_BASE}?seed=shadow&backgroundColor=212121` }, + { id: 'nova', name: 'Nova', color: 'border-pink-500', url: `${DICEBEAR_BASE}?seed=nova&backgroundColor=880e4f` }, +]; + +export function getAvatarById(id: string): HeroAvatar | undefined { + return HERO_AVATARS.find((a) => a.id === id); +} + +export function getAvatarUrl(id: string): string { + return getAvatarById(id)?.url ?? `${DICEBEAR_BASE}?seed=anonym&backgroundColor=374151`; +} diff --git a/apps/rebreak-native/lib/formatTime.ts b/apps/rebreak-native/lib/formatTime.ts new file mode 100644 index 0000000..2494b65 --- /dev/null +++ b/apps/rebreak-native/lib/formatTime.ts @@ -0,0 +1,7 @@ +export function formatRelativeTime(ts: string): string { + const diff = Date.now() - new Date(ts).getTime(); + if (diff < 60_000) return 'gerade eben'; + if (diff < 3_600_000) return `vor ${Math.floor(diff / 60_000)}m`; + if (diff < 86_400_000) return `vor ${Math.floor(diff / 3_600_000)}h`; + return new Date(ts).toLocaleDateString('de-DE'); +} diff --git a/apps/rebreak-native/lib/i18n.ts b/apps/rebreak-native/lib/i18n.ts new file mode 100644 index 0000000..13ab397 --- /dev/null +++ b/apps/rebreak-native/lib/i18n.ts @@ -0,0 +1,26 @@ +import i18n from 'i18next'; +import { initReactI18next } from 'react-i18next'; +import * as Localization from 'expo-localization'; +import de from '../locales/de.json'; +import en from '../locales/en.json'; + +const deviceLocale = Localization.getLocales()[0]?.languageCode ?? 'en'; + +i18n.use(initReactI18next).init({ + resources: { + de: { translation: de }, + en: { translation: en }, + }, + lng: deviceLocale === 'de' ? 'de' : 'en', + fallbackLng: 'en', + // RN hat kein eingebautes Intl.PluralRules — v3 funktioniert nativ ohne Polyfill + compatibilityJSON: 'v3', + interpolation: { + escapeValue: false, + // Locale-Dateien verwenden Vue-i18n-Style %{var} (1:1 portiert aus der Nuxt-App). + prefix: '%{', + suffix: '}', + }, +}); + +export default i18n; diff --git a/apps/rebreak-native/lib/lyraResponse.ts b/apps/rebreak-native/lib/lyraResponse.ts new file mode 100644 index 0000000..5c4f2cf --- /dev/null +++ b/apps/rebreak-native/lib/lyraResponse.ts @@ -0,0 +1,61 @@ +// Parser für Lyras LLM-JSON-Antworten + Emotion-Detection. +import type { Emotion as LyraEmotion } from '../components/RiveAvatar'; +import { EMPATHY_RE, HAPPY_RE } from './sosConstants'; + +export type { LyraEmotion }; +export type ChipSpec = { label: string; action: string }; + +// Parse LLM JSON response — robust gegen Markdown-Fences UND abgeschnittenes JSON +export function parseLyraResponse(raw: string): { message: string; chips: ChipSpec[] } { + if (!raw) return { message: '', chips: [] }; + // Strip ALL markdown fences (auch wenn nur am Anfang) + const text = raw.trim() + .replace(/^```(?:json)?\s*/i, '') + .replace(/```\s*$/i, '') + .trim(); + const start = text.indexOf('{'); + if (start === -1) return { message: raw.trim(), chips: [] }; + // Erst echtes JSON-Parse versuchen (komplett) + const end = text.lastIndexOf('}'); + if (end > start) { + try { + const obj = JSON.parse(text.slice(start, end + 1)); + const message = typeof obj.message === 'string' ? obj.message.trim() : ''; + const chipsRaw = Array.isArray(obj.chips) ? obj.chips : []; + const chips: ChipSpec[] = chipsRaw + .filter((c: { label?: unknown; action?: unknown }) => c && typeof c.label === 'string' && typeof c.action === 'string') + .slice(0, 5) + .map((c: { label: string; action: string }) => ({ label: c.label.trim(), action: c.action.trim() })); + if (message) return { message, chips }; + } catch {/* fall through to recovery */} + } + // RECOVERY: JSON ist abgeschnitten (z.B. max_tokens hit). + // 1) message-Feld per Regex extrahieren + const msgMatch = text.match(/"message"\s*:\s*"((?:[^"\\]|\\.)*)"/); + let message = ''; + if (msgMatch) { + try { message = JSON.parse('"' + msgMatch[1] + '"'); } catch { message = msgMatch[1]; } + } + // 2) Chips: alle vollständigen {label,action}-Objekte einsammeln + const chips: ChipSpec[] = []; + const chipRe = /\{\s*"label"\s*:\s*"((?:[^"\\]|\\.)*)"\s*,\s*"action"\s*:\s*"((?:[^"\\]|\\.)*)"\s*\}/g; + let m: RegExpExecArray | null; + while ((m = chipRe.exec(text)) !== null && chips.length < 5) { + try { + chips.push({ + label: JSON.parse('"' + m[1] + '"').trim(), + action: JSON.parse('"' + m[2] + '"').trim(), + }); + } catch { + chips.push({ label: m[1].trim(), action: m[2].trim() }); + } + } + if (message) return { message, chips }; + return { message: raw.trim(), chips: [] }; +} + +export function detectEmotion(text: string): LyraEmotion { + if (HAPPY_RE.test(text)) return 'happy'; + if (EMPATHY_RE.test(text)) return 'empathy'; + return 'idle'; +} diff --git a/apps/rebreak-native/lib/protection.ts b/apps/rebreak-native/lib/protection.ts new file mode 100644 index 0000000..81a57d0 --- /dev/null +++ b/apps/rebreak-native/lib/protection.ts @@ -0,0 +1,270 @@ +/** + * Protection orchestration layer (JS-side). + * + * Verbindet das native rebreak-protection-Modul (Device-Layer-State) mit + * dem Backend-Cooldown-API (`/api/cooldown/*` + `/api/protection/state`). + * + * Cooldown ist Backend-driven (JWT mit `cooldown_ends_at`-Claim, server-time + * = single source of truth gegen lokale-Uhr-Manipulation). Native-Modul + * kümmert sich nur um echten Device-State (NEFilter, Family Controls etc.). + */ +import { Platform } from "react-native"; +import RebreakProtection from "../modules/rebreak-protection"; +import type { + ActivateResult, + DeviceLayers, + HealthProbeOpts, + HealthProbeResult, + SyncBlocklistOpts, + SyncBlocklistResult, + SystemSettingsTarget, +} from "../modules/rebreak-protection"; +import { apiFetch } from "./api"; + +// ─── Public Types ────────────────────────────────────────────────────────── + +export type ProtectionPhase = + | "inactive" + | "activating" + | "active" + | "cooldownPending" + | "cooldownActive" + | "recoveringFromBypass"; + +export type CooldownState = { + active: boolean; + endsAt: string | null; + remainingSeconds: number; + reason: string | null; +}; + +export type ProtectionState = { + phase: ProtectionPhase; + layers: DeviceLayers; + cooldown: CooldownState; + blocklistCount: number; + plan: "free" | "pro" | "legend"; +}; + +// ─── Backend Response-Types ──────────────────────────────────────────────── + +type BackendCooldownStatus = { + active: boolean; + remainingSeconds: number; + cooldownEndsAt: string | null; + token: string | null; + canDisableProtection: boolean; + reason?: string; +}; + +// Matches actual response from `apps/rebreak/server/api/protection/state.get.ts` +// (apiFetch unwrapt das `data`-Feld bereits, daher hier nur die Inner-Shape). +type BackendProtectionState = { + protectionShouldBeActive: boolean; + cooldown: { + active: boolean; + remainingSeconds: number; + cooldownEndsAt: string | null; + }; + plan: "free" | "pro" | "legend"; +}; + +// ─── Public API ──────────────────────────────────────────────────────────── + +export const protection = { + // ─── Native-Calls (Device-Layer) ───────────────────────────────────────── + + activate(): Promise { + return RebreakProtection.activate(); + }, + + async activateUrlFilter(): Promise<{ enabled: boolean; error?: string }> { + if (Platform.OS === "android") { + // Android Layer-1 = VpnService (DNS-Filter). iOS-API erwartet hier + // {enabled, error?}, also Native-`activate()`-Result re-shapen. + const res = await RebreakProtection.activate(); + const enabled = !res.missingLayers.includes("vpn"); + return enabled ? { enabled: true } : { enabled: false, error: res.errors?.[0] }; + } + return RebreakProtection.activateUrlFilter(); + }, + + async activateFamilyControls(): Promise<{ enabled: boolean; error?: string }> { + if (Platform.OS === "android") { + // Android Layer-2 = AccessibilityService (Browser-URL-Filter) + Tamper-Lock. + // Two-step UX: + // (1) A11y nicht aktiv → Settings öffnen, return {enabled:false} mit + // Marker-Error. UI fragt nach Return den State neu ab und tappt + // erneut auf "App lock" → wir landen in Step (2). + // (2) A11y aktiv → tamperLock armen → return {enabled:true}. + const a11y = await RebreakProtection.isAccessibilityEnabled(); + if (!a11y.enabled) { + await RebreakProtection.openAccessibilitySettings(); + return { enabled: false, error: "accessibility_pending" }; + } + try { + await RebreakProtection.armTamperLock(); + return { enabled: true }; + } catch (e: any) { + return { + enabled: false, + error: e?.message ?? "tamper_lock_failed", + }; + } + } + return RebreakProtection.activateFamilyControls(); + }, + + /** Schaltet alle Layer ab. NUR aufrufen wenn JS-Layer Cooldown verifiziert. */ + forceDisable() { + return RebreakProtection.disable(); + }, + + getDeviceState(): Promise { + return RebreakProtection.getDeviceState(); + }, + + syncBlocklist(opts: SyncBlocklistOpts): Promise { + return RebreakProtection.syncBlocklist(opts); + }, + + runHealthProbe(opts?: HealthProbeOpts): Promise { + return RebreakProtection.runHealthProbe(opts); + }, + + openSystemSettings(target?: SystemSettingsTarget): Promise { + return RebreakProtection.openSystemSettings(target); + }, + + addLayerChangeListener(cb: (layers: DeviceLayers) => void) { + return RebreakProtection.addListener("onLayerChange", cb); + }, + + // ─── Backend-Cooldown ──────────────────────────────────────────────────── + + /** Startet 24h Cooldown. Schutz BLEIBT aktiv, kann erst nach Ablauf disabled werden. */ + async requestDeactivation( + reason?: string, + ): Promise<{ cooldownEndsAt: string }> { + const res = await apiFetch<{ + cooldownEndsAt: string; + token: string; + remainingSeconds: number; + }>("/api/cooldown/request", { method: "POST", body: { reason } }); + return { cooldownEndsAt: res.cooldownEndsAt }; + }, + + /** Bricht laufenden Cooldown ab. Schutz BLEIBT aktiv. */ + async cancelDeactivation(): Promise<{ cancelled: boolean }> { + const res = await apiFetch<{ cancelled: boolean }>("/api/cooldown/cancel", { + method: "POST", + body: {}, + }); + return res; + }, + + async getCooldownStatus(): Promise { + try { + const res = await apiFetch("/api/cooldown/status"); + return { + active: res.active, + endsAt: res.cooldownEndsAt, + remainingSeconds: res.remainingSeconds, + reason: res.reason ?? null, + }; + } catch { + // Offline / Backend down → konservativ: kein Cooldown angenommen + return { active: false, endsAt: null, remainingSeconds: 0, reason: null }; + } + }, + + async getBackendProtectionState(): Promise { + try { + return await apiFetch("/api/protection/state"); + } catch { + return null; + } + }, + + // ─── Combined State (für UI) ───────────────────────────────────────────── + + /** + * Holt nativen Device-State + Backend-Cooldown parallel und merged. + * Phase-Berechnung folgt der State-Machine im Plan. + */ + async getCombinedState(): Promise { + const [layers, cooldown, backend] = await Promise.all([ + this.getDeviceState(), + this.getCooldownStatus(), + this.getBackendProtectionState(), + ]); + + const allLayersOn = isAllLayersOn(layers); + const iosLockActive = + layers.appDeletionLock ?? layers.familyControls ?? false; + const phase: ProtectionPhase = cooldown.active + ? "cooldownActive" + : backend?.protectionShouldBeActive === true && + layers.urlFilter === true && + iosLockActive !== true + ? "recoveringFromBypass" + : allLayersOn + ? "active" + : "inactive"; + + return { + phase, + layers, + cooldown, + blocklistCount: layers.blocklistCount, + plan: backend?.plan ?? "free", + }; + }, + + /** + * Wenn ein Cooldown TATSÄCHLICH gelaufen ist und jetzt elapsed → native disable. + * + * Defensiv: prüft `cooldownEndsAt` (heißt es gab einen Cooldown) UND + * `remainingSeconds <= 0` (heißt er ist abgelaufen) UND `canDisableProtection`. + * Backend kann `canDisableProtection: true` auch im initial-state geben; + * der `cooldownEndsAt`-Check verhindert dann False-Positives. + */ + async applyCooldownDisableIfElapsed(): Promise { + const status = await apiFetch( + "/api/cooldown/status", + ).catch(() => null); + if (!status) return false; + if (!status.canDisableProtection) return false; + if (!status.cooldownEndsAt) return false; // nie ein Cooldown gewesen + if (status.remainingSeconds > 0) return false; // Cooldown noch nicht abgelaufen + await this.forceDisable(); + return true; + }, +}; + +// ─── Helpers ─────────────────────────────────────────────────────────────── + +export function isAllLayersOn(layers: DeviceLayers): boolean { + // iOS: urlFilter + appDeletionLock (fallback: familyControls für ältere Builds). + // Android: vpn + accessibility (+ tamperLock optional). + if ( + layers.urlFilter !== undefined || + layers.familyControls !== undefined || + layers.appDeletionLock !== undefined + ) { + const lockLayer = layers.appDeletionLock ?? layers.familyControls; + return layers.urlFilter === true && lockLayer === true; + } + if (layers.vpn !== undefined || layers.accessibility !== undefined) { + return layers.vpn === true && layers.accessibility === true; + } + return false; +} + +export function formatCooldownRemaining(seconds: number): string { + if (seconds <= 0) return "00:00:00"; + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + const s = seconds % 60; + return [h, m, s].map((n) => String(n).padStart(2, "0")).join(":"); +} diff --git a/apps/rebreak-native/lib/resolveAvatar.ts b/apps/rebreak-native/lib/resolveAvatar.ts new file mode 100644 index 0000000..4b044b8 --- /dev/null +++ b/apps/rebreak-native/lib/resolveAvatar.ts @@ -0,0 +1,29 @@ +import { getAvatarById, getAvatarUrl } from './avatars'; + +const DICEBEAR_BASE = 'https://api.dicebear.com/9.x/adventurer/svg'; + +/** + * Resolves the `profiles.avatar` field zu einer renderbaren URL. + * + * Drei Quellen-Formate werden unterstützt: + * 1. Hero-Avatar-ID (z.B. "spider", "hulk") → DiceBear-URL aus HERO_AVATARS + * 2. Custom-Photo-URL (https://... — User hat Foto via Profile-Edit + * hochgeladen, gespeichert in Supabase-Storage) → unverändert durchreichen + * 3. Leer / unbekannt → Dicebear-Initials-Fallback per nickname + * + * Wichtig: NICHT blindly getAvatarUrl(avatarId) aufrufen — das gab vorher den + * Dicebear-anonym-Fallback zurück wenn avatarId zwar truthy aber kein Hero + * (z.B. Foto-URL). Jetzt wird zuerst auf URL geprüft. + */ +export function resolveAvatar(avatarId: string | null | undefined, nickname: string): string { + if (avatarId) { + if (/^https?:\/\//i.test(avatarId)) { + return avatarId; + } + if (getAvatarById(avatarId)) { + return getAvatarUrl(avatarId); + } + } + const seed = encodeURIComponent(nickname || 'anonym'); + return `${DICEBEAR_BASE}?seed=${seed}&backgroundColor=374151`; +} diff --git a/apps/rebreak-native/lib/sosConstants.ts b/apps/rebreak-native/lib/sosConstants.ts new file mode 100644 index 0000000..99d3a4f --- /dev/null +++ b/apps/rebreak-native/lib/sosConstants.ts @@ -0,0 +1,56 @@ +// Konstanten für den SOS-Screen: Chip-Sets, Atemphasen, Emotion-Regex. + +export type ChipSet = 'start' | 'help' | 'after_breathing' | 'after_game' | 'overcome_check' | 'overcome_done' | 'none'; +type Chip = { label: string; action: string }; + +export const CHIP_SETS: Record = { + start: [ + { label: '😤 Wütend', action: 'feel:Ich bin gerade sehr wütend.' }, + { label: '😰 Ängstlich', action: 'feel:Ich bin ängstlich und nervös.' }, + { label: '😔 Traurig', action: 'feel:Ich bin traurig.' }, + { label: '😤 Gestresst', action: 'feel:Ich bin gerade gestresst.' }, + { label: '😶 Leer', action: 'feel:Ich fühle mich innerlich leer.' }, + { label: '🤔 Etwas anderes...', action: 'need_help' }, + ], + help: [ + { label: '🫁 Atemübung', action: 'breathing' }, + { label: '🎮 Spiel starten', action: 'game_picker' }, + ], + after_breathing: [ + { label: '😌 Besser', action: 'send_text:Ich fühle mich nach der Atemübung besser.' }, + { label: '🔄 Nochmal', action: 'breathing' }, + { label: '🎮 Spiel', action: 'game_picker' }, + { label: '❤️ Überwunden', action: 'overcome' }, + ], + after_game: [ + { label: '😌 Ruhiger', action: 'send_text:Das Spiel hat geholfen, ich bin ruhiger.' }, + { label: '🔄 Nochmal', action: 'game_picker' }, + { label: '❤️ Überwunden', action: 'overcome' }, + ], + overcome_check: [ + { label: '✅ Ja, ich habe es geschafft', action: 'overcome' }, + { label: '💪 Noch nicht ganz', action: 'send_text:Der Spielimpuls ist noch da, ich brauche noch Hilfe.' }, + ], + overcome_done: [ + { label: '✨ Erfolg teilen', action: 'share_success' }, + { label: '⭐ Diese Session bewerten', action: 'rate_session' }, + { label: '📊 Meine Statistik', action: 'show_stats' }, + { label: '✅ Fertig', action: 'close' }, + ], + none: [], +}; + +// ── Breathing guide ────────────────────────────────────────────────────────── +export type BreathPhase = 'inhale' | 'hold' | 'exhale'; +export type BreathState = 'idle' | 'countdown' | 'active'; +// speakLine bewusst durchgehend null — Phase-TTS würde Lyras laufende Audio abbrechen +// (User-Wahrnehmung: "Stimme ändert sich"). Visuelles Pulsieren + Countdown reicht. +export const BREATH_PHASES: { phase: BreathPhase; duration: number; label: string; color: string; speakLine: string | null }[] = [ + { phase: 'inhale', duration: 4, label: 'Einatmen', color: '#6366f1', speakLine: null }, + { phase: 'hold', duration: 7, label: 'Halten', color: '#f97316', speakLine: null }, + { phase: 'exhale', duration: 8, label: 'Ausatmen', color: '#16a34a', speakLine: null }, +]; +export const TOTAL_ROUNDS = 3; + +export const EMPATHY_RE = /schwer|rückfall|traurig|schlimm|hoffnungslos|verloren|scham|schuld|verzweifelt/i; +export const HAPPY_RE = /toll|super|geschafft|stark|stolz|fantastisch|prima/i; diff --git a/apps/rebreak-native/lib/sosPrompts.ts b/apps/rebreak-native/lib/sosPrompts.ts new file mode 100644 index 0000000..1663712 --- /dev/null +++ b/apps/rebreak-native/lib/sosPrompts.ts @@ -0,0 +1,36 @@ +// SOS-Modus System-Prompt — wird als erste user-message in den LLM-Kontext gelegt. +// Definiert Lyras Rolle + Gesprächsführung. Antwort-FORMAT wird vom Server +// festgelegt (siehe `apps/rebreak/server/api/coach/sos-stream.get.ts` +// SOS_INSTRUCTION) — Prosa + EINE Schluss-Zeile mit `[[CHIPS]]:[...]` Marker. +// Wir wiederholen das Format-Spec hier NICHT — das hatte vorher zu konfliktierenden +// Anweisungen geführt (LLM dumpte zusätzlich JSON-Wrapper ans Ende). +export const SOS_BOOT = [{ + role: 'user' as const, + content: + '[SOS-MODUS — SEHR WICHTIG]\n\n' + + 'Ich habe gerade die Notfallhilfe geöffnet, weil ich einen akuten Spielimpuls spüre.\n\n' + + 'DEINE ROLLE:\n' + + '- Du bist Lyra, meine ruhige, warme Begleitung in dieser Krise.\n' + + '- DEIN ZIEL: Impuls runterfahren → Trigger/Ursache erkennen → spätestens nach 2-3 Fragen Atemübung oder Spiel anbieten.\n' + + '- Sprich kurz (max. 2-3 Sätze), menschlich, kein Therapeut-Sprech, kein Druck.\n' + + '- Zeige Mitgefühl. Spiegele, was ich sage. Zwinge mich nicht.\n' + + '\n' + + 'GESPRÄCHSFÜHRUNG (PFLICHT):\n' + + '- KURZ: 1-2 Sätze, max 3. Mehr ist verboten.\n' + + '- JEDE Antwort endet mit einer konkreten Frage ODER einem klaren Angebot. NIE offen lassen.\n' + + '- JEDE Antwort liefert 2-4 Chips als Antwort-Optionen. NIE ohne Chips.\n' + + '- Spätestens nach meiner 3. Antwort: biete Atemübung ODER Spiel an (Chips: "🫁 Atemübung" + "🎮 Spiel" + "💬 Weiter reden").\n' + + '- Frag NIE "Magst du mir mehr erzählen?" ohne Chips — immer 2-3 Antworten als Chips anbieten.\n' + + '- ABSOLUT KRITISCH zur SPRACHE: NIEMALS Chip-Optionen im Prosa-Text auflisten oder paraphrasieren.\n' + + ' Der Prosa-Text wird VORGELESEN (TTS) → Chip-Aufzählung klingt unnatürlich.\n' + + ' ✗ FALSCH: "Magst du atmen oder lieber spielen?"\n' + + ' ✗ FALSCH: "Du kannst eine Atemübung oder ein Spiel machen."\n' + + ' ✓ RICHTIG: "Magst du was probieren?"\n' + + ' ✓ RICHTIG: "Was hilft dir gerade?"\n' + + '\n' + + 'CHIPS-INHALTE (Beispiele, nicht Format — Format ist im System-Prompt definiert):\n' + + '- Erste Antworten (Trigger erkunden): "💬 Ja, lass uns reden", "😶 Lieber nicht", "💥 Es war Stress", "🌙 Es war Einsamkeit"\n' + + '- Nach 2-3 Fragen (Angebot): "🫁 Atemübung", "🎮 Spiel", "💬 Weiter reden"\n' + + '- Nach Atmen/Spiel: "😌 Besser", "🔄 Nochmal", "❤️ Überwunden"\n\n' + + 'Jetzt los — erste Begrüßung mit warmer Frage + 3-4 Gefühls-Chips zum Erkunden. Antwort-Format folgt der System-Prompt-Spec, nicht meiner Beschreibung hier.', +}]; diff --git a/apps/rebreak-native/lib/sosStream.ts b/apps/rebreak-native/lib/sosStream.ts new file mode 100644 index 0000000..329b29b --- /dev/null +++ b/apps/rebreak-native/lib/sosStream.ts @@ -0,0 +1,141 @@ +// SSE Streaming Helper für Lyras SOS-Antworten. +// +// Architektur (bewusst entkoppelt): +// - Step 1: POST /api/coach/sos-session → liefert sessionId +// - Step 2: EventSource auf /api/coach/sos-stream?session= +// - Events: 'message' (chunk), 'chips' (parsed), 'done', 'error' +// +// Phase B (sentence-level TTS): zusätzlich `onSentence?` Callback. Wenn gesetzt, +// feuert er sobald ein vollständiger Satz erkannt wird (live während des +// Streams) + einmal am Stream-Ende für den Tail. Aufrufer kann den Satz dann +// direkt in eine TTS-Queue schieben → erste Audio-Wiedergabe ~3s früher als +// "warten bis fullText fertig". +import EventSource from 'react-native-sse'; + +type SseEvents = 'message' | 'chips' | 'done'; + +export type StreamSosLyraOpts = { + apiBase: string; + token: string; + messages: Array<{ role: 'user' | 'assistant'; content: string }>; + locale: string; + onTextUpdate: (full: string) => void; + onChips: (chips: Array<{ label: string; action: string }>) => void; + /** Phase B: feuert pro fertigem Satz live während des Streams + Tail beim + * done. Bei aktivem TTS-Streaming sollte der Aufrufer hier seine Queue + * füllen statt im onDone den ganzen Text zu sprechen. */ + onSentence?: (sentence: string) => void; + onDone: (full: string) => void; + onError: (err: unknown) => void; +}; + +// Min-Länge für sentence-level TTS — winzige "Hm." / "Ja." kommen mit dem +// nächsten Satz mit, sonst klingt's choppy. +const MIN_SENTENCE_CHARS = 8; + +/** + * Findet vollständige Sätze im Text. Ein Satz endet bei `[.!?]` GEFOLGT VON + * Whitespace + Großbuchstaben (oder Zitat-Anfang). Das filtert die häufigen + * deutschen Abkürzungen "z.B. einfach", "d.h. nichts" etc. ohne explizite + * Abkürzungs-Liste — der nächste Char ist dann meistens lowercase. + * + * Returns: { sentences[], consumed } — wieviele chars vom Anfang von `text` + * bereits in `sentences` enthalten sind (inkl. trailing whitespace). + */ +function consumeCompletedSentences(text: string): { sentences: string[]; consumed: number } { + const sentences: string[] = []; + const re = /[.!?](?=\s+[A-ZÄÖÜ"„])/g; + let lastEnd = 0; + let m: RegExpExecArray | null; + while ((m = re.exec(text)) !== null) { + const endOfSentence = m.index + 1; + sentences.push(text.slice(lastEnd, endOfSentence)); + const wsMatch = text.slice(endOfSentence).match(/^\s+/); + lastEnd = endOfSentence + (wsMatch ? wsMatch[0].length : 0); + re.lastIndex = lastEnd; + } + return { sentences, consumed: lastEnd }; +} + +export async function streamSosLyra(opts: StreamSosLyraOpts): Promise<() => void> { + // Step 1: POST zu /api/coach/sos-session → sessionId holen + const sessRes = await fetch(`${opts.apiBase}/api/coach/sos-session`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${opts.token}`, + }, + body: JSON.stringify({ messages: opts.messages, locale: opts.locale }), + }); + if (!sessRes.ok) throw new Error(`session: ${sessRes.status}`); + const { sessionId } = await sessRes.json(); + + // Step 2: EventSource für SSE-Stream + // pollingInterval: 0 → KEIN Auto-Reconnect (Session ist one-time-use) + const es = new EventSource(`${opts.apiBase}/api/coach/sos-stream?session=${sessionId}`, { + headers: { Authorization: `Bearer ${opts.token}` }, + pollingInterval: 0, + lineEndingCharacter: '\n', + }); + + let fullText = ''; + let sentenceConsumedIndex = 0; + + const flushNewSentences = () => { + if (!opts.onSentence) return; + const remaining = fullText.slice(sentenceConsumedIndex); + const { sentences, consumed } = consumeCompletedSentences(remaining); + for (const s of sentences) { + const trimmed = s.trim(); + if (trimmed.length >= MIN_SENTENCE_CHARS) { + opts.onSentence(trimmed); + } + } + sentenceConsumedIndex += consumed; + }; + + es.addEventListener('message', (event) => { + if (!event.data) return; + // Backend sendet JSON-encoded String → parse für korrekte Whitespace-Behandlung + let chunk: string; + try { + chunk = JSON.parse(event.data); + } catch { + chunk = event.data; + } + if (!chunk) return; + fullText += chunk; + opts.onTextUpdate(fullText); + // Phase B: live sentence-detection für TTS-Queue + flushNewSentences(); + }); + + es.addEventListener('chips', (event) => { + if (!event.data) return; + try { + const chips = JSON.parse(event.data); + if (Array.isArray(chips)) opts.onChips(chips); + } catch { /* ignore */ } + }); + + es.addEventListener('done', () => { + // Phase B: Tail flushen (letzter Satz ohne folgendes Capital-Letter wird + // sonst nie als "complete" erkannt). Trim leeren Tail away. + if (opts.onSentence) { + const tail = fullText.slice(sentenceConsumedIndex).trim(); + if (tail.length > 0) { + opts.onSentence(tail); + } + } + opts.onDone(fullText); + es.close(); + }); + + es.addEventListener('error', (err) => { + opts.onError(err); + es.close(); + }); + + // Return cancel-Funktion + return () => { es.close(); }; +} diff --git a/apps/rebreak-native/lib/sosTtsQueue.ts b/apps/rebreak-native/lib/sosTtsQueue.ts new file mode 100644 index 0000000..0ee06fd --- /dev/null +++ b/apps/rebreak-native/lib/sosTtsQueue.ts @@ -0,0 +1,209 @@ +// Sentence-Level TTS Queue für SOS-Streaming. +// +// Aufrufer (urge.tsx) erstellt eine neue Queue pro sendToLyra-Call und füttert +// sie via `enqueue(sentence)` aus dem `onSentence`-Callback von streamSosLyra. +// Die Queue fetched + spielt sequenziell — wenn n+1 reinkommt während n noch +// spielt, wartet der Fetch bis n's Audio durch ist (kein doppeltes Sprechen). +// +// Lifecycle: +// - new SosTtsQueue({...}) → bereit, nichts spielt +// - enqueue(s1) → fetch + play s1 +// - enqueue(s2) während s1 spielt → s2 wartet in queue, fetch+play sobald s1 fertig +// - abort() → in-flight fetch cancelled, current sound stopped+unloaded, queue cleared +// +// State-Reporting via Callbacks: onStart (erster Satz beginnt zu spielen), +// onIdle (Queue komplett durch + nichts mehr spielt). UI-Layer kann darauf +// `setIsSpeaking` triggern. +import { Audio } from 'expo-av'; +import * as FileSystem from 'expo-file-system'; + +export type SosTtsFetchOpts = { + apiBase: string; + accessToken: string; + locale: string; + /** Server-Pfad zum TTS-Endpoint, default: OpenAI. Erlaubt A/B zwischen + * /api/coach/speak-openai, /api/coach/speak-gemini, /api/coach/speak-google. */ + endpoint?: string; +}; + +export type SosTtsQueueOpts = SosTtsFetchOpts & { + /** Erster Satz beginnt zu spielen. */ + onStart?: () => void; + /** Queue ist leer + nichts spielt mehr. */ + onIdle?: () => void; + /** Single-sentence-fetch oder -playback ist gescheitert. Queue läuft weiter. */ + onError?: (err: unknown, sentence: string) => void; +}; + +const EMOJI_RE = /[\p{Extended_Pictographic}\p{Emoji_Component}]/gu; + +function cleanForTts(text: string): string { + return text.replace(EMOJI_RE, '').replace(/\s+/g, ' ').trim(); +} + +export type SosTtsMode = 'sos' | 'sos-continuation'; + +type QueueItem = { + text: string; + mode: SosTtsMode; + controller: AbortController; + /** Pre-fetch starts beim enqueue → wenn play dran ist, ist Audio meist schon + * fertig oder fast fertig. Eliminiert Gap zwischen Items im Hybrid-Mode. */ + audioPromise: Promise<{ uri: string } | null>; +}; + +export class SosTtsQueue { + private queue: QueueItem[] = []; + private playing = false; + private currentSound: Audio.Sound | null = null; + private aborted = false; + private startedOnce = false; + private opts: SosTtsQueueOpts; + + constructor(opts: SosTtsQueueOpts) { + this.opts = opts; + } + + /** + * Enqueue a text segment for TTS playback. + * @param mode Default 'sos' (warm-empathic-opening). Use 'sos-continuation' + * für Folge-Blöcke im Hybrid-Mode → server passt OpenAI's + * `instructions`-Feld an damit der Voice-Boundary weicher klingt. + */ + enqueue(sentence: string, mode: SosTtsMode = 'sos'): void { + if (this.aborted) return; + const cleaned = cleanForTts(sentence); + if (!cleaned) return; + // Pre-fetch SOFORT beim enqueue → läuft parallel zum Playback der vorigen + // Items. Heißt: wenn Item 1 fertig spielt, ist Item 2's Audio meist schon + // im Cache → null Gap zwischen den Sätzen/Blöcken. + const controller = new AbortController(); + const audioPromise = this.fetchAudio(cleaned, mode, controller.signal).catch((err) => { + this.opts.onError?.(err, cleaned); + return null; + }); + this.queue.push({ text: cleaned, mode, controller, audioPromise }); + void this.tick(); + } + + abort(): void { + this.aborted = true; + // Alle in-flight fetches cancelen (auch pre-fetched ones) + for (const item of this.queue) { + item.controller.abort(); + } + this.queue = []; + if (this.currentSound) { + const s = this.currentSound; + this.currentSound = null; + s.stopAsync().catch(() => {}); + s.unloadAsync().catch(() => {}); + } + } + + /** True wenn noch was läuft (in queue oder gerade spielend). */ + isActive(): boolean { + return !this.aborted && (this.playing || this.queue.length > 0); + } + + private async tick(): Promise { + if (this.aborted || this.playing) return; + const item = this.queue.shift(); + if (!item) return; + this.playing = true; + + if (!this.startedOnce) { + this.startedOnce = true; + this.opts.onStart?.(); + } + + try { + const audio = await item.audioPromise; + if (this.aborted || !audio) return; + + const { sound } = await Audio.Sound.createAsync( + { uri: audio.uri }, + { shouldPlay: true }, + ); + if (this.aborted) { + await sound.unloadAsync().catch(() => {}); + return; + } + this.currentSound = sound; + await new Promise((resolve) => { + sound.setOnPlaybackStatusUpdate((status) => { + if (this.aborted) { + sound.setOnPlaybackStatusUpdate(null); + resolve(); + return; + } + if (status.isLoaded && status.didJustFinish) { + sound.setOnPlaybackStatusUpdate(null); + sound.unloadAsync().catch(() => {}); + resolve(); + } + }); + }); + this.currentSound = null; + } catch (err) { + this.opts.onError?.(err, item.text); + } finally { + this.playing = false; + if (this.aborted) return; + if (this.queue.length > 0) { + void this.tick(); + } else { + this.opts.onIdle?.(); + } + } + } + + private async fetchAudio(text: string, mode: SosTtsMode, signal: AbortSignal): Promise<{ uri: string } | null> { + const endpoint = this.opts.endpoint ?? '/api/coach/speak-openai'; + const isGoogleCloud = endpoint.endsWith('/speak-google'); + const res = await fetch(`${this.opts.apiBase}${endpoint}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.opts.accessToken}`, + }, + body: JSON.stringify({ text, locale: this.opts.locale, mode }), + signal, + }); + if (!res.ok || signal.aborted) return null; + + // /speak-google liefert JSON { audio: "data:audio/mp3;base64,..." }. + // /speak-openai (audio/mpeg) und /speak-gemini (audio/wav) liefern den + // Body als raw bytes — gleiche Pipeline reicht für beide. + let base64: string; + let ext: 'mp3' | 'wav'; + if (isGoogleCloud) { + const json = (await res.json()) as { audio?: string }; + const dataUri = json.audio ?? ''; + const comma = dataUri.indexOf(','); + if (comma === -1) return null; + base64 = dataUri.slice(comma + 1); + ext = 'mp3'; + } else { + const buffer = await res.arrayBuffer(); + if (signal.aborted || buffer.byteLength === 0) return null; + const bytes = new Uint8Array(buffer); + const chunks: string[] = []; + const cs = 0x8000; + for (let i = 0; i < bytes.length; i += cs) { + chunks.push( + String.fromCharCode(...bytes.subarray(i, Math.min(i + cs, bytes.length))), + ); + } + base64 = btoa(chunks.join('')); + ext = endpoint.endsWith('/speak-gemini') ? 'wav' : 'mp3'; + } + + const tmpPath = `${FileSystem.cacheDirectory}sos-tts-q-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${ext}`; + await FileSystem.writeAsStringAsync(tmpPath, base64, { + encoding: FileSystem.EncodingType.Base64, + }); + if (signal.aborted) return null; + return { uri: tmpPath }; + } +} diff --git a/apps/rebreak-native/lib/supabase.ts b/apps/rebreak-native/lib/supabase.ts new file mode 100644 index 0000000..3304a52 --- /dev/null +++ b/apps/rebreak-native/lib/supabase.ts @@ -0,0 +1,28 @@ +import "react-native-url-polyfill/auto"; +import AsyncStorage from "@react-native-async-storage/async-storage"; +import { createClient } from "@supabase/supabase-js"; +import Constants from "expo-constants"; + +const supabaseUrl = Constants.expoConfig?.extra?.supabaseUrl as string; +const supabaseAnonKey = Constants.expoConfig?.extra?.supabaseAnonKey as string; + +if (!supabaseUrl || !supabaseAnonKey) { + throw new Error( + "Supabase URL und Anon Key müssen in app.config.ts (extra) gesetzt sein. " + + "EXPO_PUBLIC_SUPABASE_URL + EXPO_PUBLIC_SUPABASE_ANON_KEY in env.", + ); +} + +export const supabase = createClient(supabaseUrl, supabaseAnonKey, { + auth: { + storage: AsyncStorage, + autoRefreshToken: true, + persistSession: true, + detectSessionInUrl: false, + }, + realtime: { + params: { + apikey: supabaseAnonKey, + }, + }, +}); diff --git a/apps/rebreak-native/lib/tabIcons.ts b/apps/rebreak-native/lib/tabIcons.ts new file mode 100644 index 0000000..5670d5c --- /dev/null +++ b/apps/rebreak-native/lib/tabIcons.ts @@ -0,0 +1,35 @@ +// Cross-platform Tab-Bar Icons. +// +// react-native-bottom-tabs akzeptiert `ImageSource | AppleIcon` als tabBarIcon. +// AppleIcon = SF Symbols → iOS-only. Auf Android müssen wir ImageSource liefern. +// +// Lösung: 5 PNG-Files (Ionicons-rastered) im assets/tabs/-Ordner. Metro bundelt +// sie automatisch + Android RES-Drawable wird via require()-Asset-Hash erzeugt. +// react-native-bottom-tabs wendet tabBarActiveTintColor automatisch als Tint an. +// +// Warum nicht Ionicons.getImageSource() at runtime? @expo/vector-icons v14 +// expose-d die statische Methode nicht mehr — und der Vendor-Pfad nutzt +// RNVectorIconsManager (native), das Expo nicht bundled. Bundle-PNGs sind die +// einzige zuverlässige Lösung. +import { Platform, type ImageSourcePropType } from 'react-native'; + +export type TabKey = 'home' | 'chat' | 'coach' | 'blocker' | 'mail'; + +const ANDROID_ICONS: Record = { + home: require('../assets/tabs/home.png'), + chat: require('../assets/tabs/chatbubble.png'), + coach: require('../assets/tabs/sparkles.png'), + blocker: require('../assets/tabs/shield-checkmark.png'), + mail: require('../assets/tabs/mail.png'), +}; + +export function getTabIcon(key: TabKey): ImageSourcePropType | undefined { + if (Platform.OS !== 'android') return undefined; + return ANDROID_ICONS[key]; +} + +// Backwards-compat: noop. Andere Module rufen `preloadTabIcons()` aus dem alten +// async-getImageSource-Pattern auf. +export function preloadTabIcons(): Promise { + return Promise.resolve(); +} diff --git a/apps/rebreak-native/lib/theme.ts b/apps/rebreak-native/lib/theme.ts new file mode 100644 index 0000000..7afc4e4 --- /dev/null +++ b/apps/rebreak-native/lib/theme.ts @@ -0,0 +1,26 @@ +export const theme = { + bg: 'bg-white', + surface: 'bg-neutral-50', + surfaceElevated: 'bg-neutral-100', + border: 'border-neutral-200', + text: 'text-neutral-900', + textMuted: 'text-neutral-500', + brandOrange: 'text-rebreak-500', + brandOrangeBg: 'bg-rebreak-500', + brandBlue: 'bg-midnight-800', +} as const; + +export const colors = { + bg: '#ffffff', + surface: '#fafafa', + surfaceElevated: '#f5f5f5', + border: '#e5e5e5', + text: '#0a0a0a', + textMuted: '#737373', + // TEMP zum Testen: iOS native blue. Wieder auf '#f59e0b' wenn du zur Brand zurück willst. + brandOrange: '#007AFF', + brandBlue: '#0e1f3a', + success: '#16a34a', + error: '#dc2626', + warning: '#f59e0b', +} as const; diff --git a/apps/rebreak-native/lib/ttsProvider.ts b/apps/rebreak-native/lib/ttsProvider.ts new file mode 100644 index 0000000..de5b870 --- /dev/null +++ b/apps/rebreak-native/lib/ttsProvider.ts @@ -0,0 +1,54 @@ +// SOS-TTS-Provider mit AsyncStorage-Persist + Listener-Pattern. +// Live-Switch im SOS-Screen: Hook holt aktuelle Wahl + reagiert auf Änderungen +// während des Mounts. Endpoint-Path wird beim Erzeugen der TTS-Queue gelesen. +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { useEffect, useState } from 'react'; + +export type TtsProvider = 'openai' | 'gemini' | 'google-cloud'; + +const STORAGE_KEY = 'rebreak-sos-tts-provider'; +const DEFAULT_PROVIDER: TtsProvider = 'openai'; + +export const TTS_PROVIDER_LABEL: Record = { + openai: 'OpenAI', + gemini: 'Gemini', + 'google-cloud': 'Cloud', +}; + +export const TTS_PROVIDER_ENDPOINT: Record = { + openai: '/api/coach/speak-openai', + gemini: '/api/coach/speak-gemini', + 'google-cloud': '/api/coach/speak-google', +}; + +const listeners = new Set<(p: TtsProvider) => void>(); +let cached: TtsProvider | null = null; + +export async function loadTtsProvider(): Promise { + if (cached) return cached; + const raw = await AsyncStorage.getItem(STORAGE_KEY).catch(() => null); + cached = raw === 'gemini' || raw === 'google-cloud' ? raw : DEFAULT_PROVIDER; + return cached; +} + +export async function setTtsProvider(p: TtsProvider): Promise { + cached = p; + await AsyncStorage.setItem(STORAGE_KEY, p).catch(() => {}); + for (const cb of listeners) cb(p); +} + +export function endpointForProvider(p: TtsProvider): string { + return TTS_PROVIDER_ENDPOINT[p]; +} + +export function useTtsProvider(): [TtsProvider, (p: TtsProvider) => Promise] { + const [p, setP] = useState(cached ?? DEFAULT_PROVIDER); + useEffect(() => { + let mounted = true; + loadTtsProvider().then((v) => { if (mounted) setP(v); }); + const cb = (v: TtsProvider) => { if (mounted) setP(v); }; + listeners.add(cb); + return () => { mounted = false; listeners.delete(cb); }; + }, []); + return [p, setTtsProvider]; +} diff --git a/apps/rebreak-native/locales/de.json b/apps/rebreak-native/locales/de.json new file mode 100644 index 0000000..c84d8a1 --- /dev/null +++ b/apps/rebreak-native/locales/de.json @@ -0,0 +1,591 @@ +{ + "common": { + "loading": "Einen Moment...", + "cancel": "Abbrechen", + "continue": "Weiter", + "back": "Zurück", + "error": "Fehler", + "success": "Erfolgreich", + "ok": "OK", + "confirm": "Bestätigen", + "retry": "Erneut versuchen", + "unknown_error": "Unbekannter Fehler" + }, + "auth": { + "welcomeBack": "Willkommen zurück", + "signinSubtitle": "Melde dich an, um weiterzumachen.", + "signin": "Anmelden", + "signingIn": "Einen Moment...", + "signup": "Registrieren", + "signupTitle": "Konto erstellen", + "signupSubtitle": "Werde Teil der Community.", + "signOut": "Abmelden", + "email": "E-Mail", + "emailPlaceholder": "E-Mail", + "emailRequired": "E-Mail *", + "password": "Passwort", + "passwordPlaceholder": "Passwort", + "passwordRequired": "Passwort * (min. 8 Zeichen)", + "passwordMin8": "Passwort muss mindestens 8 Zeichen haben.", + "newPassword": "Neues Passwort", + "firstName": "Vorname", + "lastName": "Nachname", + "nickname": "Benutzername", + "nicknamePlaceholder": "Benutzername * (sichtbar für andere)", + "noAccount": "Noch kein Konto?", + "alreadyRegistered": "Bereits registriert?", + "fillRequired": "Bitte alle Pflichtfelder ausfüllen.", + "googleSignin": "Mit Google anmelden", + "appleSignin": "Mit Apple anmelden", + "googleSignup": "Mit Google registrieren", + "appleSignup": "Mit Apple registrieren", + "orWithEmail": "oder mit E-Mail", + "forgotPassword": "Passwort vergessen?", + "resetPasswordTitle": "Passwort zurücksetzen", + "resetPasswordSubtitle": "Gib deine E-Mail-Adresse ein und wir senden dir einen Link zum Zurücksetzen.", + "resetPasswordSend": "Link senden", + "resetPasswordSent": "E-Mail gesendet", + "resetPasswordSentDesc": "Prüfe dein Postfach. Der Link ist 60 Minuten gültig.", + "resetPasswordSentDescPrefix": "Prüfe dein Postfach für ", + "resetPasswordSentDescSuffix": ". Der Link ist 60 Minuten gültig.", + "backToLogin": "← Zurück zum Login", + "backToLoginPlain": "Zurück zum Login", + "backToSignup": "← Zurück zur Registrierung", + "chooseAvatar": "Avatar wählen", + "privacyNotice": "Deine Daten werden sicher auf Servern in Deutschland gespeichert. Wir verkaufen keine Daten an Dritte.", + "acceptTerms": "Ich akzeptiere die", + "acceptTermsSuffix": " und habe die Datenschutzerklärung gelesen.", + "termsLink": "Nutzungsbedingungen", + "pleaseAcceptTerms": "Bitte akzeptiere die Nutzungsbedingungen.", + "confirmEmailTitle": "E-Mail bestätigen", + "confirmEmailDesc": "Wir haben einen 6-stelligen Code an %{email} gesendet.", + "confirmEmailLine1": "Wir haben einen 6-stelligen Code an", + "confirmEmailLine2": "gesendet.", + "confirmBtn": "Bestätigen", + "confirmed": "Bestätigt! Du wirst weitergeleitet...", + "confirming": "Anmeldung wird bestätigt...", + "confirmSuccess": "Erfolgreich angemeldet!", + "confirmTimeout": "Zeitüberschreitung – bitte erneut versuchen.", + "confirmFailed": "Bestätigung fehlgeschlagen.", + "resend": "Erneut senden", + "resendCooldown": "Erneut senden (%{seconds}s)", + "noCode": "Keinen Code erhalten?", + "deviceLimitTitle": "Geräte-Limit erreicht", + "deviceLimitDesc": "Dein aktueller Plan erlaubt nicht mehr Geräte. Gib ein anderes Gerät frei oder upgrade deinen Plan, um auf diesem Gerät weiterzumachen.", + "deviceLimitUpgrade": "Plan upgraden", + "toLogin": "Zur Anmeldung", + "oauthFailed": "Anmeldung fehlgeschlagen", + "loginFailed": "Anmeldung fehlgeschlagen", + "registerFailed": "Registrierung fehlgeschlagen" + }, + "landing": { + "appName": "Rebreak", + "tagline": "Du gehst nicht allein.", + "start": "Loslegen", + "version": "v0.1.0 — RN Migration Phase 1 Skeleton" + }, + "splash": { + "tagline": "You will never walk alone!", + "subtitle": "Zusammen schaffen wir das.", + "madeInGermany": "Made in Germany" + }, + "appHeader": { + "appName": "ReBreak", + "sosLabel": "SOS — Atemübung", + "sosSubtitle": "Sofort-Hilfe bei Druck", + "editProfile": "Profil bearbeiten", + "settings": "Einstellungen", + "signOut": "Abmelden" + }, + "tabs": { + "home": "Home", + "chat": "Chat", + "coach": "Coach", + "blocker": "Blocker", + "mail": "Mail" + }, + "home": { + "tagline": "Du gehst nicht allein.", + "start": "Loslegen", + "greeting_morning": "Guten Morgen", + "greeting_day": "Guten Tag", + "greeting_evening": "Guten Abend", + "streak_days_one": "Tag clean", + "streak_days_other": "Tage clean", + "streak_start": "Starte deinen ersten Tag", + "quote_of_day": "Gedanke des Tages", + "quick_access": "Schnellzugriff", + "stats_urges": "Impulse", + "stats_chats": "Gespräche", + "stats_mails": "Mails blockiert" + }, + "coach": { + "title": "Lyra", + "subtitle": "Dein CBT-Coach", + "welcome": "Hallo! Ich bin Lyra, dein persönlicher Coach. Wie geht es dir heute? Ich bin hier, um dir zuzuhören und zu helfen.", + "input_placeholder": "Schreib mir...", + "new_chat": "Neues Gespräch", + "lyra": "Lyra", + "placeholder": "Was beschäftigt dich?", + "speaking": "Lyra spricht...", + "recording": "Aufnahme läuft...", + "transcribing": "Wird verarbeitet...", + "feedback_saved": "Feedback gespeichert", + "welcome_back": "Willkommen zurück", + "online": "online", + "thinking": "schreibt …", + "error": "Etwas ist schiefgelaufen. Bitte versuche es erneut." + }, + "blocker": { + "title": "Blocker", + "subtitle": "208.000+ Domains blockiert", + "status_active": "Aktiv", + "status_inactive": "Inaktiv", + "filter_label": "Gambling-Filter", + "filter_active_desc": "Alle Gambling-Seiten werden blockiert", + "filter_inactive_desc": "Filter ist deaktiviert", + "tamper_title": "Manipulationsschutz", + "tamper_desc": "Der Filter ist gegen einfaches Deaktivieren gesichert. Eine Entsperrung erfordert eine 6-Stunden-Abkühlphase.", + "custom_domains": "Eigene Domains", + "add_domain": "Hinzufügen", + "help_link": "Hilfe & FAQ zum Blocker", + "status_approved": "Genehmigt", + "status_rejected": "Abgelehnt", + "status_pending": "Ausstehend", + + "add_sheet_title": "Domain blockieren", + "add_sheet_label": "Domain", + "add_sheet_placeholder": "z.B. bet365.com", + "add_sheet_invalid": "Bitte gültige Domain eingeben (z.B. example.com)", + "add_sheet_warning_free": "Diese Domain bleibt dauerhaft auf deiner Liste — du kannst sie später nicht entfernen.", + "add_sheet_warning_pro": "Diese Domain ist permanent. Du kannst sie zur globalen Blocklist freigeben — dann wird der Slot frei und sie schützt alle ReBreak-User.", + "add_sheet_confirm_permanent": "Ich verstehe dass diese Domain permanent ist.", + "add_sheet_add_failed": "Hinzufügen fehlgeschlagen.", + "add_sheet_already_global": "%{domain} steht bereits in der globalen Sperrliste — kein Slot nötig.", + + "cooldown_banner_title": "Cooldown läuft", + + "deactivation_actionsheet_title": "24-Stunden-Cooldown starten?", + "deactivation_actionsheet_message": "Schutz bleibt während dieser Zeit aktiv. Du kannst jederzeit abbrechen.", + "deactivation_start_cta": "Cooldown starten", + "deactivation_failed_msg": "Cooldown konnte nicht gestartet werden.", + "deactivation_heading": "Bevor du deaktivierst", + "deactivation_title": "Wir verstehen das.", + "deactivation_intro": "Bevor du den Schutz abschaltest, hier was du wissen solltest:", + "deactivation_bullet1_title": "24 Stunden Cooldown", + "deactivation_bullet1_text": "Der Schutz bleibt 24h aktiv, selbst wenn du den Cooldown startest. Diese Zeit gibt dir Raum den Drang abklingen zu lassen.", + "deactivation_bullet2_title": "Du kannst jederzeit abbrechen", + "deactivation_bullet2_text": "Wenn der Drang nachlässt: ein Tap und der Cooldown ist weg. Der Schutz bleibt einfach an.", + "deactivation_bullet3_title": "Andere Werkzeuge sind da", + "deactivation_bullet3_text": "Atemübung, Lyra, deine Streak — alles bleibt verfügbar während du wartest.", + "deactivation_breathe_cta": "Jetzt 3 min atmen", + "deactivation_start_anyway": "Cooldown trotzdem starten", + "deactivation_starting": "Cooldown wird gestartet…", + "deactivation_cancel_failed": "Cooldown konnte nicht abgebrochen werden.", + + "domain_section_title": "Eigene Domains", + "domain_add_a11y": "Domain hinzufügen", + "domain_limit_title": "Limit erreicht", + "domain_limit_desc": "Pro: 208k+ Domains, Refill bei Freigabe — tippe für Details", + "domain_empty": "Noch keine eigenen Domains.\nTippe + um eine hinzuzufügen.", + "domain_badge_voting": "Voting", + "domain_badge_pruefung": "Prüfung", + "domain_badge_rejected": "Abgelehnt", + "domain_badge_active": "Aktiv", + "domain_btn_freigeben": "Freigeben", + "domain_btn_erneut": "Erneut", + "domain_btn_in_abstimmung": "In Abstimmung", + "domain_btn_rebreak_prueft": "ReBreak prüft", + "domain_confirm_legend_resubmit": "Erneut an ReBreak senden?", + "domain_confirm_legend_first": "Domain an ReBreak senden?", + "domain_confirm_community_resubmit": "Erneut zur Abstimmung freigeben?", + "domain_confirm_community_first": "Domain zur Abstimmung freigeben?", + "domain_confirm_legend_message": "%{domain} wird direkt an das ReBreak-Team weitergeleitet und manuell geprüft.", + "domain_confirm_community_message": "%{domain} wird zur Community-Abstimmung freigegeben (Yes/No-Voting).", + "domain_success_legend_title": "Domain eingereicht", + "domain_success_community_title": "Domain in Abstimmung", + "domain_success_legend_message": "Das ReBreak-Team prüft die Domain manuell. Du bekommst eine Benachrichtigung beim Ergebnis.", + "domain_success_community_message": "Die Community kann jetzt abstimmen. Du wirst beim Ergebnis benachrichtigt.", + + "upgrade_alert_title": "Pro-Upgrade", + "upgrade_alert_desc": "Stripe-Checkout kommt in Step 11.", + + "protection_card_title": "ReBreak-Schutz", + "protection_card_locked_title": "ReBreak-Schutz aktiv", + "protection_subtitle_inactive": "Tippe um den Schutz zu aktivieren", + "protection_subtitle_cooldown": "Cooldown läuft — Schutz weiter aktiv", + "protection_subtitle_free": "Filter aktiv — %{count} eigene Domains", + "protection_subtitle_legend": "Geschützt vor 208.000+ Domains + bis zu 10 eigenen", + "protection_subtitle_pro": "Geschützt vor 208.000+ Domains + 5 eigenen", + "protection_settings_a11y": "Schutz-Einstellungen", + "protection_stat_domains": "Domains", + "protection_stat_method": "Methode", + "protection_stat_method_dns": "DNS", + "protection_stat_method_native": "Native", + "protection_stat_status": "Status", + "protection_stat_status_live": "Live", + + "activate_url_failed_title": "URL-Filter konnte nicht aktiviert werden", + "activate_url_failed_msg": "Unbekannter Fehler.\nDu kannst es nochmal versuchen oder System-Einstellungen prüfen.", + "activate_settings_btn": "Einstellungen", + "activate_app_lock_failed_title": "App-Lock konnte nicht aktiviert werden", + "activate_app_lock_failed_msg": "Bildschirmzeit-Berechtigung wurde verweigert. Du kannst es nochmal versuchen.", + "sync_list_failed_title": "Filter-Liste konnte nicht geladen werden", + "sync_list_failed_msg": "Bitte später nochmal versuchen.", + "activation_failed_title": "Aktivierung fehlgeschlagen", + + "details_done": "Fertig", + "details_title": "Schutz-Details", + "details_active_title": "Schutz aktiv", + "details_domains_blocked": "%{value} Domains blockiert", + "details_layers_heading": "Aktive Layer", + "details_layer_url_label": "Network-Filter", + "details_layer_url_desc": "Blockt Gambling-Domains system-weit (NEFilter Extension)", + "details_layer_applock_label": "App-Lock", + "details_layer_applock_desc": "ReBreak kann nicht impulsiv gelöscht werden", + "details_layer_vpn_label": "VPN-Filter", + "details_layer_vpn_desc": "Lokaler DNS-Filter via VpnService", + "details_layer_a11y_label": "Browser-Filter", + "details_layer_a11y_desc": "Erkennt URL-Eingaben in Browser-Apps", + "details_layer_tamper_label": "Tamper-Lock", + "details_layer_tamper_desc": "Watchdog gegen externes Deaktivieren", + "details_lyra_cta_title": "Brauchst du den Schutz nicht mehr?", + "details_lyra_cta_subtitle": "Sprich mit Lyra darüber — sie hört zu.", + "details_deactivate_link": "Ich will trotzdem deaktivieren", + + "layers_url_filter_title": "URL-Filter", + "layers_url_filter_subtitle_active": "System-weiter Filter aktiv", + "layers_url_filter_subtitle_inactive": "Blockt Gambling-Seiten in Safari + Apps", + "layers_app_lock_title": "App-Lock", + "layers_app_lock_subtitle_active": "Familienzugriff aktiv", + "layers_app_lock_subtitle_inactive": "Verhindert dass du ReBreak im Impuls löschst", + "layers_app_lock_warning": "Sobald aktiv kannst du den Schutz nur über einen 24-Stunden-Cooldown abschalten. Das ist gewollt.", + + "kpi_global_label": "Geblockte Domains weltweit", + "kpi_global_subtitle": "Aktive Einträge in der globalen Blockliste", + "delta_week": "diese Woche", + "delta_month": "diesen Monat", + "kpi_submissions_title": "Deine eingereichten Domains", + "kpi_submissions_subtitle": "Status deiner Beiträge zur globalen Liste", + "kpi_my_submissions": "insgesamt", + "kpi_status_active": "aktiv", + "kpi_status_vote": "im Vote", + "kpi_status_review": "in Prüfung", + "kpi_in_vote": "Im Vote", + "kpi_in_review": "In Prüfung", + "kpi_avg_per_user": "Ø Domains pro User", + "kpi_avg_wait": "Ø Wartezeit", + "kpi_days_suffix": "Tage", + + "faq_heading": "Häufige Fragen", + "faq1_q": "Wie funktioniert der Schutz?", + "faq1_a": "Der Schutz läuft direkt im iOS-System als Inhaltsfilter. Glücksspielseiten werden lokal auf deinem Gerät blockiert — kein Datenverkehr verlässt dein iPhone.", + "faq2_q": "Wie viele Seiten werden blockiert?", + "faq2_a": "Über 208.000 Domains aus einer kuratierten globalen Blockliste — Online-Casinos, Sportwetten, Glücksspiel-Plattformen und verwandte Seiten. Die Liste wird regelmäßig aktualisiert.", + "faq3_q": "Kann ich eigene Domains hinzufügen?", + "faq3_a": "Ja. Über die Domain-Liste auf der Blocker-Seite kannst du eigene Domains hinzufügen, die zusätzlich zur globalen Liste blockiert werden.", + "faq4_q": "Warum kann ich den Schutz nicht sofort abschalten?", + "faq4_a": "Wenn du im Drang bist, willst du oft schnell deaktivieren — und es danach bereuen. Der 24-Stunden-Cooldown gibt dir Zeit, den Drang abklingen zu lassen. Du kannst den Cooldown jederzeit abbrechen — der Schutz bleibt dann einfach an.", + + "more_info_title": "Wie funktioniert der Cooldown?" + }, + "mail": { + "title": "Mail-Schutz", + "subtitle": "Gambling-Mails automatisch blockieren", + "plan_free": "Free", + "stat_accounts": "Postfach", + "stat_domains": "Domains", + "stat_interval": "Scan-Intervall", + "connect_title": "Verbinde dein Postfach", + "connect_desc": "Rebreak scannt automatisch nach Gambling-Mails und blockiert sie — ohne deine E-Mails zu lesen.", + "connect_cta": "Jetzt verbinden", + "privacy_1": "Nur Betreff + Absender werden geprüft", + "privacy_2": "Kein Zugriff auf Mail-Inhalte", + "privacy_3": "DSGVO-konform, Server in DE", + "providers_title": "Unterstützte Anbieter", + "provider_other": "Andere", + "empty_title": "Noch keine Mails blockiert", + "empty_subtitle": "Verbinde dein Postfach, damit Rebreak automatisch schützt.", + + "connect_sheet_title": "Postfach verbinden", + "connect_sheet_subtitle": "Wähle deinen E-Mail-Anbieter. Rebreak löscht Gambling-Mails automatisch — Inhalte werden nie gelesen.", + + "provider_gmail": "Gmail", + "provider_icloud": "iCloud Mail", + "provider_outlook": "Outlook", + "provider_yahoo": "Yahoo Mail", + "provider_gmx": "GMX / Web.de", + + "app_password_required_title": "App-Passwort erforderlich", + "app_password_guide_gmail": "Gmail erfordert ein App-spezifisches Passwort (kein normales Google-Passwort). Aktiviere 2FA und erstelle ein App-Passwort unter myaccount.google.com/apppasswords.", + "app_password_guide_icloud": "iCloud erfordert ein App-spezifisches Passwort. Gehe zu appleid.apple.com → Anmelden → App-spezifische Passwörter.", + "app_password_guide_outlook": "Outlook mit Microsoft-Konto: Aktiviere 2FA und erstelle ein App-Passwort unter account.microsoft.com/security.", + "app_password_guide_yahoo": "Yahoo erfordert ein App-Passwort. Aktiviere 2FA und erstelle es unter login.yahoo.com/account/security.", + "app_password_guide_gmx": "GMX / Web.de: Aktiviere IMAP in den Einstellungen und verwende dein normales Passwort oder ein App-Passwort falls 2FA aktiv.", + "app_password_guide_other": "Gib die IMAP-Zugangsdaten deines E-Mail-Anbieters ein. App-Passwort empfohlen wenn vorhanden.", + "app_password_open_link": "Jetzt App-Passwort erstellen", + + "form_email_label": "E-Mail-Adresse", + "form_email_placeholder": "deine@email.de", + "form_password_label": "App-Passwort", + "form_password_placeholder": "App-Passwort (nicht dein Login-Passwort)", + "form_privacy_note": "Dein Passwort wird AES-verschlüsselt gespeichert. Inhalte deiner Mails werden nie gelesen — nur Betreff und Absender.", + "form_connect_btn": "Postfach verbinden", + "form_fields_required": "E-Mail und Passwort sind erforderlich.", + "connect_failed": "Verbindung fehlgeschlagen. Prüfe deine Zugangsdaten.", + + "section_accounts": "Postfächer", + "add_account_a11y": "Postfach hinzufügen", + + "empty_state_title": "Kein Postfach verbunden", + "empty_state_subtitle": "Verbinde dein erstes Postfach — Rebreak löscht Gambling-Mails automatisch, bevor du sie siehst.", + "empty_state_cta": "Erstes Postfach verbinden", + + "account_active": "Aktiv", + "account_inactive": "Inaktiv", + "account_last_scan": "Zuletzt vor %{time}", + "account_never_scanned": "Noch nicht gescannt", + "account_just_now": "gerade eben", + "account_stat_blocked": "Blockiert", + "account_stat_scanned": "Gescannt", + "account_stat_block_rate": "Block-Rate", + "account_disconnect_confirm_title": "Postfach trennen?", + "account_disconnect_confirm_message": "%{email} wird getrennt und alle Scan-Daten werden gelöscht.", + "account_disconnect_confirm_btn": "Trennen", + + "stats_blocked": "Blockiert", + "stats_accounts": "Postfächer", + "stats_next_scan": "Nächster Scan", + "stats_next_scan_soon": "gleich", + "stats_mode": "Modus", + "stats_account_summary": "über %{count} Postfach/Postfächer", + "scheduled": "Geplant", + "account_of_scanned": "von %{scanned} gescannt", + "activity_log_count": "%{count} Mail(s) blockiert", + + "connect_success_title": "Postfach verbunden", + "connect_success_message": "Rebreak scannt ab jetzt automatisch nach Gambling-Mails.", + + "add_account": "Postfach hinzufügen", + "section_accounts_count": "%{used} von %{max} verbunden", + "section_accounts_count_unlimited": "%{used} verbunden · unbegrenzt", + "live": "Live", + "disconnect": "Trennen", + "loading": "Lädt…", + "app_password_placeholder": "App-Passwort", + + "scan_interval_label": "Scan-Intervall", + "realtime_desc": "Echtzeit-Blockierung via IMAP IDLE", + "free_scan_interval_hint": "Free-Plan: fest 4h. Upgrade für 1h.", + + "account_change_password": "Passwort ändern", + "edit_account_title": "Passwort aktualisieren", + "edit_account_subtitle": "Gib das neue App-Passwort für %{email} ein. Das alte Passwort wird ersetzt.", + "edit_account_save": "Speichern", + + "activity_log_title": "Kürzlich blockiert", + "activity_log_subtitle": "In den letzten 24h blockierte Mails", + "activity_log_empty": "Keine Mails in den letzten 24h blockiert", + "activity_log_more": "+ %{count} weitere", + "activity_no_subject": "(kein Betreff)", + + "upgrade_alert_title": "Mehr Postfächer", + "upgrade_alert_desc": "Upgrade auf Pro für bis zu 3 Postfächer, auf Legend für unbegrenzte Postfächer." + }, + "settings": { + "title": "Einstellungen", + "account_section": "Konto", + "prefs_section": "Einstellungen", + "danger_section": "Danger Zone", + "edit_profile": "Profil bearbeiten", + "devices": "Geräte", + "devices_desc": "Registrierte Geräte verwalten", + "subscription": "Abonnement", + "plan_free": "Free", + "push_notifications": "Push-Benachrichtigungen", + "streak_reminders": "Streak-Erinnerungen", + "language": "Sprache", + "language_current": "Deutsch", + "upgrade_cta": "Auf Pro upgraden — 29 €/Jahr", + "delete_account": "Konto löschen", + "delete_desc": "Alle Daten werden unwiderruflich gelöscht.", + "sign_out": "Abmelden" + }, + "urge": { + "title": "SOS — Atemübung", + "step_dashboard": "Start", + "step_emotion": "Emotion", + "step_breathing": "Atmung", + "step_games": "Lyra Games", + "step_result": "Reflexion", + "step_done": "Fertig", + "feel_urge": "Spürst du gerade einen starken Impuls?", + "feel_urge_desc": "Wir führen dich in kleinen Schritten durch einen sicheren Reset.", + "yes_urge": "Ja, ich brauche Hilfe", + "just_play": "Nur kurz spielen", + "this_week": "Diese Woche", + "total_urges": "Impulse", + "overcome_count": "Überwunden", + "breathing_exercises": "Atemübungen", + "having_urge": "Du bist nicht allein.", + "how_feeling": "Wie fühlst du dich gerade?", + "emotion_stress": "Stress", + "emotion_sadness": "Trauer", + "emotion_anger": "Wut", + "emotion_empty": "Leere", + "emotion_boredom": "Langeweile", + "emotion_other": "Anderes", + "lets_breathe": "Lass uns kurz atmen", + "breathing_desc": "Nur 3 Runden. Danach ist dein Kopf meist deutlich ruhiger.", + "round": "Runde %{current} / %{total}", + "round_simple": "Runde %{current} / %{total}", + "intro": "Tief durchatmen hilft, den Impuls zu überwältigen.", + "inhale": "Einatmen", + "hold": "Halten", + "exhale": "Ausatmen", + "start": "Übung starten", + "start_exercise": "Atemübung starten", + "skip": "Überspringen", + "game_offer_title": "Lyra Games", + "game_offer_text": "Wähle ein kurzes Spiel. 2-3 Minuten reichen oft, um den Impuls zu brechen.", + "just_play_lyra": "Kleiner Fokus-Reset gefällig? Such dir ein Spiel aus.", + "game_memory": "Memory", + "game_tictactoe": "Tic-Tac-Toe", + "game_snake": "Snake", + "game_tetris": "Tetris", + "game_memory_desc": "Paare finden, Fokus zurückholen", + "game_tictactoe_desc": "Schnelles Duell für klare Entscheidungen", + "game_snake_desc": "Rhythmus statt Grübeln", + "game_tetris_desc": "Muster ordnen, Kopf beruhigen", + "skip_games": "Spiele überspringen", + "back": "Zurück", + "open_lyra": "Mit Lyra öffnen", + "game_start_title": "Spiel starten", + "game_start_desc": "%{game} wird mit Lyra gestartet.", + "how_overcome": "Wie ging es danach?", + "answer_helps": "Deine Antwort hilft dir, Muster zu erkennen und stärker zu werden.", + "i_overcame": "Ich habe den Impuls überwunden", + "i_gave_in": "Ich habe nachgegeben", + "overcame_msg": "Stark. Jeder überwundene Impuls trainiert dein Gehirn neu.", + "gave_in_msg": "Kein Urteil. Ehrlichkeit ist der Startpunkt für den nächsten Sieg.", + "save": "Speichern", + "done_title": "Sehr gut!", + "done_desc": "Du hast die Atemübung abgeschlossen. Dein Nervensystem hat sich beruhigt.", + "done_back": "Zurück", + "well_done": "Stark gemacht", + "chin_up": "Kopf hoch", + "overcame_result": "Du hast den Impuls durchbrochen. Bleib bei dem, was dir gut tut.", + "gave_in_result": "Ein Rückschritt ist kein Ende. Atme durch und starte neu.", + "back_to_dashboard": "Zurück zum Dashboard" + }, + "notifications": { + "title": "Benachrichtigungen", + "empty_title": "Keine Benachrichtigungen", + "empty_subtitle": "Du bist auf dem neuesten Stand.", + "mark_all_read": "Alle als gelesen markieren", + "liked_post": "hat deinen Beitrag geliked", + "commented_post": "hat deinen Beitrag kommentiert", + "voted_domain": "hat über deine Domain abgestimmt", + "domain_accepted": "ist jetzt in der globalen Sperrliste", + "domain_accepted_sub": "Tippe um deine Sperrliste zu öffnen", + "domain_rejected": "wurde abgelehnt und aus deiner Liste entfernt", + "new_follower": "folgt dir jetzt", + "generic": "hat dich benachrichtigt", + "just_now": "gerade eben", + "min_ago": "vor %{n} Min", + "hours_ago": "vor %{n} Std", + "days_ago": "vor %{n} T" + }, + "chat": { + "title": "Chat", + "dms": "Direktnachrichten", + "rooms": "Gruppen", + "groups": "Gruppen", + "direct": "Direkt", + "no_chats": "Noch keine Chats", + "no_rooms": "Noch keine Gruppen", + "start_dm": "Neuen DM starten", + "placeholder": "Nachricht schreiben…", + "you": "Du: ", + "just_now": "gerade", + "loading": "Laden…", + "send_failed": "Nachricht konnte nicht gesendet werden.", + "create_group": "Gruppe erstellen", + "create": "Erstellen", + "room_name": "Gruppenname", + "room_description": "Beschreibung (optional)", + "public_room": "Öffentliche Gruppe", + "join_mode": "Beitrittsmodus", + "join_mode_approval": "Mit Freigabe", + "join_mode_invite": "Nur Einladung", + "join": "Beitreten", + "join_pending": "Beitritt wird geprüft…", + "join_required": "Tritt der Gruppe bei, um mitzuschreiben.", + "members": "Mitglieder", + "settings": "Einstellungen", + "info": "Info", + "leave_room": "Gruppe verlassen", + "reply": "Antworten", + "reply_to": "Antwort an", + "like": "Liken", + "unlike": "Like entfernen", + "copy": "Kopieren", + "image_attachment": "Bild", + "file_attachment": "Datei", + "upload_failed": "Upload fehlgeschlagen", + "member_count": "%{n} Mitglieder", + "pending_request": "Beitrittsanfragen", + "approve": "Annehmen", + "reject": "Ablehnen", + "avatar_updated": "Gruppenbild aktualisiert", + "send": "Senden" + }, + "community": { + "compose_placeholder": "Was bewegt dich gerade?", + "compose_default_user": "Du", + "compose_photo_perm_title": "Foto-Zugriff", + "compose_photo_perm_desc": "Bitte erlaube den Zugriff auf deine Fotos in den iOS-Einstellungen.", + "image": "Bild", + "cancel": "Abbrechen", + "share": "Teilen", + "no_posts": "Sei der Erste der was teilt", + "cat_all": "Alle", + "cat_games": "Games", + "cat_domain": "Domain-Votes", + "cat_lyra": "Lyra", + "cat_rebreak": "ReBreak", + "like": "Gefällt mir", + "comment": "Kommentar", + "comments_title": "Kommentare", + "comments_empty": "Noch keine Kommentare – sei der Erste!", + "reply": "Antworten", + "reply_to": "Antwort an", + "send": "Senden", + "comment_placeholder": "Kommentar schreiben…", + "filter": "Filter", + "published": "Veröffentlicht", + "post_failed": "Post konnte nicht veröffentlicht werden.", + "anonymous_label": "Anonym", + "tier_starter": "Starter", + "tier_pro": "Pro", + "tier_legend": "Legend", + "bot_admin": "Admin", + "bot_ai": "KI", + "reposted_suffix": "hat repostet", + "domain_proposal_label": "Sperrlisten-Vorschlag", + "domain_added_to_blocklist": "Zur globalen Sperrliste hinzugefügt", + "domain_added": "In der globalen Sperrliste", + "domain_proposed": "Zur Aufnahme vorgeschlagen", + "domain_vote_own": "Du kannst nicht über deinen eigenen Vorschlag abstimmen.", + "vote_yes": "Ja", + "vote_no": "Nein", + "vote_rejected": "Abgelehnt", + "vote_in_review": "In Prüfung", + "voted_thanks": "Danke für deine Stimme!" + }, + "streak": { + "label_one": "Tag", + "label_other": "Tage", + "label_suffix": "clean" + } +} diff --git a/apps/rebreak-native/locales/en.json b/apps/rebreak-native/locales/en.json new file mode 100644 index 0000000..ba1c229 --- /dev/null +++ b/apps/rebreak-native/locales/en.json @@ -0,0 +1,591 @@ +{ + "common": { + "loading": "One moment...", + "cancel": "Cancel", + "continue": "Continue", + "back": "Back", + "error": "Error", + "success": "Success", + "ok": "OK", + "confirm": "Confirm", + "retry": "Try again", + "unknown_error": "Unknown error" + }, + "auth": { + "welcomeBack": "Welcome back", + "signinSubtitle": "Sign in to continue.", + "signin": "Sign in", + "signingIn": "One moment...", + "signup": "Sign up", + "signupTitle": "Create account", + "signupSubtitle": "Join the community.", + "signOut": "Sign out", + "email": "Email", + "emailPlaceholder": "Email", + "emailRequired": "Email *", + "password": "Password", + "passwordPlaceholder": "Password", + "passwordRequired": "Password * (min. 8 characters)", + "passwordMin8": "Password must be at least 8 characters.", + "newPassword": "New password", + "firstName": "First name", + "lastName": "Last name", + "nickname": "Username", + "nicknamePlaceholder": "Username * (visible to others)", + "noAccount": "No account yet?", + "alreadyRegistered": "Already registered?", + "fillRequired": "Please fill in all required fields.", + "googleSignin": "Sign in with Google", + "appleSignin": "Sign in with Apple", + "googleSignup": "Sign up with Google", + "appleSignup": "Sign up with Apple", + "orWithEmail": "or with email", + "forgotPassword": "Forgot password?", + "resetPasswordTitle": "Reset password", + "resetPasswordSubtitle": "Enter your email and we'll send you a reset link.", + "resetPasswordSend": "Send link", + "resetPasswordSent": "Email sent", + "resetPasswordSentDesc": "Check your inbox. The link is valid for 60 minutes.", + "resetPasswordSentDescPrefix": "Check your inbox for ", + "resetPasswordSentDescSuffix": ". The link is valid for 60 minutes.", + "backToLogin": "← Back to sign in", + "backToLoginPlain": "Back to sign in", + "backToSignup": "← Back to sign up", + "chooseAvatar": "Choose avatar", + "privacyNotice": "Your data is stored securely on servers in Germany. We never sell data to third parties.", + "acceptTerms": "I accept the", + "acceptTermsSuffix": " and have read the privacy policy.", + "termsLink": "Terms of Service", + "pleaseAcceptTerms": "Please accept the Terms of Service.", + "confirmEmailTitle": "Confirm email", + "confirmEmailDesc": "We sent a 6-digit code to %{email}.", + "confirmEmailLine1": "We sent a 6-digit code to", + "confirmEmailLine2": "", + "confirmBtn": "Confirm", + "confirmed": "Confirmed! Redirecting...", + "confirming": "Confirming sign-in...", + "confirmSuccess": "Successfully signed in!", + "confirmTimeout": "Timed out – please try again.", + "confirmFailed": "Confirmation failed.", + "resend": "Resend", + "resendCooldown": "Resend (%{seconds}s)", + "noCode": "Didn't receive a code?", + "deviceLimitTitle": "Device limit reached", + "deviceLimitDesc": "Your current plan doesn't allow more devices. Free up another device or upgrade your plan to continue on this device.", + "deviceLimitUpgrade": "Upgrade plan", + "toLogin": "Back to sign in", + "oauthFailed": "Sign in failed", + "loginFailed": "Sign in failed", + "registerFailed": "Registration failed" + }, + "landing": { + "appName": "Rebreak", + "tagline": "You're not walking alone.", + "start": "Get started", + "version": "v0.1.0 — RN Migration Phase 1 Skeleton" + }, + "splash": { + "tagline": "You will never walk alone!", + "subtitle": "Together we'll make it.", + "madeInGermany": "Made in Germany" + }, + "appHeader": { + "appName": "ReBreak", + "sosLabel": "SOS — Breathing exercise", + "sosSubtitle": "Instant help under pressure", + "editProfile": "Edit profile", + "settings": "Settings", + "signOut": "Sign out" + }, + "tabs": { + "home": "Home", + "chat": "Chat", + "coach": "Coach", + "blocker": "Blocker", + "mail": "Mail" + }, + "home": { + "tagline": "You're not walking alone.", + "start": "Get started", + "greeting_morning": "Good morning", + "greeting_day": "Good afternoon", + "greeting_evening": "Good evening", + "streak_days_one": "day clean", + "streak_days_other": "days clean", + "streak_start": "Start your first day", + "quote_of_day": "Thought of the day", + "quick_access": "Quick access", + "stats_urges": "Urges", + "stats_chats": "Chats", + "stats_mails": "Mails blocked" + }, + "coach": { + "title": "Lyra", + "subtitle": "Your CBT coach", + "welcome": "Hi! I'm Lyra, your personal coach. How are you doing today? I'm here to listen and help.", + "input_placeholder": "Write to me...", + "new_chat": "New chat", + "lyra": "Lyra", + "placeholder": "What's on your mind?", + "speaking": "Lyra is speaking...", + "recording": "Recording...", + "transcribing": "Processing...", + "feedback_saved": "Feedback saved", + "welcome_back": "Welcome back", + "online": "online", + "thinking": "typing …", + "error": "Something went wrong. Please try again." + }, + "blocker": { + "title": "Blocker", + "subtitle": "208,000+ domains blocked", + "status_active": "Active", + "status_inactive": "Inactive", + "filter_label": "Gambling Filter", + "filter_active_desc": "All gambling sites are being blocked", + "filter_inactive_desc": "Filter is disabled", + "tamper_title": "Tamper protection", + "tamper_desc": "The filter is secured against easy disabling. Unlocking requires a 6-hour cooldown period.", + "custom_domains": "Custom Domains", + "add_domain": "Add", + "help_link": "Help & FAQ about Blocker", + "status_approved": "Approved", + "status_rejected": "Rejected", + "status_pending": "Pending", + + "add_sheet_title": "Block domain", + "add_sheet_label": "Domain", + "add_sheet_placeholder": "e.g. bet365.com", + "add_sheet_invalid": "Please enter a valid domain (e.g. example.com)", + "add_sheet_warning_free": "This domain stays on your list permanently — you cannot remove it later.", + "add_sheet_warning_pro": "This domain is permanent. You can release it to the global blocklist — the slot becomes free again and it will protect every ReBreak user.", + "add_sheet_confirm_permanent": "I understand this domain is permanent.", + "add_sheet_add_failed": "Failed to add domain.", + "add_sheet_already_global": "%{domain} is already on the global blocklist — no slot needed.", + + "cooldown_banner_title": "Cooldown running", + + "deactivation_actionsheet_title": "Start 24-hour cooldown?", + "deactivation_actionsheet_message": "Protection stays active during this time. You can cancel anytime.", + "deactivation_start_cta": "Start cooldown", + "deactivation_failed_msg": "Could not start cooldown.", + "deactivation_heading": "Before you deactivate", + "deactivation_title": "We get it.", + "deactivation_intro": "Before you turn off protection, here's what you should know:", + "deactivation_bullet1_title": "24-hour cooldown", + "deactivation_bullet1_text": "Protection stays active for 24 hours even after you start the cooldown. This time gives you space to let the urge pass.", + "deactivation_bullet2_title": "You can cancel anytime", + "deactivation_bullet2_text": "If the urge fades: one tap and the cooldown is gone. Protection just stays on.", + "deactivation_bullet3_title": "Other tools are here", + "deactivation_bullet3_text": "Breathing exercise, Lyra, your streak — everything stays available while you wait.", + "deactivation_breathe_cta": "Breathe for 3 min", + "deactivation_start_anyway": "Start cooldown anyway", + "deactivation_starting": "Starting cooldown…", + "deactivation_cancel_failed": "Could not cancel cooldown.", + + "domain_section_title": "Custom domains", + "domain_add_a11y": "Add domain", + "domain_limit_title": "Limit reached", + "domain_limit_desc": "Pro: 208k+ domains, refill on release — tap for details", + "domain_empty": "No custom domains yet.\nTap + to add one.", + "domain_badge_voting": "Voting", + "domain_badge_pruefung": "Review", + "domain_badge_rejected": "Rejected", + "domain_badge_active": "Active", + "domain_btn_freigeben": "Release", + "domain_btn_erneut": "Retry", + "domain_btn_in_abstimmung": "In voting", + "domain_btn_rebreak_prueft": "ReBreak reviewing", + "domain_confirm_legend_resubmit": "Resubmit to ReBreak?", + "domain_confirm_legend_first": "Send domain to ReBreak?", + "domain_confirm_community_resubmit": "Resubmit to community vote?", + "domain_confirm_community_first": "Release domain to community vote?", + "domain_confirm_legend_message": "%{domain} will be sent directly to the ReBreak team for manual review.", + "domain_confirm_community_message": "%{domain} will be released to the community vote (yes/no voting).", + "domain_success_legend_title": "Domain submitted", + "domain_success_community_title": "Domain in voting", + "domain_success_legend_message": "The ReBreak team is reviewing this domain manually. You'll get a notification with the result.", + "domain_success_community_message": "The community can now vote. You'll be notified once the result is in.", + + "upgrade_alert_title": "Pro upgrade", + "upgrade_alert_desc": "Stripe checkout is coming in step 11.", + + "protection_card_title": "ReBreak protection", + "protection_card_locked_title": "ReBreak protection active", + "protection_subtitle_inactive": "Tap to activate protection", + "protection_subtitle_cooldown": "Cooldown running — protection still active", + "protection_subtitle_free": "Filter active — %{count} custom domains", + "protection_subtitle_legend": "Protected against 208,000+ domains + up to 10 custom", + "protection_subtitle_pro": "Protected against 208,000+ domains + 5 custom", + "protection_settings_a11y": "Protection settings", + "protection_stat_domains": "Domains", + "protection_stat_method": "Method", + "protection_stat_method_dns": "DNS", + "protection_stat_method_native": "Native", + "protection_stat_status": "Status", + "protection_stat_status_live": "Live", + + "activate_url_failed_title": "Could not activate URL filter", + "activate_url_failed_msg": "Unknown error.\nYou can try again or check System Settings.", + "activate_settings_btn": "Settings", + "activate_app_lock_failed_title": "Could not activate App Lock", + "activate_app_lock_failed_msg": "Screen Time permission was denied. You can try again.", + "sync_list_failed_title": "Filter list could not be loaded", + "sync_list_failed_msg": "Please try again later.", + "activation_failed_title": "Activation failed", + + "details_done": "Done", + "details_title": "Protection details", + "details_active_title": "Protection active", + "details_domains_blocked": "%{value} domains blocked", + "details_layers_heading": "Active layers", + "details_layer_url_label": "Network filter", + "details_layer_url_desc": "Blocks gambling domains system-wide (NEFilter Extension)", + "details_layer_applock_label": "App lock", + "details_layer_applock_desc": "ReBreak cannot be deleted impulsively", + "details_layer_vpn_label": "VPN filter", + "details_layer_vpn_desc": "Local DNS filter via VpnService", + "details_layer_a11y_label": "Browser filter", + "details_layer_a11y_desc": "Detects URL input in browser apps", + "details_layer_tamper_label": "Tamper lock", + "details_layer_tamper_desc": "Watchdog against external deactivation", + "details_lyra_cta_title": "Don't need protection anymore?", + "details_lyra_cta_subtitle": "Talk to Lyra about it — she's listening.", + "details_deactivate_link": "Deactivate anyway", + + "layers_url_filter_title": "URL filter", + "layers_url_filter_subtitle_active": "System-wide filter active", + "layers_url_filter_subtitle_inactive": "Blocks gambling sites in Safari + apps", + "layers_app_lock_title": "App lock", + "layers_app_lock_subtitle_active": "Family access active", + "layers_app_lock_subtitle_inactive": "Prevents you from deleting ReBreak on impulse", + "layers_app_lock_warning": "Once active, you can only disable protection through a 24-hour cooldown. That's by design.", + + "kpi_global_label": "Domains blocked worldwide", + "kpi_global_subtitle": "Active entries in the global blocklist", + "delta_week": "this week", + "delta_month": "this month", + "kpi_submissions_title": "Your submitted domains", + "kpi_submissions_subtitle": "Status of your contributions to the global list", + "kpi_my_submissions": "total", + "kpi_status_active": "active", + "kpi_status_vote": "in vote", + "kpi_status_review": "in review", + "kpi_in_vote": "In vote", + "kpi_in_review": "In review", + "kpi_avg_per_user": "Avg. domains per user", + "kpi_avg_wait": "Avg. wait", + "kpi_days_suffix": "days", + + "faq_heading": "FAQ", + "faq1_q": "How does protection work?", + "faq1_a": "Protection runs directly in iOS as a content filter. Gambling sites are blocked locally on your device — no traffic leaves your iPhone.", + "faq2_q": "How many sites are blocked?", + "faq2_a": "Over 208,000 domains from a curated global blocklist — online casinos, sports betting, gambling platforms and related sites. The list is updated regularly.", + "faq3_q": "Can I add my own domains?", + "faq3_a": "Yes. From the domain list on the blocker page you can add custom domains that get blocked in addition to the global list.", + "faq4_q": "Why can't I turn protection off immediately?", + "faq4_a": "In the moment of urge, you often want to disable fast — and regret it after. The 24-hour cooldown gives you time for the urge to pass. You can cancel the cooldown anytime — protection then simply stays on.", + + "more_info_title": "How does the cooldown work?" + }, + "mail": { + "title": "Mail Shield", + "subtitle": "Automatically block gambling emails", + "plan_free": "Free", + "stat_accounts": "Mailbox", + "stat_domains": "Domains", + "stat_interval": "Scan interval", + "connect_title": "Connect your mailbox", + "connect_desc": "Rebreak automatically scans for gambling emails and blocks them — without reading your emails.", + "connect_cta": "Connect now", + "privacy_1": "Only subject + sender are checked", + "privacy_2": "No access to email content", + "privacy_3": "GDPR-compliant, servers in Germany", + "providers_title": "Supported providers", + "provider_other": "Other", + "empty_title": "No emails blocked yet", + "empty_subtitle": "Connect your mailbox so Rebreak can protect you automatically.", + + "connect_sheet_title": "Connect mailbox", + "connect_sheet_subtitle": "Choose your email provider. Rebreak deletes gambling emails automatically — your message content is never read.", + + "provider_gmail": "Gmail", + "provider_icloud": "iCloud Mail", + "provider_outlook": "Outlook", + "provider_yahoo": "Yahoo Mail", + "provider_gmx": "GMX / Web.de", + + "app_password_required_title": "App password required", + "app_password_guide_gmail": "Gmail requires an app-specific password (not your regular Google password). Enable 2FA and create an app password at myaccount.google.com/apppasswords.", + "app_password_guide_icloud": "iCloud requires an app-specific password. Go to appleid.apple.com → Sign in → App-specific passwords.", + "app_password_guide_outlook": "Outlook with Microsoft account: Enable 2FA and create an app password at account.microsoft.com/security.", + "app_password_guide_yahoo": "Yahoo requires an app password. Enable 2FA and create it at login.yahoo.com/account/security.", + "app_password_guide_gmx": "GMX / Web.de: Enable IMAP in settings and use your regular password or an app password if 2FA is active.", + "app_password_guide_other": "Enter the IMAP credentials of your email provider. An app password is recommended if available.", + "app_password_open_link": "Create app password now", + + "form_email_label": "Email address", + "form_email_placeholder": "your@email.com", + "form_password_label": "App password", + "form_password_placeholder": "App password (not your login password)", + "form_privacy_note": "Your password is stored AES-encrypted. The content of your emails is never read — only subject and sender.", + "form_connect_btn": "Connect mailbox", + "form_fields_required": "Email and password are required.", + "connect_failed": "Connection failed. Please check your credentials.", + + "section_accounts": "Mailboxes", + "add_account_a11y": "Add mailbox", + + "empty_state_title": "No mailbox connected", + "empty_state_subtitle": "Connect your first mailbox — Rebreak will delete gambling emails automatically before you see them.", + "empty_state_cta": "Connect first mailbox", + + "account_active": "Active", + "account_inactive": "Inactive", + "account_last_scan": "%{time} ago", + "account_never_scanned": "Not scanned yet", + "account_just_now": "just now", + "account_stat_blocked": "Blocked", + "account_stat_scanned": "Scanned", + "account_stat_block_rate": "Block rate", + "account_disconnect_confirm_title": "Disconnect mailbox?", + "account_disconnect_confirm_message": "%{email} will be disconnected and all scan data will be deleted.", + "account_disconnect_confirm_btn": "Disconnect", + + "stats_blocked": "Blocked", + "stats_accounts": "Mailboxes", + "stats_next_scan": "Next scan", + "stats_next_scan_soon": "soon", + "stats_mode": "Mode", + "stats_account_summary": "across %{count} mailbox(es)", + "scheduled": "Scheduled", + "account_of_scanned": "of %{scanned} scanned", + "activity_log_count": "%{count} mail(s) blocked", + + "connect_success_title": "Mailbox connected", + "connect_success_message": "Rebreak will now automatically scan for gambling emails.", + + "upgrade_alert_title": "More mailboxes", + "upgrade_alert_desc": "Upgrade to Pro for up to 3 mailboxes, or Legend for unlimited.", + + "add_account": "Add mailbox", + "section_accounts_count": "%{used} of %{max} connected", + "section_accounts_count_unlimited": "%{used} connected · unlimited", + "live": "Live", + "disconnect": "Disconnect", + "loading": "Loading…", + "app_password_placeholder": "App password", + + "scan_interval_label": "Scan interval", + "realtime_desc": "Real-time blocking via IMAP IDLE", + "free_scan_interval_hint": "Free plan: fixed 4h interval. Upgrade for 1h.", + + "account_change_password": "Change password", + "edit_account_title": "Update password", + "edit_account_subtitle": "Enter the new app password for %{email}. The previous password will be replaced.", + "edit_account_save": "Save", + + "activity_log_title": "Recently blocked", + "activity_log_subtitle": "Mails blocked in the last 24h", + "activity_log_empty": "No mails blocked in the last 24h", + "activity_log_more": "+ %{count} more", + "activity_no_subject": "(no subject)" + }, + "settings": { + "title": "Settings", + "account_section": "Account", + "prefs_section": "Preferences", + "danger_section": "Danger Zone", + "edit_profile": "Edit profile", + "devices": "Devices", + "devices_desc": "Manage registered devices", + "subscription": "Subscription", + "plan_free": "Free", + "push_notifications": "Push notifications", + "streak_reminders": "Streak reminders", + "language": "Language", + "language_current": "English", + "upgrade_cta": "Upgrade to Pro — €29/year", + "delete_account": "Delete account", + "delete_desc": "All data will be permanently deleted.", + "sign_out": "Sign out" + }, + "urge": { + "title": "SOS — Breathing exercise", + "step_dashboard": "Start", + "step_emotion": "Emotion", + "step_breathing": "Breathing", + "step_games": "Lyra games", + "step_result": "Reflection", + "step_done": "Done", + "feel_urge": "Feeling a strong urge right now?", + "feel_urge_desc": "We'll guide you through a short reset, step by step.", + "yes_urge": "Yes, I need help", + "just_play": "Just play", + "this_week": "This week", + "total_urges": "Urges", + "overcome_count": "Overcome", + "breathing_exercises": "Breathing sessions", + "having_urge": "You're not alone.", + "how_feeling": "How are you feeling right now?", + "emotion_stress": "Stress", + "emotion_sadness": "Sadness", + "emotion_anger": "Anger", + "emotion_empty": "Emptiness", + "emotion_boredom": "Boredom", + "emotion_other": "Other", + "lets_breathe": "Let's breathe for a minute", + "breathing_desc": "Just 3 rounds. Your mind usually feels calmer afterwards.", + "round": "Round %{current} / %{total}", + "round_simple": "Round %{current} / %{total}", + "intro": "Deep breathing helps overcome the urge.", + "inhale": "Inhale", + "hold": "Hold", + "exhale": "Exhale", + "start": "Start exercise", + "start_exercise": "Start breathing", + "skip": "Skip", + "game_offer_title": "Lyra games", + "game_offer_text": "Pick a short game. 2-3 minutes are often enough to break the urge.", + "just_play_lyra": "Need a quick focus reset? Pick a game.", + "game_memory": "Memory", + "game_tictactoe": "Tic-Tac-Toe", + "game_snake": "Snake", + "game_tetris": "Tetris", + "game_memory_desc": "Find pairs and regain focus", + "game_tictactoe_desc": "Quick duel for clear decisions", + "game_snake_desc": "Rhythm over rumination", + "game_tetris_desc": "Organize patterns, calm your mind", + "skip_games": "Skip games", + "back": "Back", + "open_lyra": "Open with Lyra", + "game_start_title": "Start game", + "game_start_desc": "%{game} will be started with Lyra.", + "how_overcome": "How did it go afterwards?", + "answer_helps": "Your answer helps you spot patterns and get stronger.", + "i_overcame": "I overcame the urge", + "i_gave_in": "I gave in", + "overcame_msg": "Strong. Every resisted urge rewires your brain.", + "gave_in_msg": "No judgment. Honesty is the start of the next win.", + "save": "Save", + "done_title": "Well done!", + "done_desc": "You completed the breathing exercise. Your nervous system has calmed down.", + "done_back": "Back", + "well_done": "Great job", + "chin_up": "Keep your head up", + "overcame_result": "You broke the urge loop. Stay close to what helps you.", + "gave_in_result": "A setback is not the end. Breathe and restart.", + "back_to_dashboard": "Back to dashboard" + }, + "notifications": { + "title": "Notifications", + "empty_title": "No notifications", + "empty_subtitle": "You're all caught up.", + "mark_all_read": "Mark all as read", + "liked_post": "liked your post", + "commented_post": "commented on your post", + "voted_domain": "voted on your domain", + "domain_accepted": "is now in the global blocklist", + "domain_accepted_sub": "Tap to open your blocklist", + "domain_rejected": "was rejected and removed from your list", + "new_follower": "started following you", + "generic": "sent you a notification", + "just_now": "just now", + "min_ago": "%{n} min ago", + "hours_ago": "%{n} h ago", + "days_ago": "%{n} d ago" + }, + "chat": { + "title": "Chat", + "dms": "Direct Messages", + "rooms": "Groups", + "groups": "Groups", + "direct": "Direct", + "no_chats": "No chats yet", + "no_rooms": "No groups yet", + "start_dm": "Start new DM", + "placeholder": "Write a message…", + "you": "You: ", + "just_now": "just now", + "loading": "Loading…", + "send_failed": "Failed to send message.", + "create_group": "Create group", + "create": "Create", + "room_name": "Group name", + "room_description": "Description (optional)", + "public_room": "Public group", + "join_mode": "Join mode", + "join_mode_approval": "With approval", + "join_mode_invite": "Invite only", + "join": "Join", + "join_pending": "Join request pending…", + "join_required": "Join the group to participate.", + "members": "Members", + "settings": "Settings", + "info": "Info", + "leave_room": "Leave group", + "reply": "Reply", + "reply_to": "Replying to", + "like": "Like", + "unlike": "Unlike", + "copy": "Copy", + "image_attachment": "Image", + "file_attachment": "File", + "upload_failed": "Upload failed", + "member_count": "%{n} members", + "pending_request": "Join requests", + "approve": "Approve", + "reject": "Reject", + "avatar_updated": "Group photo updated", + "send": "Send" + }, + "community": { + "compose_placeholder": "What's on your mind?", + "compose_default_user": "You", + "compose_photo_perm_title": "Photo access", + "compose_photo_perm_desc": "Please allow access to your photos in iOS Settings.", + "image": "Image", + "cancel": "Cancel", + "share": "Share", + "no_posts": "Be the first to share something", + "cat_all": "All", + "cat_games": "Games", + "cat_domain": "Domain Votes", + "cat_lyra": "Lyra", + "cat_rebreak": "ReBreak", + "like": "Like", + "comment": "Comment", + "comments_title": "Comments", + "comments_empty": "No comments yet – be the first!", + "reply": "Reply", + "reply_to": "Replying to", + "send": "Send", + "comment_placeholder": "Write a comment…", + "filter": "Filter", + "published": "Published", + "post_failed": "Failed to publish post.", + "anonymous_label": "Anonymous", + "tier_starter": "Starter", + "tier_pro": "Pro", + "tier_legend": "Legend", + "bot_admin": "Admin", + "bot_ai": "AI", + "reposted_suffix": "reposted", + "domain_proposal_label": "Blocklist proposal", + "domain_added_to_blocklist": "Added to global blocklist", + "domain_added": "In the global blocklist", + "domain_proposed": "Proposed for inclusion", + "domain_vote_own": "You can't vote on your own proposal.", + "vote_yes": "Yes", + "vote_no": "No", + "vote_rejected": "Rejected", + "vote_in_review": "Under review", + "voted_thanks": "Thanks for your vote!" + }, + "streak": { + "label_one": "day", + "label_other": "days", + "label_suffix": "clean" + } +} diff --git a/apps/rebreak-native/metro.config.js b/apps/rebreak-native/metro.config.js new file mode 100644 index 0000000..72d7b59 --- /dev/null +++ b/apps/rebreak-native/metro.config.js @@ -0,0 +1,31 @@ +// Metro config für Expo + pnpm Monorepo +// Quelle: https://docs.expo.dev/guides/monorepos/ + +const { getDefaultConfig } = require('expo/metro-config'); +const { withNativeWind } = require('nativewind/metro'); +const path = require('path'); + +const projectRoot = __dirname; +const monorepoRoot = path.resolve(projectRoot, '../..'); + +const config = getDefaultConfig(projectRoot); + +// 1. Watch alle Workspace-Pakete +config.watchFolders = [monorepoRoot]; + +// 2. Auflösung über Workspace-root + lokale node_modules +config.resolver.nodeModulesPaths = [ + path.resolve(projectRoot, 'node_modules'), + path.resolve(monorepoRoot, 'node_modules'), +]; + +// 3. Symlinks via pnpm +config.resolver.unstable_enableSymlinks = true; +config.resolver.unstable_enablePackageExports = true; + +// 4. .riv (Rive-Animation) als Asset registrieren +config.resolver.assetExts = [...(config.resolver.assetExts ?? []), 'riv']; + +module.exports = withNativeWind(config, { + input: './global.css', +}); diff --git a/apps/rebreak-native/metro.sh b/apps/rebreak-native/metro.sh new file mode 100755 index 0000000..29fb8cc --- /dev/null +++ b/apps/rebreak-native/metro.sh @@ -0,0 +1,34 @@ +#!/bin/bash +# Rebreak Native: Metro Bundler (Kill + Clean Restart). +# Killt jede laufende Instanz auf 8081, dann frischer Start mit --clear-Cache. +# Aufruf: ./metro.sh (mit cache-clear, Default) +# ./metro.sh --keep (ohne --clear, schneller wenn keine Dependency-Changes) +set -e + +cd "$(dirname "$0")" + +echo "🚇 Metro Bundler" +echo "================" + +# 1) Existierende Metro-Instanz auf Port 8081 killen +PIDS=$(lsof -iTCP:8081 -sTCP:LISTEN -n -P 2>/dev/null | awk 'NR>1 {print $2}' | sort -u) +if [ -n "$PIDS" ]; then + echo "→ Killing existing Metro on :8081 (PIDs: $PIDS)" + echo "$PIDS" | xargs kill -9 2>/dev/null || true + sleep 1 +else + echo "→ Kein Metro auf :8081 aktiv" +fi + +# 2) Stale node-Prozesse die expo CLI gestartet haben (Belt-and-Suspenders) +pkill -f "expo start" 2>/dev/null || true +pkill -f "react-native/cli/build" 2>/dev/null || true + +# 3) Start +if [ "$1" = "--keep" ]; then + echo "→ Starte Metro (Cache behalten)" + exec npx expo start +else + echo "→ Starte Metro mit --clear (Haste-Map + Transformer-Cache reset)" + exec npx expo start --clear +fi diff --git a/apps/rebreak-native/modules/rebreak-protection/expo-module.config.json b/apps/rebreak-native/modules/rebreak-protection/expo-module.config.json new file mode 100644 index 0000000..4a6ed44 --- /dev/null +++ b/apps/rebreak-native/modules/rebreak-protection/expo-module.config.json @@ -0,0 +1,9 @@ +{ + "platforms": ["apple", "android"], + "apple": { + "modules": ["RebreakProtectionModule"] + }, + "android": { + "modules": ["expo.modules.rebreakprotection.RebreakProtectionModule"] + } +} diff --git a/apps/rebreak-native/modules/rebreak-protection/index.ts b/apps/rebreak-native/modules/rebreak-protection/index.ts new file mode 100644 index 0000000..19650f0 --- /dev/null +++ b/apps/rebreak-native/modules/rebreak-protection/index.ts @@ -0,0 +1,17 @@ +import RebreakProtectionModule from './src/RebreakProtectionModule'; + +export type { + ActivateResult, + DeviceLayers, + DisableResult, + HealthProbeOpts, + HealthProbeOutcome, + HealthProbeResult, + ProtectionLayerKey, + RebreakProtectionEvents, + SyncBlocklistOpts, + SyncBlocklistResult, + SystemSettingsTarget, +} from './src/RebreakProtection.types'; + +export default RebreakProtectionModule; diff --git a/apps/rebreak-native/modules/rebreak-protection/package.json b/apps/rebreak-native/modules/rebreak-protection/package.json new file mode 100644 index 0000000..434b291 --- /dev/null +++ b/apps/rebreak-native/modules/rebreak-protection/package.json @@ -0,0 +1,8 @@ +{ + "name": "rebreak-protection", + "version": "0.1.0", + "private": true, + "description": "ReBreak unified protection module — NEFilter + Family Controls (iOS) + VpnService + AccessibilityService + Tamper Lock (Android).", + "main": "index.ts", + "types": "index.ts" +} diff --git a/apps/rebreak-native/modules/rebreak-protection/src/RebreakProtection.types.ts b/apps/rebreak-native/modules/rebreak-protection/src/RebreakProtection.types.ts new file mode 100644 index 0000000..f0a652e --- /dev/null +++ b/apps/rebreak-native/modules/rebreak-protection/src/RebreakProtection.types.ts @@ -0,0 +1,92 @@ +/** + * Device-local layer state — was tatsächlich an/aus ist auf dem Gerät. + * Plattform-spezifisch: iOS hat urlFilter+familyControls, Android hat + * vpn+accessibility+tamperLock. Felder die auf der jeweils anderen + * Plattform irrelevant sind, sind undefined. + */ +export type DeviceLayers = { + // iOS + urlFilter?: boolean; + familyControls?: boolean; + appDeletionLock?: boolean; + // Android + vpn?: boolean; + accessibility?: boolean; + tamperLock?: boolean; + // Shared + blocklistCount: number; + blocklistLastSyncAt: string | null; +}; + +export type ActivateResult = { + allLayersOn: boolean; + /** + * Welche Layer der User noch aktivieren muss (z.B. weil ein + * System-Permission-Dialog abgelehnt wurde). Wenn allLayersOn=true → leer. + */ + missingLayers: ProtectionLayerKey[]; + /** + * Detaillierte Native-Errors für UX-relevante Diagnose. Leer wenn alle + * Layers durchgekommen sind. Beispiele: NEFilter saveToPreferences-Failure, + * Family Controls Authorization denied. + */ + errors?: string[]; +}; + +export type DisableResult = { + allLayersOff: boolean; +}; + +export type ProtectionLayerKey = + | "urlFilter" + | "familyControls" + | "appDeletionLock" + | "vpn" + | "accessibility" + | "tamperLock"; + +export type SyncBlocklistOpts = { + baseURL: string; + authToken: string; +}; + +export type SyncBlocklistResult = { + updated: boolean; + count: number; + plan?: string; +}; + +export type HealthProbeOutcome = "blocked" | "loaded" | "offline" | "timeout"; + +export type HealthProbeOpts = { + /** Default: https://bet365.com — sollte sicher in der Blocklist stehen. */ + target?: string; + /** Default 5s. */ + timeoutSeconds?: number; +}; + +export type HealthProbeResult = { + outcome: HealthProbeOutcome; + reason: string; + durationMs: number; + target: string; +}; + +export type SystemSettingsTarget = + /** Android: Settings → Network → VPN. */ + | "vpn" + /** Android: Settings → Accessibility (Bedienungshilfen). */ + | "accessibility" + /** iOS: Settings → Screen Time → Family Controls. */ + | "screenTime" + /** iOS+Android: App-Notifications-Settings. */ + | "notifications"; + +/** + * Events die das native Modul fired wenn sich Device-Layer State ändert + * (z.B. User schaltet VPN extern aus, deaktiviert A11y, oder NEFilter-Config + * wurde von außen entfernt). Der App-State-Watchdog hängt darauf. + */ +export type RebreakProtectionEvents = { + onLayerChange: (state: DeviceLayers) => void; +}; diff --git a/apps/rebreak-native/modules/rebreak-protection/src/RebreakProtectionModule.ts b/apps/rebreak-native/modules/rebreak-protection/src/RebreakProtectionModule.ts new file mode 100644 index 0000000..4958390 --- /dev/null +++ b/apps/rebreak-native/modules/rebreak-protection/src/RebreakProtectionModule.ts @@ -0,0 +1,92 @@ +import { NativeModule, requireNativeModule } from 'expo'; + +import type { + ActivateResult, + DeviceLayers, + DisableResult, + HealthProbeOpts, + HealthProbeResult, + RebreakProtectionEvents, + SyncBlocklistOpts, + SyncBlocklistResult, + SystemSettingsTarget, +} from './RebreakProtection.types'; + +declare class RebreakProtectionModule extends NativeModule { + /** + * iOS: aktiviert NUR den NEFilter (URL-Filter Layer). + * Triggert iOS-Dialog "Filter-Konfiguration zulassen". + */ + activateUrlFilter(): Promise<{ enabled: boolean; error?: string }>; + + /** + * iOS: aktiviert NUR Family Controls (Auth + denyAppRemoval = der Lock). + * Triggert iOS-Dialog "Bildschirmzeit verwalten". + * Sobald aktiv, kann der User den Schutz nur über Cooldown deaktivieren. + */ + activateFamilyControls(): Promise<{ enabled: boolean; error?: string }>; + + /** + * Aktiviert ALLE Schutz-Layer in einem Call (legacy, beide Dialoge nacheinander). + * Bevorzugt activateUrlFilter() + activateFamilyControls() einzeln aufrufen. + */ + activate(): Promise; + + /** + * Schaltet ALLE Schutz-Layer ab. NUR aufrufen wenn JS-Layer verifiziert + * hat dass der 24h-Cooldown abgelaufen ist. Native-Modul prüft das nicht + * — der Backend-Cooldown ist Single Source of Truth, das ist Aufgabe der + * JS-Schicht. + */ + disable(): Promise; + + /** Aktueller Device-State. Polling- und Health-Check-Pfad. */ + getDeviceState(): Promise; + + /** + * Lädt blocklist.bin vom Server, schreibt atomisch in App-Group/internal + * storage, postet Reload-Notification an die Filter-Extension. Server + * respondet 304 wenn ETag matched → updated=false. Plan-aware: + * Free → nur personal-domains (≤5), Pro/Legend → 208k+ + personal. + */ + syncBlocklist(opts: SyncBlocklistOpts): Promise; + + /** + * E2E-Verifikation: Hidden WebView lädt eine bekannte Gambling-Domain, + * prüft ob WebKit/Browser den Load aborted (Filter funktioniert) oder die + * Page lädt (Filter ist tot — Alarm). + */ + runHealthProbe(opts?: HealthProbeOpts): Promise; + + /** Öffnet System-Settings auf dem entsprechenden Tab. */ + openSystemSettings(target?: SystemSettingsTarget): Promise; + + // ─── Android-spezifische Methoden (auf iOS undefined zur Laufzeit) ─────── + + /** Android: Live-Check ob unser AccessibilityService aktuell als enabled + * registriert ist (Settings.Secure + AccessibilityManager). */ + isAccessibilityEnabled(): Promise<{ enabled: boolean }>; + + /** Android: Öffnet Settings → Bedienungshilfen, möglichst tief auf die + * Rebreak-Detail-Page (deep-link). Fallback: generelle A11y-Liste. */ + openAccessibilitySettings(): Promise<{ opened: boolean }>; + + /** Android: Aktiviert Tamper-Lock-Watchdog (Settings-Page-Blockade durch + * AccessibilityService). Wirft `preconditions_not_met` wenn VPN oder A11y + * nicht beide live. */ + armTamperLock(): Promise<{ armed: boolean }>; + + /** Android: Disarm Tamper-Lock. Schutz-Layers laufen weiter, aber Settings- + * Watchdog blockt nicht mehr. Im normalen Flow nur nach Cooldown-Ablauf. */ + disarmTamperLock(): Promise<{ armed: boolean }>; + + /** Android: kombinierter Status aller 3 Layers + Blocklist-Count. */ + getProtectionStatus(): Promise<{ + vpnEnabled: boolean; + accessibilityEnabled: boolean; + blocklistCount: number; + tamperArmed: boolean; + }>; +} + +export default requireNativeModule('RebreakProtection'); diff --git a/apps/rebreak-native/modules/rebreak-protection/src/RebreakProtectionModule.web.ts b/apps/rebreak-native/modules/rebreak-protection/src/RebreakProtectionModule.web.ts new file mode 100644 index 0000000..ceca35c --- /dev/null +++ b/apps/rebreak-native/modules/rebreak-protection/src/RebreakProtectionModule.web.ts @@ -0,0 +1,69 @@ +/** + * Web-Stub. Ergibt im Browser keinen funktionalen Schutz — der Filter ist + * inhärent device-bound. Verhindert nur dass Imports auf Web crashen. + */ +import { registerWebModule, NativeModule } from 'expo'; + +import type { + ActivateResult, + DeviceLayers, + DisableResult, + HealthProbeResult, + RebreakProtectionEvents, + SyncBlocklistResult, +} from './RebreakProtection.types'; + +class RebreakProtectionModuleWeb extends NativeModule { + async activate(): Promise { + return { allLayersOn: false, missingLayers: [] }; + } + + async disable(): Promise { + return { allLayersOff: true }; + } + + async getDeviceState(): Promise { + return { blocklistCount: 0, blocklistLastSyncAt: null }; + } + + async syncBlocklist(): Promise { + return { updated: false, count: 0 }; + } + + async runHealthProbe(): Promise { + return { + outcome: 'offline', + reason: 'web_stub', + durationMs: 0, + target: '', + }; + } + + async openSystemSettings(): Promise { + // no-op + } + + // Android-only stubs (Web nutzt keinen davon, aber Type-Compat). + async isAccessibilityEnabled() { + return { enabled: false }; + } + async openAccessibilitySettings() { + return { opened: false }; + } + async armTamperLock() { + return { armed: false }; + } + async disarmTamperLock() { + return { armed: false }; + } + async getProtectionStatus() { + return { + vpnEnabled: false, + accessibilityEnabled: false, + blocklistCount: 0, + tamperArmed: false, + }; + } +} + +export default registerWebModule(RebreakProtectionModuleWeb, 'RebreakProtection'); diff --git a/apps/rebreak-native/nativewind-env.d.ts b/apps/rebreak-native/nativewind-env.d.ts new file mode 100644 index 0000000..c0d8380 --- /dev/null +++ b/apps/rebreak-native/nativewind-env.d.ts @@ -0,0 +1,3 @@ +/// + +// NOTE: This file should not be edited and should be committed with your source code. It is generated by NativeWind. \ No newline at end of file diff --git a/apps/rebreak-native/package.json b/apps/rebreak-native/package.json new file mode 100644 index 0000000..34c0d01 --- /dev/null +++ b/apps/rebreak-native/package.json @@ -0,0 +1,71 @@ +{ + "name": "@trucko/rebreak-native", + "version": "0.1.0", + "private": true, + "main": "expo-router/entry", + "scripts": { + "start": "expo start --dev-client", + "ios": "expo run:ios", + "android": "expo run:android", + "prebuild": "expo prebuild --clean", + "lint": "expo lint", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@expo-google-fonts/nunito": "^0.2.3", + "@expo/vector-icons": "^14.0.0", + "@react-native-async-storage/async-storage": "^2.1.2", + "@react-native-community/slider": "^5.2.0", + "@react-navigation/native": "^7.0.0", + "@supabase/supabase-js": "^2.46.0", + "@tanstack/react-query": "^5.59.0", + "expo": "^53.0.0", + "expo-apple-authentication": "~7.2.4", + "expo-application": "~6.1.5", + "expo-av": "~15.1.7", + "expo-build-properties": "~0.14.8", + "expo-clipboard": "^55.0.13", + "expo-constants": "~17.1.8", + "expo-dev-client": "~5.2.4", + "expo-file-system": "~18.1.11", + "expo-font": "~13.0.0", + "expo-haptics": "^55.0.14", + "expo-image-picker": "~16.1.4", + "expo-linking": "~7.1.7", + "expo-localization": "~16.1.6", + "expo-modules-core": "^2.0.0", + "expo-notifications": "~0.31.5", + "expo-router": "~5.1.11", + "expo-speech": "~13.1.7", + "expo-splash-screen": "~0.30.10", + "expo-status-bar": "~2.2.3", + "expo-web-browser": "~14.2.0", + "i18next": "^23.16.0", + "lottie-react-native": "7.2.2", + "nativewind": "^4.1.0", + "react": "19.0.0", + "react-dom": "19.0.0", + "react-hook-form": "^7.53.0", + "react-i18next": "^15.1.0", + "react-native": "0.79.6", + "react-native-bottom-tabs": "^1.2.0", + "react-native-gesture-handler": "~2.24.0", + "react-native-mmkv": "^3.1.0", + "react-native-reanimated": "~4.0.0", + "react-native-safe-area-context": "5.4.0", + "react-native-screens": "~4.11.1", + "react-native-sse": "^1.2.1", + "react-native-svg": "15.11.2", + "react-native-url-polyfill": "^2.0.0", + "react-native-worklets": "~0.4.0", + "rive-react-native": "^9.0.1", + "tailwindcss": "^3.4.14", + "valibot": "^1.2.0", + "zustand": "^5.0.0" + }, + "devDependencies": { + "@babel/core": "^7.25.0", + "@types/react": "~19.0.14", + "typescript": "~5.8.3" + } +} diff --git a/apps/rebreak-native/plugins/with-fmt-consteval-fix.js b/apps/rebreak-native/plugins/with-fmt-consteval-fix.js new file mode 100644 index 0000000..7667806 --- /dev/null +++ b/apps/rebreak-native/plugins/with-fmt-consteval-fix.js @@ -0,0 +1,144 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ +/** + * Workaround für Xcode 16 + RN 0.79 + fmt 11.0.2: + * "Call to consteval function 'fmt::basic_format_string<...>' is not a constant expression" + * + * Grund: fmt/include/fmt/base.h definiert FMT_USE_CONSTEVAL UNCONDITIONAL — + * kein `#ifndef`-Guard. Daher hilft `-DFMT_USE_CONSTEVAL=0` als Compiler-Flag + * NICHT — fmt's eigener Header überschreibt es. + * + * Wir patchen daher direkt die Source-Datei nach `pod install`: + * - In ios/Pods/fmt/include/fmt/base.h einen Override-Block einfügen, der + * nach fmt's eigener Detection FMT_USE_CONSTEVAL auf 0 zwingt. + * + * Wirkung: + * - basic_format_string-Konstruktor ist nicht mehr consteval, sondern constexpr + * - FMT_STRING("{}{}") expansion compiliert wieder unter Apple Clang 16+ + * + * Idempotent — markiert die Patch-Stelle mit einem Magic-Comment. + */ +const { withDangerousMod } = require('@expo/config-plugins'); +const fs = require('fs'); +const path = require('path'); + +const PATCH_MARKER = '/* REBREAK_FMT_CONSTEVAL_FIX */'; + +const SOURCE_PATCH = ` + +${PATCH_MARKER} +// Xcode 16 + Apple Clang 16+ haben einen consteval-Bug der fmt's +// FMT_STRING-basierte format_to-Calls bricht. Wir zwingen daher +// FMT_USE_CONSTEVAL=0 nach fmt's eigener Detection. +#undef FMT_USE_CONSTEVAL +#define FMT_USE_CONSTEVAL 0 +#undef FMT_CONSTEVAL +#define FMT_CONSTEVAL +#undef FMT_CONSTEXPR20 +#define FMT_CONSTEXPR20 +${PATCH_MARKER} +`; + +function patchFmtBaseHeader(podsDir) { + const baseHeader = path.join(podsDir, 'fmt', 'include', 'fmt', 'base.h'); + if (!fs.existsSync(baseHeader)) { + console.warn('[with-fmt-consteval-fix] fmt/base.h not found at', baseHeader); + return false; + } + + let content = fs.readFileSync(baseHeader, 'utf-8'); + if (content.includes(PATCH_MARKER)) { + return false; // schon gepatcht + } + + // Patch nach der Detection-Block einfügen, vor dem `#if FMT_USE_CONSTEVAL` + // Suche nach dem End-of-Detection (line endet mit `#endif` direkt vor + // `#if FMT_USE_CONSTEVAL`). + const anchor = '#if FMT_USE_CONSTEVAL'; + const idx = content.indexOf(anchor); + if (idx === -1) { + console.warn('[with-fmt-consteval-fix] anchor not found in base.h'); + return false; + } + + // Patch direkt vor dem anchor einfügen + content = content.slice(0, idx) + SOURCE_PATCH + '\n' + content.slice(idx); + fs.writeFileSync(baseHeader, content); + console.log('[with-fmt-consteval-fix] patched', baseHeader); + return true; +} + +module.exports = function withFmtConstevalFix(config) { + return withDangerousMod(config, [ + 'ios', + async (cfg) => { + const podsDir = path.join(cfg.modRequest.platformProjectRoot, 'Pods'); + // Wenn Pods/ noch nicht existiert (= prebuild Phase, vor pod install): + // Patch wird automatisch beim nächsten Run angewendet sobald Pods da sind. + // Damit der User aber NICHT manuell nachpatchen muss, packen wir den + // Patch zusätzlich in einen Podfile-pre_install-Hook der bei JEDEM + // pod install läuft (auch beim ersten). + if (fs.existsSync(podsDir)) { + patchFmtBaseHeader(podsDir); + } + + // Podfile pre_install-Hook injizieren — patched die fmt-Source bei + // jedem pod install (nachdem Pods/fmt/ bereits gedownloaded ist). + const podfilePath = path.join(cfg.modRequest.platformProjectRoot, 'Podfile'); + let podfile = fs.readFileSync(podfilePath, 'utf-8'); + + // Alten Patch (frühere Plugin-Version) entfernen + const oldPatchRegex = /\s*# ─── Rebreak: fmt consteval fix[\s\S]*?# ───────────────────────────────────────────────────────/g; + podfile = podfile.replace(oldPatchRegex, ''); + const oldPostInstallPatchRegex = /\s*# ═══ Rebreak: fmt consteval source-patch[\s\S]*?# ═══════════════════════════════════════════════════════/g; + podfile = podfile.replace(oldPostInstallPatchRegex, ''); + + // Neuen pre_install-Hook injizieren (vor `target` block). + // Wir patchen die fmt/base.h-Datei direkt im pre_install — pod install + // hat dann zu dem Zeitpunkt schon den Pod gedownloaded. + const PRE_INSTALL = ` +# ═══ Rebreak: fmt consteval source-patch (Xcode 16 + RN 0.79) ═══ +pre_install do |installer| + fmt_base_h = File.join(installer.sandbox.root, 'fmt', 'include', 'fmt', 'base.h') + if File.exist?(fmt_base_h) + content = File.read(fmt_base_h) + marker = '/* REBREAK_FMT_CONSTEVAL_FIX */' + unless content.include?(marker) + patch = <<~PATCH + +#{marker} +// Xcode 16 + Apple Clang 16+ consteval-Bug Workaround +#undef FMT_USE_CONSTEVAL +#define FMT_USE_CONSTEVAL 0 +#undef FMT_CONSTEVAL +#define FMT_CONSTEVAL +#undef FMT_CONSTEXPR20 +#define FMT_CONSTEXPR20 +#{marker} + PATCH + anchor = '#if FMT_USE_CONSTEVAL' + if content.include?(anchor) + content = content.sub(anchor, patch + "\\n" + anchor) + File.write(fmt_base_h, content) + Pod::UI.puts " -> Patched fmt/base.h with consteval workaround".green + end + end + end +end +# ═══════════════════════════════════════════════════════ +`; + + // Inject vor dem ersten `target` block (Top-level) + const targetMatch = podfile.match(/^target\s+['"][^'"]+['"]\s+do/m); + if (targetMatch) { + const insertAt = targetMatch.index; + podfile = podfile.slice(0, insertAt) + PRE_INSTALL + '\n' + podfile.slice(insertAt); + } else { + // Fallback: ans Ende anhängen + podfile += PRE_INSTALL; + } + + fs.writeFileSync(podfilePath, podfile); + return cfg; + }, + ]); +}; diff --git a/apps/rebreak-native/plugins/with-rebreak-protection-android.js b/apps/rebreak-native/plugins/with-rebreak-protection-android.js new file mode 100644 index 0000000..f9b3111 --- /dev/null +++ b/apps/rebreak-native/plugins/with-rebreak-protection-android.js @@ -0,0 +1,127 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ +/** + * Expo Config-Plugin — wires the Android VpnService (DNS-Filter) + + * AccessibilityService (URL filter Layer 2) into AndroidManifest.xml at + * prebuild time. + * + * Was es macht: + * 1) Sorgt für `xmlns:tools` auf . + * 2) Registriert mit + * foregroundServiceType="systemExempted" + intent-filter + * android.net.VpnService + permission BIND_VPN_SERVICE. + * (`systemExempted` ist seit Android 14 der korrekte Type für + * VPN-/Filter-Foreground-Services — vorher war `specialUse`+content_filter + * angedacht aber bringt mehr Probleme als Nutzen.) + * 3) Registriert mit + * android:permission=BIND_ACCESSIBILITY_SERVICE + intent-filter + * android.accessibilityservice.AccessibilityService + meta-data + * android.accessibilityservice → @xml/accessibility_service_config. + * + * Wird aus app.config.ts via `plugins: ['./plugins/with-rebreak-protection-android']` + * registriert. Idempotent — kann beliebig oft via `expo prebuild` laufen. + * + * Native Source: `modules/rebreak-protection/android/src/main/java/expo/modules/rebreakprotection/` + * - VpnService: expo.modules.rebreakprotection.vpn.RebreakVpnService + * - AccessibilityService: expo.modules.rebreakprotection.accessibility.RebreakAccessibilityService + */ + +const { + withAndroidManifest, + AndroidConfig, +} = require('@expo/config-plugins'); + +const VPN_SERVICE_CLASS = + 'expo.modules.rebreakprotection.vpn.RebreakVpnService'; +const A11Y_SERVICE_CLASS = + 'expo.modules.rebreakprotection.accessibility.RebreakAccessibilityService'; + +// ─── 1) tools-Namespace auf ────────────────────────────────────── + +function ensureToolsNamespace(manifest) { + if (!manifest.manifest.$) manifest.manifest.$ = {}; + if (!manifest.manifest.$['xmlns:tools']) { + manifest.manifest.$['xmlns:tools'] = 'http://schemas.android.com/tools'; + } +} + +// ─── 2) -Tag für RebreakVpnService ───────────────────────────────── + +function ensureVpnService(manifest) { + const application = AndroidConfig.Manifest.getMainApplicationOrThrow(manifest); + + if (!application.service) application.service = []; + + const alreadyDeclared = application.service.some( + (svc) => svc.$ && svc.$['android:name'] === VPN_SERVICE_CLASS, + ); + if (alreadyDeclared) return; + + application.service.push({ + $: { + 'android:name': VPN_SERVICE_CLASS, + 'android:permission': 'android.permission.BIND_VPN_SERVICE', + 'android:foregroundServiceType': 'systemExempted', + 'android:exported': 'false', + }, + 'intent-filter': [ + { + action: [{ $: { 'android:name': 'android.net.VpnService' } }], + }, + ], + }); +} + +// ─── 3) -Tag für RebreakAccessibilityService ─────────────────────── + +function ensureAccessibilityService(manifest) { + const application = AndroidConfig.Manifest.getMainApplicationOrThrow(manifest); + + if (!application.service) application.service = []; + + const alreadyDeclared = application.service.some( + (svc) => svc.$ && svc.$['android:name'] === A11Y_SERVICE_CLASS, + ); + if (alreadyDeclared) return; + + application.service.push({ + $: { + 'android:name': A11Y_SERVICE_CLASS, + 'android:permission': 'android.permission.BIND_ACCESSIBILITY_SERVICE', + 'android:label': '@string/accessibility_service_summary', + 'android:exported': 'true', + }, + 'intent-filter': [ + { + action: [ + { + $: { + 'android:name': + 'android.accessibilityservice.AccessibilityService', + }, + }, + ], + }, + ], + 'meta-data': [ + { + $: { + 'android:name': 'android.accessibilityservice', + 'android:resource': '@xml/accessibility_service_config', + }, + }, + ], + }); +} + +// ─── Composition ──────────────────────────────────────────────────────────── + +function withRebreakProtectionAndroid(config) { + return withAndroidManifest(config, (cfg) => { + ensureToolsNamespace(cfg.modResults); + ensureVpnService(cfg.modResults); + ensureAccessibilityService(cfg.modResults); + return cfg; + }); +} + +module.exports = withRebreakProtectionAndroid; diff --git a/apps/rebreak-native/plugins/with-rebreak-protection-ios.js b/apps/rebreak-native/plugins/with-rebreak-protection-ios.js new file mode 100644 index 0000000..d35b84c --- /dev/null +++ b/apps/rebreak-native/plugins/with-rebreak-protection-ios.js @@ -0,0 +1,192 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ +/** + * Expo Config-Plugin — wires the NEFilter Extension target into the iOS + * project at prebuild time. + * + * Was es macht: + * 1) Setzt die Entitlements der Haupt-App (family-controls, network- + * extension, app-groups). + * 2) Kopiert `modules/rebreak-protection/ios/RebreakURLFilter/` nach + * `ios/RebreakURLFilter/` (idempotent). + * 3) Fügt einen neuen Xcode-Target `RebreakURLFilter` (Bundle-ID + * `org.rebreak.app.RebreakURLFilter`) zum Projekt hinzu, mit: + * - Source-File: FilterControlProvider.swift + * - NetworkExtension.framework + * - Embed-App-Extensions Build-Phase im Haupt-Target + * - Entitlements via `RebreakURLFilter.entitlements` + * + * Wird aus app.config.ts via `plugins: ['./plugins/with-rebreak-protection-ios']` + * registriert. Idempotent — kann beliebig oft via `expo prebuild` laufen. + */ + +const fs = require('fs'); +const path = require('path'); + +const { + withEntitlementsPlist, + withDangerousMod, + withXcodeProject, +} = require('@expo/config-plugins'); + +const APP_GROUP = 'group.org.rebreak.app'; +const TARGET_NAME = 'RebreakURLFilter'; +const EXT_BUNDLE_SUFFIX = 'RebreakURLFilter'; +const MODULE_DIR = path.join( + __dirname, + '..', + 'modules', + 'rebreak-protection', + 'ios', + TARGET_NAME, +); + +// ─── 1) Haupt-App Entitlements ────────────────────────────────────────────── + +function withMainAppEntitlements(config) { + return withEntitlementsPlist(config, (cfg) => { + cfg.modResults['com.apple.developer.networking.networkextension'] = [ + 'content-filter-provider', + ]; + cfg.modResults['com.apple.developer.family-controls'] = true; + const groups = cfg.modResults['com.apple.security.application-groups'] || []; + if (!groups.includes(APP_GROUP)) { + cfg.modResults['com.apple.security.application-groups'] = [...groups, APP_GROUP]; + } + return cfg; + }); +} + +// ─── 2) Extension-Sources ins ios/-Verzeichnis kopieren ───────────────────── + +function withCopyExtensionSources(config) { + return withDangerousMod(config, [ + 'ios', + async (cfg) => { + const platformProjectRoot = cfg.modRequest.platformProjectRoot; + const dest = path.join(platformProjectRoot, TARGET_NAME); + if (!fs.existsSync(MODULE_DIR)) { + throw new Error( + `[with-rebreak-protection-ios] Extension source dir missing: ${MODULE_DIR}`, + ); + } + if (!fs.existsSync(dest)) fs.mkdirSync(dest, { recursive: true }); + for (const file of fs.readdirSync(MODULE_DIR)) { + const srcFile = path.join(MODULE_DIR, file); + const destFile = path.join(dest, file); + fs.copyFileSync(srcFile, destFile); + } + return cfg; + }, + ]); +} + +// ─── 3) Xcode-Target hinzufügen ────────────────────────────────────────────── + +function withExtensionTarget(config) { + return withXcodeProject(config, async (cfg) => { + const proj = cfg.modResults; + + // Idempotenz: skip wenn Target schon angelegt + if (proj.pbxTargetByName(TARGET_NAME)) { + return cfg; + } + + const mainBundleId = cfg.ios?.bundleIdentifier; + if (!mainBundleId) { + throw new Error('[with-rebreak-protection-ios] ios.bundleIdentifier fehlt in app.config'); + } + const extBundleId = `${mainBundleId}.${EXT_BUNDLE_SUFFIX}`; + + // ── Target anlegen (Type: app_extension) ── + const target = proj.addTarget(TARGET_NAME, 'app_extension', TARGET_NAME, extBundleId); + + // ── Build-Phasen: Sources + Frameworks + Resources ── + proj.addBuildPhase( + ['FilterControlProvider.swift'], + 'PBXSourcesBuildPhase', + 'Sources', + target.uuid, + ); + proj.addBuildPhase( + ['NetworkExtension.framework'], + 'PBXFrameworksBuildPhase', + 'Frameworks', + target.uuid, + ); + // Info.plist gehört NICHT als Resource — wird via INFOPLIST_FILE referenziert. + + // ── PBXGroup für die Sources ── + const pbxGroup = proj.addPbxGroup( + ['FilterControlProvider.swift', 'Info.plist', 'RebreakURLFilter.entitlements'], + TARGET_NAME, + TARGET_NAME, + ); + // Group ans CustomTemplate-Group hängen damit sie im Project Navigator erscheint + const groups = proj.hash.project.objects.PBXGroup; + Object.keys(groups).forEach((key) => { + if ( + groups[key].name === 'CustomTemplate' || + (groups[key].name === undefined && groups[key].path === undefined) + ) { + proj.addToPbxGroup(pbxGroup.uuid, key); + } + }); + + // ── Build-Settings auf der Target-Configuration anpassen ── + const configurations = proj.pbxXCBuildConfigurationSection(); + Object.keys(configurations) + .filter((k) => typeof configurations[k] === 'object') + .forEach((k) => { + const buildSettingsObj = configurations[k].buildSettings; + if ( + buildSettingsObj && + buildSettingsObj.PRODUCT_NAME && + buildSettingsObj.PRODUCT_NAME.replace(/"/g, '') === TARGET_NAME + ) { + buildSettingsObj.INFOPLIST_FILE = `"${TARGET_NAME}/Info.plist"`; + buildSettingsObj.CODE_SIGN_ENTITLEMENTS = `"${TARGET_NAME}/${TARGET_NAME}.entitlements"`; + buildSettingsObj.IPHONEOS_DEPLOYMENT_TARGET = '15.1'; + buildSettingsObj.SWIFT_VERSION = '5.9'; + buildSettingsObj.TARGETED_DEVICE_FAMILY = '"1,2"'; + buildSettingsObj.CODE_SIGN_STYLE = 'Automatic'; + } + }); + + // ── Embed App Extensions Build-Phase im Haupt-Target ── + // Suche nach existierender CopyFilesBuildPhase mit Comment "Embed App Extensions" + const mainTargetUuid = proj.getFirstTarget().uuid; + const buildPhases = proj.hash.project.objects.PBXNativeTarget[mainTargetUuid].buildPhases; + const copyFilesPhases = proj.hash.project.objects.PBXCopyFilesBuildPhase || {}; + const hasEmbedPhase = Object.keys(copyFilesPhases).some((key) => { + const phase = copyFilesPhases[key]; + return ( + typeof phase === 'object' && + phase.dstSubfolderSpec === 13 && // 13 = PluginsAndFrameworks (App Extensions) + buildPhases.some((bp) => bp.value === key) + ); + }); + if (!hasEmbedPhase) { + proj.addBuildPhase( + [`${TARGET_NAME}.appex`], + 'PBXCopyFilesBuildPhase', + 'Embed App Extensions', + mainTargetUuid, + 'app_extension', // dstSubfolderSpec=13 + ); + } + + // ── Target-Dependency: Haupt-App muss Extension vor sich bauen ── + proj.addTargetDependency(mainTargetUuid, [target.uuid]); + + return cfg; + }); +} + +// ─── Composition ──────────────────────────────────────────────────────────── + +module.exports = function withRebreakProtectionIos(config) { + config = withMainAppEntitlements(config); + config = withCopyExtensionSources(config); + config = withExtensionTarget(config); + return config; +}; diff --git a/apps/rebreak-native/plugins/with-rive-asset-android.js b/apps/rebreak-native/plugins/with-rive-asset-android.js new file mode 100644 index 0000000..c2fc81d --- /dev/null +++ b/apps/rebreak-native/plugins/with-rive-asset-android.js @@ -0,0 +1,50 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ +/** + * Expo Config-Plugin — kopiert das Rive-Asset (lyra-avatar.riv) bei prebuild + * nach android/app/src/main/res/raw/lyra_avatar.riv damit Rive-Native auf + * Android es als raw-resource laden kann. + * + * Hintergrund: rive-react-native auf Android akzeptiert NUR + * - resourceName="lyra_avatar" (raw-resource-Name, lowercase+underscores) + * - url="https://..." (remote) + * + * Mit `source={{ uri: 'file:///...' }}` (was iOS via expo-asset gut findet) + * crasht Android: "File resource not found. You must provide correct url + * or resourceName!" + * + * Fix: bei jedem prebuild .riv aus assets/ in res/raw/ kopieren. + * + * Usage in app.config.ts: plugins: ['./plugins/with-rive-asset-android'] + */ + +const fs = require('fs'); +const path = require('path'); +const { withDangerousMod } = require('@expo/config-plugins'); + +const SOURCE = path.join(__dirname, '..', 'assets', 'lyra-avatar.riv'); +// Android raw-resource convention: lowercase + underscores (NICHT hyphens). +const TARGET_FILENAME = 'lyra_avatar.riv'; + +function withRiveAssetAndroid(config) { + return withDangerousMod(config, [ + 'android', + async (cfg) => { + const platformProjectRoot = cfg.modRequest.platformProjectRoot; + const targetDir = path.join(platformProjectRoot, 'app', 'src', 'main', 'res', 'raw'); + const target = path.join(targetDir, TARGET_FILENAME); + + if (!fs.existsSync(SOURCE)) { + throw new Error(`[with-rive-asset-android] Quelle fehlt: ${SOURCE}`); + } + + if (!fs.existsSync(targetDir)) { + fs.mkdirSync(targetDir, { recursive: true }); + } + + fs.copyFileSync(SOURCE, target); + return cfg; + }, + ]); +} + +module.exports = withRiveAssetAndroid; diff --git a/apps/rebreak-native/scripts/fix-embed-extension.js b/apps/rebreak-native/scripts/fix-embed-extension.js new file mode 100644 index 0000000..4dbcd6b --- /dev/null +++ b/apps/rebreak-native/scripts/fix-embed-extension.js @@ -0,0 +1,140 @@ +#!/usr/bin/env node +/** + * One-shot Fix: fügt die fehlende "Embed App Extensions" Build-Phase ins + * existierende Xcode-Projekt ein, ohne den ganzen Prebuild zu wiederholen. + * + * Wird auch in `with-rebreak-protection-ios.js` als Logik referenziert, + * damit zukünftige Prebuilds das richtig hinkriegen. + * + * Usage: node scripts/fix-embed-extension.js + */ +const fs = require('fs'); +const path = require('path'); +const xcode = require('xcode'); + +const PROJECT_ROOT = path.resolve(__dirname, '..'); +const PBXPROJ = path.join(PROJECT_ROOT, 'ios', 'Rebreak.xcodeproj', 'project.pbxproj'); +const TARGET_NAME = 'RebreakURLFilter'; + +if (!fs.existsSync(PBXPROJ)) { + console.error('❌ pbxproj not found at:', PBXPROJ); + process.exit(1); +} + +const project = xcode.project(PBXPROJ); +project.parseSync(); + +// 1) Find Main + Extension targets +const mainTarget = project.getFirstTarget(); +if (!mainTarget) { + console.error('❌ no main target found'); + process.exit(1); +} +const mainTargetUuid = mainTarget.uuid; +const extTargetUuid = project.findTargetKey(TARGET_NAME); +if (!extTargetUuid) { + console.error(`❌ Extension target "${TARGET_NAME}" not found`); + process.exit(1); +} + +console.log(`✓ Main target: ${mainTarget.firstTarget.name} (${mainTargetUuid})`); +console.log(`✓ Extension target: ${TARGET_NAME} (${extTargetUuid})`); + +// 2) Check existing Embed phase +const buildPhases = project.hash.project.objects.PBXNativeTarget[mainTargetUuid].buildPhases || []; +const copyFilesPhases = project.hash.project.objects.PBXCopyFilesBuildPhase || {}; + +let hasEmbedPhase = false; +for (const key of Object.keys(copyFilesPhases)) { + const phase = copyFilesPhases[key]; + if (typeof phase !== 'object') continue; + if (!buildPhases.some((bp) => bp.value === key)) continue; + // dstSubfolderSpec kann String '13' oder Number 13 sein — beide checken + if (phase.dstSubfolderSpec === 13 || phase.dstSubfolderSpec === '13') { + hasEmbedPhase = true; + console.log(`✓ Embed phase already exists: ${key}`); + break; + } +} + +if (hasEmbedPhase) { + console.log('✓ Already has Embed App Extensions phase'); +} else { + console.log('→ Adding Embed App Extensions phase to main target...'); + const newPhase = project.addBuildPhase( + [`${TARGET_NAME}.appex`], + 'PBXCopyFilesBuildPhase', + 'Embed App Extensions', + mainTargetUuid, + 'app_extension', + ); + const phaseObj = project.hash.project.objects.PBXCopyFilesBuildPhase[newPhase.uuid]; + if (phaseObj && phaseObj.dstSubfolderSpec !== 13 && phaseObj.dstSubfolderSpec !== '13') { + phaseObj.dstSubfolderSpec = 13; + phaseObj.dstPath = ''; + } +} + +// Target-Dependency: Main → Extension (zwingt Build-Order) +console.log('→ Ensuring target dependency (Main → Extension)...'); +const mainObj = project.hash.project.objects.PBXNativeTarget[mainTargetUuid]; +const existingDeps = mainObj.dependencies || []; +const dependencyTargets = project.hash.project.objects.PBXTargetDependency || {}; +const hasDepToExt = existingDeps.some((dep) => { + const depObj = dependencyTargets[dep.value]; + return depObj && depObj.target === extTargetUuid; +}); +if (!hasDepToExt) { + project.addTargetDependency(mainTargetUuid, [extTargetUuid]); + console.log('✓ Target dependency added'); +} else { + console.log('✓ Target dependency already exists'); +} + +// Save & exit (von hier — die ursprüngliche save-Logik unten wurde durch dieses Block ersetzt) +fs.writeFileSync(PBXPROJ, project.writeSync()); +console.log('✅ Saved pbxproj'); +console.log('Now: in Xcode → Cmd+Shift+K (clean) → Cmd+B (build)'); +process.exit(0); + +// 3) Add Embed phase +console.log('→ Adding Embed App Extensions phase to main target...'); +const newPhase = project.addBuildPhase( + [`${TARGET_NAME}.appex`], + 'PBXCopyFilesBuildPhase', + 'Embed App Extensions', + mainTargetUuid, + 'app_extension', +); +console.log(`✓ New phase UUID: ${newPhase.uuid}`); + +// 4) Verify dstSubfolderSpec was set correctly +const phaseObj = project.hash.project.objects.PBXCopyFilesBuildPhase[newPhase.uuid]; +if (phaseObj && phaseObj.dstSubfolderSpec !== 13 && phaseObj.dstSubfolderSpec !== '13') { + console.log( + `⚠️ dstSubfolderSpec was ${phaseObj.dstSubfolderSpec}, forcing to 13`, + ); + phaseObj.dstSubfolderSpec = 13; + phaseObj.dstPath = ''; +} + +// 5) Add target dependency: Main → Extension +// Sorgt dafür dass Xcode die Extension VOR der Main-App baut. +console.log('→ Adding target dependency: Main → Extension...'); +try { + project.addTargetDependency(mainTargetUuid, [extTargetUuid]); + console.log('✓ Target dependency added'); +} catch (e) { + console.log('⚠️ Target dependency might already exist:', e.message); +} + +// 6) Add Extension's appex to the main app's Frameworks/PBXFileReference linkage +// so Xcode knows where to find it during embedding. +// (xcode npm package's addBuildPhase handles file references automatically +// if the .appex was registered via addTarget — sollte schon da sein.) + +// 7) Save +fs.writeFileSync(PBXPROJ, project.writeSync()); +console.log('✅ Saved pbxproj'); +console.log(''); +console.log('Now rebuild in Xcode (Cmd+B). The .appex should be embedded into Rebreak.app/PlugIns/'); diff --git a/apps/rebreak-native/stores/auth.ts b/apps/rebreak-native/stores/auth.ts new file mode 100644 index 0000000..935bd7c --- /dev/null +++ b/apps/rebreak-native/stores/auth.ts @@ -0,0 +1,157 @@ +import { create } from 'zustand'; +import type { Session, User } from '@supabase/supabase-js'; +import * as WebBrowser from 'expo-web-browser'; +import * as Linking from 'expo-linking'; +import { supabase } from '../lib/supabase'; + +WebBrowser.maybeCompleteAuthSession(); + +type AuthState = { + user: User | null; + session: Session | null; + loading: boolean; + + init: () => Promise; + signInWithPassword: (email: string, password: string) => Promise<{ error?: string }>; + signUp: ( + email: string, + password: string, + metadata: { username: string; firstName?: string; lastName?: string; avatarId: string; avatarUrl: string } + ) => Promise<{ error?: string }>; + signOut: () => Promise; + signInWithOAuth: (provider: 'google' | 'apple') => Promise<{ error?: string }>; + resetPasswordForEmail: (email: string) => Promise<{ error?: string }>; + verifyOtp: (email: string, token: string) => Promise<{ error?: string }>; + resendConfirmation: (email: string) => Promise<{ error?: string }>; +}; + +export const useAuthStore = create((set) => ({ + user: null, + session: null, + loading: true, + + init: async () => { + const { data } = await supabase.auth.getSession(); + set({ + session: data.session, + user: data.session?.user ?? null, + loading: false, + }); + + supabase.auth.onAuthStateChange((_event, session) => { + set({ session, user: session?.user ?? null }); + }); + }, + + signInWithPassword: async (email, password) => { + const { data, error } = await supabase.auth.signInWithPassword({ email, password }); + if (error) return { error: error.message }; + set({ session: data.session, user: data.user }); + return {}; + }, + + signUp: async (email, password, metadata) => { + const { data, error } = await supabase.auth.signUp({ + email, + password, + options: { + data: { + username: metadata.username, + first_name: metadata.firstName ?? '', + last_name: metadata.lastName ?? '', + avatar_id: metadata.avatarId, + avatar_url: metadata.avatarUrl, + }, + // Deep-link redirect for email confirmation — scheme registered in app.config.ts + emailRedirectTo: 'rebreak://auth/confirm', + }, + }); + if (error) return { error: error.message }; + set({ session: data.session, user: data.user ?? null }); + return {}; + }, + + signOut: async () => { + await supabase.auth.signOut(); + set({ session: null, user: null }); + }, + + signInWithOAuth: async (provider) => { + const redirectUri = Linking.createURL('auth/callback'); + + if (provider === 'apple') { + // TODO: configure Apple Sign-In + // Requires expo-apple-authentication to be installed + Apple Developer entitlement. + // Apple Client ID = Bundle ID (org.rebreak.app) for native flow. + // For now we fall through to the Supabase OAuth web flow as a temporary path. + } + + const { data, error } = await supabase.auth.signInWithOAuth({ + provider, + options: { + redirectTo: redirectUri, + skipBrowserRedirect: true, + }, + }); + + if (error) return { error: error.message }; + if (!data.url) return { error: 'Kein OAuth-URL erhalten' }; + + const result = await WebBrowser.openAuthSessionAsync(data.url, redirectUri); + + if (result.type !== 'success') { + return result.type === 'cancel' ? {} : { error: 'OAuth fehlgeschlagen' }; + } + + // Extract tokens from the deep-link URL fragment + const url = result.url; + const params = new URLSearchParams(url.split('#')[1] ?? url.split('?')[1] ?? ''); + const accessToken = params.get('access_token'); + const refreshToken = params.get('refresh_token'); + + if (!accessToken || !refreshToken) { + // Session may already be set via onAuthStateChange — check + const { data: sessionData } = await supabase.auth.getSession(); + if (sessionData.session) { + set({ session: sessionData.session, user: sessionData.session.user }); + return {}; + } + return { error: 'Session konnte nicht gelesen werden' }; + } + + const { data: sessionData, error: sessionError } = await supabase.auth.setSession({ + access_token: accessToken, + refresh_token: refreshToken, + }); + + if (sessionError) return { error: sessionError.message }; + set({ session: sessionData.session, user: sessionData.session?.user ?? null }); + return {}; + }, + + resetPasswordForEmail: async (email) => { + const { error } = await supabase.auth.resetPasswordForEmail(email, { + redirectTo: 'rebreak://auth/reset-password', + }); + if (error) return { error: error.message }; + return {}; + }, + + verifyOtp: async (email, token) => { + const { data, error } = await supabase.auth.verifyOtp({ + email, + token, + type: 'signup', + }); + if (error) return { error: error.message }; + if (!data.session) return { error: 'Bestätigung fehlgeschlagen – bitte erneut versuchen.' }; + set({ session: data.session, user: data.user ?? null }); + return {}; + }, + + resendConfirmation: async (email) => { + const { error } = await supabase.auth.resend({ type: 'signup', email }); + if (error) return { error: error.message }; + return {}; + }, +})); diff --git a/apps/rebreak-native/stores/coach.ts b/apps/rebreak-native/stores/coach.ts new file mode 100644 index 0000000..f7bee35 --- /dev/null +++ b/apps/rebreak-native/stores/coach.ts @@ -0,0 +1,86 @@ +import { create } from 'zustand'; +import { apiFetch } from '../lib/api'; + +let historyLoaded = false; + +export type Message = { + id: string; + role: 'user' | 'assistant'; + content: string; + isError?: boolean; + feedbackSaved?: boolean; +}; + +type CoachState = { + messages: Message[]; + thinking: boolean; + historyLoaded: boolean; + /** True sobald der Welcome-Back-Call in dieser App-Session geantwortet hat + * — verhindert dass jede Tab-Rückkehr eine neue Lyra-Begrüßung anhängt. */ + welcomeBackShownThisSession: boolean; + + loadHistory: () => Promise; + clearHistory: () => Promise; + sendMessage: (content: string, locale: string) => Promise<{ message: string; feedbackSaved?: boolean }>; + prependMessage: (msg: Message) => void; + pushMessage: (msg: Message) => void; + markFeedbackSaved: (id: string) => void; + setThinking: (v: boolean) => void; + setWelcomeBackShown: (v: boolean) => void; + reset: () => void; +}; + +export const useCoachStore = create((set, get) => ({ + messages: [], + thinking: false, + historyLoaded: false, + welcomeBackShownThisSession: false, + + loadHistory: async () => { + if (historyLoaded) { + set({ historyLoaded: true }); + return; + } + historyLoaded = true; + const res = await apiFetch<{ messages: Array<{ role: 'user' | 'assistant'; content: string }> }>( + '/api/coach/history' + ); + set({ + messages: res.messages?.length + ? res.messages.map((m, i) => ({ id: i.toString(), role: m.role, content: m.content })) + : [], + historyLoaded: true, + }); + }, + + clearHistory: async () => { + await apiFetch('/api/coach/history', { method: 'DELETE' }).catch(() => null); + historyLoaded = false; + set({ messages: [], historyLoaded: false, welcomeBackShownThisSession: false }); + }, + + sendMessage: async (content, locale) => { + const { messages } = get(); + const res = await apiFetch<{ message: string; feedbackSaved?: boolean }>('/api/coach/message', { + method: 'POST', + body: { + messages: messages.filter((m) => !m.isError).map((m) => ({ role: m.role, content: m.content })), + locale, + }, + }); + return res; + }, + + prependMessage: (msg) => set((s) => ({ messages: [msg, ...s.messages] })), + pushMessage: (msg) => set((s) => ({ messages: [...s.messages, msg] })), + markFeedbackSaved: (id) => + set((s) => ({ + messages: s.messages.map((m) => (m.id === id ? { ...m, feedbackSaved: true } : m)), + })), + setThinking: (v) => set({ thinking: v }), + setWelcomeBackShown: (v) => set({ welcomeBackShownThisSession: v }), + reset: () => { + historyLoaded = false; + set({ messages: [], thinking: false, historyLoaded: false, welcomeBackShownThisSession: false }); + }, +})); diff --git a/apps/rebreak-native/stores/community.ts b/apps/rebreak-native/stores/community.ts new file mode 100644 index 0000000..1584404 --- /dev/null +++ b/apps/rebreak-native/stores/community.ts @@ -0,0 +1,105 @@ +import { create } from 'zustand'; + +export type CommunityCategory = 'all' | 'games' | 'domain_vote' | 'lyra' | 'rebreak'; + +export interface CommunityPostAuthor { + id: string | null; + username: string; + nickname: string; + avatar: string | null; + plan: string; + tier?: string; +} + +export interface CommunityPost { + id: string; + category: string; + content: string; + imageUrl?: string | null; + likesCount: number; + dislikesCount: number; + commentsCount: number; + repostsCount: number; + isAnonymous: boolean; + createdAt: string; + userLike: 'like' | 'dislike' | null; + isBot?: boolean; + botType?: 'lyra' | 'rebreak'; + gameName?: string | null; + challengeId?: string | null; + challengeStatus?: 'OPEN' | 'ACTIVE' | 'FINISHED' | 'CANCELLED' | null; + opponentName?: string | null; + isLive?: boolean; + userVote?: 'yes' | 'no' | null; + submission?: { + id: string; + domain: string; + status: 'pending' | 'approved' | 'rejected' | 'in_review'; + yesVotes: number; + noVotes: number; + reviewedAt?: string | null; + yesVoters?: Array<{ id: string; nickname: string; avatar: string | null }>; + noVoters?: Array<{ id: string; nickname: string; avatar: string | null }>; + } | null; + author: CommunityPostAuthor; + repostOf?: { + author: CommunityPostAuthor; + content: string; + imageUrl?: string | null; + } | null; +} + +export interface CommunityComment { + id: string; + content: string; + createdAt: string; + parentCommentId: string | null; + authorNickname: string; + authorAvatar: string | null; + authorId: string | null; + likesCount: number; + userLike: boolean; +} + +type CommunityState = { + activeCategory: CommunityCategory; + setCategory: (cat: CommunityCategory) => void; + optimisticLikes: Record; + applyOptimisticLike: (postId: string, currentLike: 'like' | null, currentCount: number) => { newLike: 'like' | null; newCount: number }; + revertOptimisticLike: (postId: string) => void; + clearOptimisticLike: (postId: string) => void; +}; + +export const useCommunityStore = create((set, get) => ({ + activeCategory: 'all', + optimisticLikes: {}, + + setCategory: (cat) => set({ activeCategory: cat }), + + applyOptimisticLike: (postId, currentLike, currentCount) => { + const isLiked = currentLike === 'like'; + const newLike: 'like' | null = isLiked ? null : 'like'; + const newCount = isLiked ? Math.max(0, currentCount - 1) : currentCount + 1; + set((s) => ({ + optimisticLikes: { + ...s.optimisticLikes, + [postId]: { delta: newCount - currentCount, userLike: newLike }, + }, + })); + return { newLike, newCount }; + }, + + revertOptimisticLike: (postId) => { + set((s) => { + const { [postId]: _, ...rest } = s.optimisticLikes; + return { optimisticLikes: rest }; + }); + }, + + clearOptimisticLike: (postId) => { + set((s) => { + const { [postId]: _, ...rest } = s.optimisticLikes; + return { optimisticLikes: rest }; + }); + }, +})); diff --git a/apps/rebreak-native/stores/notifications.ts b/apps/rebreak-native/stores/notifications.ts new file mode 100644 index 0000000..285e9a7 --- /dev/null +++ b/apps/rebreak-native/stores/notifications.ts @@ -0,0 +1,149 @@ +import { create } from "zustand"; +import { apiFetch } from "../lib/api"; +import { supabase } from "../lib/supabase"; +import type { RealtimeChannel } from "@supabase/supabase-js"; + +export interface AppNotification { + id: string; + type: string; + actorName: string; + actorAvatar: string | null; + postId: string | null; + preview: string | null; + readAt: string | null; + createdAt: string; +} + +type NotificationState = { + items: AppNotification[]; + unread: number; + loaded: boolean; + load: () => Promise; + markRead: () => Promise; + remove: (id: string) => Promise; + startRealtime: () => Promise; + stopRealtime: () => void; + reset: () => void; +}; + +let realtimeSub: RealtimeChannel | null = null; +let reconnectTimer: ReturnType | null = null; + +export const useNotificationStore = create((set, get) => ({ + items: [], + unread: 0, + loaded: false, + + load: async () => { + try { + const res = await apiFetch<{ items: AppNotification[]; unread: number }>( + "/api/notifications", + ); + set({ items: res.items ?? [], unread: res.unread ?? 0, loaded: true }); + } catch (err) { + console.warn("[notifications] load failed:", err); + } + }, + + markRead: async () => { + if (get().unread === 0) return; + const now = new Date().toISOString(); + set((s) => ({ + unread: 0, + items: s.items.map((n) => ({ ...n, readAt: n.readAt ?? now })), + })); + try { + await apiFetch("/api/notifications/read", { method: "POST" }); + } catch (err) { + console.warn("[notifications] markRead failed:", err); + } + }, + + remove: async (id) => { + set((s) => ({ items: s.items.filter((n) => n.id !== id) })); + try { + await apiFetch(`/api/notifications/${id}`, { method: "DELETE" }); + } catch (err) { + console.warn("[notifications] remove failed:", err); + } + }, + + startRealtime: async () => { + if (realtimeSub) return; + const { data } = await supabase.auth.getSession(); + const session = data.session; + if (!session?.user?.id) return; + + supabase.realtime.setAuth(session.access_token); + const myId = session.user.id; + + realtimeSub = supabase + .channel(`notifications:${myId}:${Date.now()}`) + .on( + "postgres_changes", + { + event: "INSERT", + schema: "rebreak", + table: "notifications", + filter: `recipient_id=eq.${myId}`, + }, + (payload: any) => { + const r = payload.new; + const notif: AppNotification = { + id: r.id, + type: r.type, + actorName: r.actor_name, + actorAvatar: r.actor_avatar ?? null, + postId: r.post_id ?? null, + preview: r.preview ?? null, + readAt: null, + createdAt: r.created_at, + }; + set((s) => { + if (s.items.find((n) => n.id === notif.id)) return s; + return { items: [notif, ...s.items], unread: s.unread + 1 }; + }); + }, + ) + .on( + "postgres_changes", + { + event: "DELETE", + schema: "rebreak", + table: "notifications", + filter: `recipient_id=eq.${myId}`, + }, + (payload: any) => { + const id = payload.old?.id; + if (!id) return; + set((s) => ({ items: s.items.filter((n) => n.id !== id) })); + }, + ) + .subscribe((status) => { + if (status === "CHANNEL_ERROR" || status === "TIMED_OUT") { + console.warn("[notifRealtime] error:", status); + get().stopRealtime(); + if (reconnectTimer) clearTimeout(reconnectTimer); + reconnectTimer = setTimeout(() => { + get().startRealtime(); + }, 3000); + } + }); + }, + + stopRealtime: () => { + if (realtimeSub) { + supabase.removeChannel(realtimeSub); + realtimeSub = null; + } + if (reconnectTimer) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } + }, + + reset: () => { + get().stopRealtime(); + set({ items: [], unread: 0, loaded: false }); + }, +})); diff --git a/apps/rebreak-native/tailwind.config.js b/apps/rebreak-native/tailwind.config.js new file mode 100644 index 0000000..79e80ce --- /dev/null +++ b/apps/rebreak-native/tailwind.config.js @@ -0,0 +1,50 @@ +/** @type {import('tailwindcss').Config} */ +module.exports = { + content: [ + './app/**/*.{ts,tsx}', + './components/**/*.{ts,tsx}', + ], + presets: [require('nativewind/preset')], + theme: { + extend: { + colors: { + // Rebreak brand colors — orange burst + dark blue from app-icon.png + // TEMP: iOS native blue palette zum Testen. + // Original Brand-Orange: 50:#fff7ed → 500:#f59e0b → 900:#78350f + rebreak: { + 50: '#eff6ff', + 100: '#dbeafe', + 200: '#bfdbfe', + 300: '#93c5fd', + 400: '#60a5fa', + 500: '#007AFF', + 600: '#0062cc', + 700: '#0050a3', + 800: '#003e7a', + 900: '#002b52', + }, + // Dark blue background palette aus dem Brand-Icon + midnight: { + 50: '#e6edf6', + 100: '#cad9eb', + 200: '#92b4d6', + 300: '#5a8ec1', + 400: '#3a6da3', + 500: '#264e7d', + 600: '#1a3a60', + 700: '#13284a', + 800: '#0e1f3a', + 900: '#091428', + 950: '#040a16', + }, + }, + fontFamily: { + sans: ['Nunito_400Regular', 'system-ui'], + semibold: ['Nunito_600SemiBold'], + bold: ['Nunito_700Bold'], + extrabold: ['Nunito_800ExtraBold'], + }, + }, + }, + plugins: [], +}; diff --git a/apps/rebreak-native/tools/gen-android-launcher.sh b/apps/rebreak-native/tools/gen-android-launcher.sh new file mode 100755 index 0000000..df851c1 --- /dev/null +++ b/apps/rebreak-native/tools/gen-android-launcher.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# Generates Android launcher icons from assets/adaptive-icon-android.png. +# - Trims white border, re-centers chain on 1024×1024 transparent canvas +# (logo size unchanged, only position). +# - Writes ic_launcher_foreground.webp (transparent bg) for adaptive icon. +# - Writes ic_launcher{,_round}.webp on #0a0a0a square as legacy fallback. +# - Background color matches values/colors.xml (iconBackground = #0a0a0a). +set -euo pipefail + +cd "$(dirname "$0")/.." + +SOURCE="assets/adaptive-icon-android.png" +RES="android/app/src/main/res" +BG="#0a0a0a" + +if [[ ! -f "$SOURCE" ]]; then + echo "Missing $SOURCE" >&2 + exit 1 +fi + +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT + +# 1. Trim near-white borders, re-embed centered on 1024×1024 transparent canvas. +# -fuzz 5% absorbs JPEG-style artifacts around the white background. +magick "$SOURCE" \ + -fuzz 5% -trim +repage \ + -background "rgba(0,0,0,0)" -gravity center -extent 1024x1024 \ + "$TMP/master.png" + +# Overwrite source so app.config.ts stays the single source of truth. +cp "$TMP/master.png" "$SOURCE" +echo "Wrote centered master → $SOURCE" + +# bash 3.2 (macOS default) has no assoc arrays — use parallel positional pairs: +# "::". +BUCKETS=( "mdpi:108:48" "hdpi:162:72" "xhdpi:216:96" "xxhdpi:324:144" "xxxhdpi:432:192" ) + +for entry in "${BUCKETS[@]}"; do + bucket="${entry%%:*}" + rest="${entry#*:}" + fg_size="${rest%%:*}" + lg_size="${rest#*:}" + dir="$RES/mipmap-$bucket" + mkdir -p "$dir" + + # Foreground layer (transparent bg) for adaptive icon. + magick "$TMP/master.png" -resize "${fg_size}x${fg_size}" "$TMP/fg-$bucket.png" + cwebp -quiet -q 95 "$TMP/fg-$bucket.png" -o "$dir/ic_launcher_foreground.webp" + echo " ic_launcher_foreground.webp $bucket ${fg_size}px" + + # Legacy ic_launcher / ic_launcher_round on dark background. + magick -size "${lg_size}x${lg_size}" "xc:$BG" \ + \( "$TMP/master.png" -resize "${lg_size}x${lg_size}" \) -gravity center -composite \ + "$TMP/lg-$bucket.png" + cwebp -quiet -q 95 "$TMP/lg-$bucket.png" -o "$dir/ic_launcher.webp" + cp "$dir/ic_launcher.webp" "$dir/ic_launcher_round.webp" + echo " ic_launcher{,_round}.webp $bucket ${lg_size}px" +done + +echo "Done." diff --git a/apps/rebreak-native/tsconfig.json b/apps/rebreak-native/tsconfig.json new file mode 100644 index 0000000..c5ad5b1 --- /dev/null +++ b/apps/rebreak-native/tsconfig.json @@ -0,0 +1,39 @@ +{ + "extends": "expo/tsconfig.base", + "compilerOptions": { + "strict": true, + "paths": { + "@/*": [ + "./*" + ], + "@/components/*": [ + "./components/*" + ], + "@/hooks/*": [ + "./hooks/*" + ], + "@/stores/*": [ + "./stores/*" + ], + "@/lib/*": [ + "./lib/*" + ], + "@/locales/*": [ + "./locales/*" + ] + }, + "types": [ + "expo/types" + ] + }, + "include": [ + "**/*.ts", + "**/*.tsx", + ".expo/types/**/*.ts", + "expo-env.d.ts", + "nativewind-env.d.ts" + ], + "exclude": [ + "node_modules" + ] +} \ No newline at end of file diff --git a/backend/nitro.config.ts b/backend/nitro.config.ts new file mode 100644 index 0000000..b49b574 --- /dev/null +++ b/backend/nitro.config.ts @@ -0,0 +1,42 @@ +import { defineNitroConfig } from "nitropack/config"; + +export default defineNitroConfig({ + compatibilityDate: "latest", + srcDir: "server", + preset: "node-server", + + // Supabase als external dep — nicht bundlen + externals: { + inline: [/^(?!@supabase\/supabase-js)/], + }, + + imports: { + dirs: ["db", "db/**", "utils", "utils/**"], + exclude: ["**/node_modules/**"], + }, + + runtimeConfig: { + databaseUrl: process.env.DATABASE_URL ?? "", + adminSecret: process.env.ADMIN_SECRET ?? "", + openrouterApiKey: process.env.OPENROUTER_API_KEY ?? "", + deepgramApiKey: process.env.DEEPGRAM_API_KEY ?? "", + googleApiKey: process.env.GOOGLE_API_KEY ?? "", + googleAiApiKey: process.env.GOOGLE_AI_API_KEY ?? "", + azureTtsKey: process.env.AZURE_TTS_KEY ?? "", + azureTtsRegion: process.env.AZURE_TTS_REGION ?? "", + openaiApiKey: process.env.OPENAI_API_KEY ?? "", + stripeSecretKey: process.env.STRIPE_SECRET_KEY ?? "", + stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET ?? "", + resendApiKey: process.env.RESEND_API_KEY ?? "", + encryptionKey: process.env.ENCRYPTION_KEY ?? "", + lyraBotUserId: process.env.LYRA_BOT_USER_ID ?? "", + rebreakBotUserId: process.env.REBREAK_BOT_USER_ID ?? "", + groqApiKey: process.env.GROQ_API_KEY ?? "", + cronSecret: process.env.CRON_SECRET ?? "", + public: { + stripePublishableKey: process.env.STRIPE_PUBLISHABLE_KEY ?? "", + appUrl: process.env.APP_URL ?? "https://staging.rebreak.org", + apiBase: process.env.API_BASE ?? "https://staging.rebreak.org", + }, + }, +}); diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..517b344 --- /dev/null +++ b/backend/package.json @@ -0,0 +1,34 @@ +{ + "name": "rebreak-backend", + "private": true, + "type": "module", + "version": "0.1.0", + "scripts": { + "build": "prisma generate --schema prisma/schema.prisma && nitro build", + "dev": "nitro dev", + "preview": "node .output/server/index.mjs", + "start": "node .output/server/index.mjs", + "prisma:generate": "prisma generate --schema prisma/schema.prisma" + }, + "dependencies": { + "@prisma/adapter-pg": "^7.2.0", + "@prisma/client": "^7.2.0", + "@supabase/supabase-js": "^2.39.7", + "groq-sdk": "^0.7.0", + "imapflow": "^1.2.18", + "jose": "^6.0.0", + "openai": "^4.65.0", + "pg": "^8.16.3", + "resend": "^4.0.0", + "stripe": "^17.0.0", + "zod": "^3.23.8" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "@types/pg": "^8.11.10", + "h3": "^1.15.4", + "nitropack": "^2.12.4", + "prisma": "^7.2.0", + "typescript": "^5.9.3" + } +} diff --git a/backend/prisma.config.ts b/backend/prisma.config.ts new file mode 100644 index 0000000..82da075 --- /dev/null +++ b/backend/prisma.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "prisma/config"; + +export default defineConfig({ + schema: "prisma/schema.prisma", + migrations: { + path: "prisma/migrations", + }, + datasource: { + url: process.env.DATABASE_URL ?? "", + }, +}); diff --git a/backend/prisma/migrations/0_init/migration.sql b/backend/prisma/migrations/0_init/migration.sql new file mode 100644 index 0000000..a02a3d8 --- /dev/null +++ b/backend/prisma/migrations/0_init/migration.sql @@ -0,0 +1,404 @@ + +-- CreateSchema +CREATE SCHEMA IF NOT EXISTS "rebreak"; + +-- CreateEnum +CREATE TYPE "rebreak"."FeedbackStatus" AS ENUM ('PENDING', 'REVIEWING', 'PLANNED', 'SHIPPED', 'REJECTED'); + +-- CreateEnum +CREATE TYPE "rebreak"."GameChallengeStatus" AS ENUM ('OPEN', 'ACTIVE', 'FINISHED', 'CANCELLED'); + +-- CreateTable +CREATE TABLE "rebreak"."profiles" ( + "id" UUID NOT NULL, + "username" TEXT, + "nickname" TEXT, + "avatar" TEXT, + "plan" TEXT NOT NULL DEFAULT 'free', + "streak" INTEGER NOT NULL DEFAULT 0, + "followers_count" INTEGER NOT NULL DEFAULT 0, + "stripe_customer_id" TEXT, + "stripe_subscription_id" TEXT, + "premium_until" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "profiles_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "rebreak"."streaks" ( + "id" UUID NOT NULL, + "user_id" UUID NOT NULL, + "start_date" DATE NOT NULL, + "current_days" INTEGER NOT NULL DEFAULT 0, + "longest_days" INTEGER NOT NULL DEFAULT 0, + "avg_monthly_savings" DOUBLE PRECISION, + "is_active" BOOLEAN NOT NULL DEFAULT true, + + CONSTRAINT "streaks_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "rebreak"."streak_events" ( + "id" UUID NOT NULL, + "user_id" UUID NOT NULL, + "type" TEXT NOT NULL, + "meta" JSONB, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "streak_events_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "rebreak"."urge_logs" ( + "id" UUID NOT NULL, + "user_id" UUID NOT NULL, + "timestamp" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "emotion" TEXT NOT NULL, + "was_overcome" BOOLEAN NOT NULL DEFAULT false, + "breathing_done" BOOLEAN NOT NULL DEFAULT false, + + CONSTRAINT "urge_logs_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "rebreak"."community_posts" ( + "id" UUID NOT NULL, + "user_id" UUID NOT NULL, + "category" TEXT NOT NULL, + "content" TEXT NOT NULL, + "image_url" TEXT, + "upvotes" INTEGER NOT NULL DEFAULT 0, + "likes_count" INTEGER NOT NULL DEFAULT 0, + "dislikes_count" INTEGER NOT NULL DEFAULT 0, + "comments_count" INTEGER NOT NULL DEFAULT 0, + "reposts_count" INTEGER NOT NULL DEFAULT 0, + "is_anonymous" BOOLEAN NOT NULL DEFAULT false, + "is_moderated" BOOLEAN NOT NULL DEFAULT false, + "repost_of_id" UUID, + "challenge_id" UUID, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "community_posts_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "rebreak"."post_likes" ( + "user_id" UUID NOT NULL, + "post_id" UUID NOT NULL, + "type" TEXT NOT NULL, + + CONSTRAINT "post_likes_pkey" PRIMARY KEY ("user_id","post_id") +); + +-- CreateTable +CREATE TABLE "rebreak"."community_replies" ( + "id" UUID NOT NULL, + "post_id" UUID NOT NULL, + "user_id" UUID NOT NULL, + "content" TEXT NOT NULL, + "parent_reply_id" UUID, + "is_anonymous" BOOLEAN NOT NULL DEFAULT false, + "likes_count" INTEGER NOT NULL DEFAULT 0, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "community_replies_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "rebreak"."comment_likes" ( + "user_id" UUID NOT NULL, + "comment_id" UUID NOT NULL, + + CONSTRAINT "comment_likes_pkey" PRIMARY KEY ("user_id","comment_id") +); + +-- CreateTable +CREATE TABLE "rebreak"."chat_messages" ( + "id" UUID NOT NULL, + "user_id" UUID NOT NULL, + "content" TEXT NOT NULL, + "room_id" UUID, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "chat_messages_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "rebreak"."direct_messages" ( + "id" UUID NOT NULL, + "sender_id" UUID NOT NULL, + "receiver_id" UUID NOT NULL, + "content" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "read_at" TIMESTAMP(3), + + CONSTRAINT "direct_messages_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "rebreak"."user_follows" ( + "follower_id" UUID NOT NULL, + "following_id" UUID NOT NULL, + + CONSTRAINT "user_follows_pkey" PRIMARY KEY ("follower_id","following_id") +); + +-- CreateTable +CREATE TABLE "rebreak"."user_scores" ( + "user_id" UUID NOT NULL, + "total_points" INTEGER NOT NULL DEFAULT 0, + "tier" TEXT NOT NULL DEFAULT 'beginner', + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "user_scores_pkey" PRIMARY KEY ("user_id") +); + +-- CreateTable +CREATE TABLE "rebreak"."score_events" ( + "id" UUID NOT NULL, + "user_id" UUID NOT NULL, + "event_type" TEXT NOT NULL, + "points" INTEGER NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "meta" JSONB, + + CONSTRAINT "score_events_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "rebreak"."user_custom_domains" ( + "id" UUID NOT NULL, + "user_id" UUID NOT NULL, + "domain" TEXT NOT NULL, + "source" TEXT NOT NULL DEFAULT 'manual', + "status" TEXT NOT NULL DEFAULT 'active', + "post_id" UUID, + "added_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "user_custom_domains_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "rebreak"."domain_submissions" ( + "id" UUID NOT NULL, + "user_id" UUID NOT NULL, + "domain" TEXT NOT NULL, + "custom_domain_id" UUID NOT NULL, + "post_id" UUID, + "status" TEXT NOT NULL DEFAULT 'pending', + "yes_votes" INTEGER NOT NULL DEFAULT 0, + "no_votes" INTEGER NOT NULL DEFAULT 0, + "review_note" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "reviewed_at" TIMESTAMP(3), + + CONSTRAINT "domain_submissions_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "rebreak"."domain_votes" ( + "user_id" UUID NOT NULL, + "submission_id" UUID NOT NULL, + "vote" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "domain_votes_pkey" PRIMARY KEY ("user_id","submission_id") +); + +-- CreateTable +CREATE TABLE "rebreak"."feedback_items" ( + "id" UUID NOT NULL, + "user_id" UUID NOT NULL, + "content" TEXT NOT NULL, + "category" TEXT, + "status" "rebreak"."FeedbackStatus" NOT NULL DEFAULT 'PENDING', + "admin_note" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "feedback_items_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "rebreak"."blocklist_domains" ( + "id" UUID NOT NULL, + "domain" TEXT NOT NULL, + "source" TEXT NOT NULL, + "is_active" BOOLEAN NOT NULL DEFAULT true, + "report_count" INTEGER NOT NULL DEFAULT 0, + + CONSTRAINT "blocklist_domains_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "rebreak"."trusted_contacts" ( + "id" UUID NOT NULL, + "user_id" UUID NOT NULL, + "name" TEXT NOT NULL, + "phone" TEXT, + "email" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "trusted_contacts_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "rebreak"."coach_sessions" ( + "id" UUID NOT NULL, + "user_id" UUID NOT NULL, + "content" JSONB NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "coach_sessions_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "rebreak"."mail_connections" ( + "id" UUID NOT NULL, + "user_id" UUID NOT NULL, + "email" TEXT NOT NULL, + "provider" TEXT NOT NULL DEFAULT 'imap', + "provider_name" TEXT, + "imap_host" TEXT NOT NULL, + "imap_port" INTEGER NOT NULL, + "password_encrypted" TEXT NOT NULL, + "is_active" BOOLEAN NOT NULL DEFAULT true, + "scan_interval" INTEGER NOT NULL DEFAULT 24, + "last_scanned_at" TIMESTAMP(3), + "next_scan_at" TIMESTAMP(3), + "emails_blocked" INTEGER NOT NULL DEFAULT 0, + "emails_scanned" INTEGER NOT NULL DEFAULT 0, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "mail_connections_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "rebreak"."game_challenges" ( + "id" UUID NOT NULL, + "challenger_id" UUID NOT NULL, + "challenger_name" TEXT NOT NULL, + "opponent_id" UUID, + "opponent_name" TEXT, + "status" "rebreak"."GameChallengeStatus" NOT NULL DEFAULT 'OPEN', + "board" TEXT NOT NULL DEFAULT '---------', + "current_turn" TEXT NOT NULL DEFAULT 'X', + "winner" TEXT, + "post_id" UUID, + "game_type" TEXT NOT NULL DEFAULT 'tictactoe', + "is_live" BOOLEAN NOT NULL DEFAULT false, + "memory_state" JSONB, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "game_challenges_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "rebreak"."notifications" ( + "id" UUID NOT NULL, + "recipient_id" UUID NOT NULL, + "type" TEXT NOT NULL, + "actor_name" TEXT NOT NULL, + "post_id" UUID, + "preview" TEXT, + "read_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "notifications_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "rebreak"."game_scores" ( + "user_id" UUID NOT NULL, + "player_name" TEXT NOT NULL, + "wins" INTEGER NOT NULL DEFAULT 0, + "losses" INTEGER NOT NULL DEFAULT 0, + "draws" INTEGER NOT NULL DEFAULT 0, + "points" INTEGER NOT NULL DEFAULT 0, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "game_scores_pkey" PRIMARY KEY ("user_id") +); + +-- CreateTable +CREATE TABLE "rebreak"."mail_blocked" ( + "id" UUID NOT NULL, + "user_id" UUID NOT NULL, + "connection_id" UUID NOT NULL, + "gmail_message_id" TEXT NOT NULL, + "sender_email" TEXT NOT NULL, + "sender_name" TEXT, + "subject" TEXT NOT NULL, + "received_at" TIMESTAMP(3) NOT NULL, + "action" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "mail_blocked_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "rebreak"."imap_proxy_accounts" ( + "id" UUID NOT NULL, + "user_id" UUID NOT NULL, + "proxy_username" TEXT NOT NULL, + "proxy_password" TEXT NOT NULL, + "connection_id" UUID NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "imap_proxy_accounts_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "user_custom_domains_user_id_domain_key" ON "rebreak"."user_custom_domains"("user_id", "domain"); + +-- CreateIndex +CREATE UNIQUE INDEX "domain_submissions_custom_domain_id_key" ON "rebreak"."domain_submissions"("custom_domain_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "blocklist_domains_domain_key" ON "rebreak"."blocklist_domains"("domain"); + +-- CreateIndex +CREATE UNIQUE INDEX "mail_connections_user_id_email_key" ON "rebreak"."mail_connections"("user_id", "email"); + +-- CreateIndex +CREATE INDEX "notifications_recipient_id_read_at_idx" ON "rebreak"."notifications"("recipient_id", "read_at"); + +-- CreateIndex +CREATE UNIQUE INDEX "mail_blocked_gmail_message_id_user_id_key" ON "rebreak"."mail_blocked"("gmail_message_id", "user_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "imap_proxy_accounts_proxy_username_key" ON "rebreak"."imap_proxy_accounts"("proxy_username"); + +-- CreateIndex +CREATE UNIQUE INDEX "imap_proxy_accounts_connection_id_key" ON "rebreak"."imap_proxy_accounts"("connection_id"); + +-- AddForeignKey +ALTER TABLE "rebreak"."community_posts" ADD CONSTRAINT "community_posts_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "rebreak"."profiles"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "rebreak"."community_posts" ADD CONSTRAINT "community_posts_repost_of_id_fkey" FOREIGN KEY ("repost_of_id") REFERENCES "rebreak"."community_posts"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "rebreak"."post_likes" ADD CONSTRAINT "post_likes_post_id_fkey" FOREIGN KEY ("post_id") REFERENCES "rebreak"."community_posts"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "rebreak"."community_replies" ADD CONSTRAINT "community_replies_post_id_fkey" FOREIGN KEY ("post_id") REFERENCES "rebreak"."community_posts"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "rebreak"."community_replies" ADD CONSTRAINT "community_replies_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "rebreak"."profiles"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "rebreak"."comment_likes" ADD CONSTRAINT "comment_likes_comment_id_fkey" FOREIGN KEY ("comment_id") REFERENCES "rebreak"."community_replies"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "rebreak"."domain_submissions" ADD CONSTRAINT "domain_submissions_custom_domain_id_fkey" FOREIGN KEY ("custom_domain_id") REFERENCES "rebreak"."user_custom_domains"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "rebreak"."domain_votes" ADD CONSTRAINT "domain_votes_submission_id_fkey" FOREIGN KEY ("submission_id") REFERENCES "rebreak"."domain_submissions"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "rebreak"."mail_blocked" ADD CONSTRAINT "mail_blocked_connection_id_fkey" FOREIGN KEY ("connection_id") REFERENCES "rebreak"."mail_connections"("id") ON DELETE CASCADE ON UPDATE CASCADE; + diff --git a/backend/prisma/migrations/20250712_chat_rooms_and_features/migration.sql b/backend/prisma/migrations/20250712_chat_rooms_and_features/migration.sql new file mode 100644 index 0000000..afd1d82 --- /dev/null +++ b/backend/prisma/migrations/20250712_chat_rooms_and_features/migration.sql @@ -0,0 +1,79 @@ +-- CreateTable: chat_rooms +CREATE TABLE "rebreak"."chat_rooms" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "name" TEXT NOT NULL, + "description" TEXT, + "is_public" BOOLEAN NOT NULL DEFAULT false, + "avatar_url" TEXT, + "created_by" UUID NOT NULL, + "join_mode" TEXT NOT NULL DEFAULT 'open', + "invite_code" TEXT, + "member_count" INTEGER NOT NULL DEFAULT 0, + "is_default" BOOLEAN NOT NULL DEFAULT false, + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ NOT NULL, + + CONSTRAINT "chat_rooms_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex: chat_rooms.invite_code +CREATE UNIQUE INDEX "chat_rooms_invite_code_key" ON "rebreak"."chat_rooms"("invite_code"); + +-- CreateTable: chat_room_members +CREATE TABLE "rebreak"."chat_room_members" ( + "room_id" UUID NOT NULL, + "user_id" UUID NOT NULL, + "role" TEXT NOT NULL DEFAULT 'member', + "status" TEXT NOT NULL DEFAULT 'active', + "joined_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "chat_room_members_pkey" PRIMARY KEY ("room_id","user_id") +); + +-- AlterTable: chat_messages – neue Spalten +ALTER TABLE "rebreak"."chat_messages" ADD COLUMN "reply_to_id" UUID; +ALTER TABLE "rebreak"."chat_messages" ADD COLUMN "attachment_url" TEXT; +ALTER TABLE "rebreak"."chat_messages" ADD COLUMN "attachment_type" TEXT; +ALTER TABLE "rebreak"."chat_messages" ADD COLUMN "attachment_name" TEXT; +ALTER TABLE "rebreak"."chat_messages" ADD COLUMN "likes_count" INTEGER NOT NULL DEFAULT 0; + +-- CreateTable: chat_message_likes +CREATE TABLE "rebreak"."chat_message_likes" ( + "user_id" UUID NOT NULL, + "message_id" UUID NOT NULL, + + CONSTRAINT "chat_message_likes_pkey" PRIMARY KEY ("user_id","message_id") +); + +-- AlterTable: direct_messages – neue Spalten +ALTER TABLE "rebreak"."direct_messages" ADD COLUMN "reply_to_id" UUID; +ALTER TABLE "rebreak"."direct_messages" ADD COLUMN "attachment_url" TEXT; +ALTER TABLE "rebreak"."direct_messages" ADD COLUMN "attachment_type" TEXT; +ALTER TABLE "rebreak"."direct_messages" ADD COLUMN "attachment_name" TEXT; +ALTER TABLE "rebreak"."direct_messages" ADD COLUMN "likes_count" INTEGER NOT NULL DEFAULT 0; + +-- CreateTable: direct_message_likes +CREATE TABLE "rebreak"."direct_message_likes" ( + "user_id" UUID NOT NULL, + "message_id" UUID NOT NULL, + + CONSTRAINT "direct_message_likes_pkey" PRIMARY KEY ("user_id","message_id") +); + +-- AddForeignKey: chat_room_members -> chat_rooms +ALTER TABLE "rebreak"."chat_room_members" ADD CONSTRAINT "chat_room_members_room_id_fkey" FOREIGN KEY ("room_id") REFERENCES "rebreak"."chat_rooms"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey: chat_messages -> chat_rooms +ALTER TABLE "rebreak"."chat_messages" ADD CONSTRAINT "chat_messages_room_id_fkey" FOREIGN KEY ("room_id") REFERENCES "rebreak"."chat_rooms"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey: chat_messages self-reply +ALTER TABLE "rebreak"."chat_messages" ADD CONSTRAINT "chat_messages_reply_to_id_fkey" FOREIGN KEY ("reply_to_id") REFERENCES "rebreak"."chat_messages"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey: chat_message_likes -> chat_messages +ALTER TABLE "rebreak"."chat_message_likes" ADD CONSTRAINT "chat_message_likes_message_id_fkey" FOREIGN KEY ("message_id") REFERENCES "rebreak"."chat_messages"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey: direct_messages self-reply +ALTER TABLE "rebreak"."direct_messages" ADD CONSTRAINT "direct_messages_reply_to_id_fkey" FOREIGN KEY ("reply_to_id") REFERENCES "rebreak"."direct_messages"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey: direct_message_likes -> direct_messages +ALTER TABLE "rebreak"."direct_message_likes" ADD CONSTRAINT "direct_message_likes_message_id_fkey" FOREIGN KEY ("message_id") REFERENCES "rebreak"."direct_messages"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/backend/prisma/migrations/20260422_game_high_scores/migration.sql b/backend/prisma/migrations/20260422_game_high_scores/migration.sql new file mode 100644 index 0000000..78238ea --- /dev/null +++ b/backend/prisma/migrations/20260422_game_high_scores/migration.sql @@ -0,0 +1,18 @@ +-- CreateTable: game_high_scores (best score per user per game) +CREATE TABLE IF NOT EXISTS rebreak.game_high_scores ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "user_id" UUID NOT NULL, + "nickname" TEXT NOT NULL, + "game_name" TEXT NOT NULL, + "score" INTEGER NOT NULL, + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "game_high_scores_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX IF NOT EXISTS "game_high_scores_user_id_game_name_key" + ON rebreak.game_high_scores ("user_id", "game_name"); + +CREATE INDEX IF NOT EXISTS "game_high_scores_game_name_score_idx" + ON rebreak.game_high_scores ("game_name", "score" DESC); diff --git a/backend/prisma/migrations/20260422_game_ratings/migration.sql b/backend/prisma/migrations/20260422_game_ratings/migration.sql new file mode 100644 index 0000000..ac59730 --- /dev/null +++ b/backend/prisma/migrations/20260422_game_ratings/migration.sql @@ -0,0 +1,12 @@ +-- CreateTable +CREATE TABLE IF NOT EXISTS rebreak.game_ratings ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "user_id" UUID NOT NULL, + "game_name" TEXT NOT NULL, + "stars" INTEGER NOT NULL, + "feedback" TEXT, + "score" INTEGER NOT NULL DEFAULT 0, + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "game_ratings_pkey" PRIMARY KEY ("id") +); diff --git a/backend/prisma/migrations/20260426_add_actor_avatar_to_notifications/migration.sql b/backend/prisma/migrations/20260426_add_actor_avatar_to_notifications/migration.sql new file mode 100644 index 0000000..30706d5 --- /dev/null +++ b/backend/prisma/migrations/20260426_add_actor_avatar_to_notifications/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable: notifications – actor_avatar Spalte hinzufügen +ALTER TABLE rebreak.notifications + ADD COLUMN IF NOT EXISTS "actor_avatar" TEXT; diff --git a/backend/prisma/migrations/20260428_add_cooldown_requests/migration.sql b/backend/prisma/migrations/20260428_add_cooldown_requests/migration.sql new file mode 100644 index 0000000..1ebe764 --- /dev/null +++ b/backend/prisma/migrations/20260428_add_cooldown_requests/migration.sql @@ -0,0 +1,24 @@ +-- CooldownRequest table — tracks user-initiated cooldowns before disabling protection. +-- Backed by `model CooldownRequest` in schema.prisma. +-- +-- Drift-Fix: diese Migration wurde nachträglich angelegt (2026-04-28). Auf +-- staging-DB wurde die Tabelle manuell via psql erstellt. Auf neuen DBs (Prod- +-- Migration, Dev-Setup) sollte diese Migration laufen. +-- +-- Wenn ein DB schon die Tabelle hat (Drift): nach dem ersten `prisma migrate deploy` +-- führe einmalig aus: +-- pnpm prisma migrate resolve --applied 20260428_add_cooldown_requests + +CREATE TABLE IF NOT EXISTS "rebreak"."cooldown_requests" ( + "id" UUID PRIMARY KEY DEFAULT gen_random_uuid(), + "user_id" UUID NOT NULL, + "reason" TEXT, + "cooldown_started_at" TIMESTAMP(3) WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + "cooldown_ends_at" TIMESTAMP(3) WITH TIME ZONE NOT NULL, + "resolved_at" TIMESTAMP(3) WITH TIME ZONE, + "cancelled_at" TIMESTAMP(3) WITH TIME ZONE, + "token_jti" TEXT NOT NULL UNIQUE +); + +CREATE INDEX IF NOT EXISTS "cooldown_requests_user_id_cooldown_ends_at_idx" + ON "rebreak"."cooldown_requests"("user_id", "cooldown_ends_at"); diff --git a/backend/prisma/migrations/20260430_add_custom_imap_tls/migration.sql b/backend/prisma/migrations/20260430_add_custom_imap_tls/migration.sql new file mode 100644 index 0000000..3a12510 --- /dev/null +++ b/backend/prisma/migrations/20260430_add_custom_imap_tls/migration.sql @@ -0,0 +1,17 @@ +-- Migration: add_custom_imap_tls +-- Fügt zwei TLS-Steuerfelder zur mail_connections-Tabelle hinzu. +-- Defaults entsprechen dem bisherigen Verhalten → keine Breaking Changes für bestehende Rows. +-- reject_unauthorized = true → TLS-Cert wird wie bisher validiert +-- use_starttls = false → implizites TLS (Port 993) wie bisher + +ALTER TABLE rebreak.mail_connections + ADD COLUMN IF NOT EXISTS reject_unauthorized BOOLEAN NOT NULL DEFAULT true, + ADD COLUMN IF NOT EXISTS use_starttls BOOLEAN NOT NULL DEFAULT false; + +COMMENT ON COLUMN rebreak.mail_connections.reject_unauthorized IS + 'TLS-Zertifikat-Validierung. false nur für interne/self-signed IMAP-Server.'; + +COMMENT ON COLUMN rebreak.mail_connections.use_starttls IS + 'true = STARTTLS (Port 143/587, Verbindung startet unverschlüsselt). false = implicit TLS (Port 993).'; + +NOTIFY pgrst, 'reload schema'; diff --git a/backend/prisma/migrations/20260430_add_user_devices/migration.sql b/backend/prisma/migrations/20260430_add_user_devices/migration.sql new file mode 100644 index 0000000..fca5551 --- /dev/null +++ b/backend/prisma/migrations/20260430_add_user_devices/migration.sql @@ -0,0 +1,23 @@ +-- UserDevice table — Device-Binding pro User (Anti-Account-Sharing). +-- Backed by `model UserDevice` in schema.prisma. +-- +-- Limits siehe plan-features.ts maxDevices: Free=1, Pro=1, Legend=3. +-- Frontend liefert deviceId via Capacitor Device.getId() (persistent UUID). +-- Auth-Middleware enforced via x-device-id Header. + +CREATE TABLE IF NOT EXISTS "rebreak"."user_devices" ( + "id" UUID PRIMARY KEY DEFAULT gen_random_uuid(), + "user_id" UUID NOT NULL, + "device_id" TEXT NOT NULL, + "platform" TEXT NOT NULL, + "model" TEXT, + "name" TEXT, + "last_seen_at" TIMESTAMP(3) WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + "created_at" TIMESTAMP(3) WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE UNIQUE INDEX IF NOT EXISTS "user_devices_user_id_device_id_key" + ON "rebreak"."user_devices"("user_id", "device_id"); + +CREATE INDEX IF NOT EXISTS "user_devices_user_id_idx" + ON "rebreak"."user_devices"("user_id"); diff --git a/backend/prisma/migrations/20260504_add_lyra_memories/migration.sql b/backend/prisma/migrations/20260504_add_lyra_memories/migration.sql new file mode 100644 index 0000000..0b1edc1 --- /dev/null +++ b/backend/prisma/migrations/20260504_add_lyra_memories/migration.sql @@ -0,0 +1,53 @@ +-- LyraMemory — strukturierte persistente User-Erinnerungen für den Lyra-Coach. +-- Art-9-Gesundheitsdaten (Glücksspielkontext): strenge RLS, nur service-role schreibt. +-- +-- Deploy: pnpm prisma migrate deploy (auf Hetzner) + +-- CreateEnum +CREATE TYPE "rebreak"."LyraMemoryType" AS ENUM ( + 'trigger', + 'habit', + 'strength', + 'relationship', + 'milestone', + 'pain_point', + 'goal', + 'preference' +); + +-- CreateTable +CREATE TABLE IF NOT EXISTS "rebreak"."lyra_memories" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "user_id" UUID NOT NULL, + "type" "rebreak"."LyraMemoryType" NOT NULL, + "content" VARCHAR(500) NOT NULL, + "confidence" DOUBLE PRECISION NOT NULL DEFAULT 0.7, + "source" TEXT, + "created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(), + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(), + "last_referenced_at" TIMESTAMPTZ, + + CONSTRAINT "lyra_memories_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX IF NOT EXISTS "lyra_memories_user_id_type_idx" + ON "rebreak"."lyra_memories" ("user_id", "type"); + +-- EnableRLS +ALTER TABLE "rebreak"."lyra_memories" ENABLE ROW LEVEL SECURITY; + +-- Policy: User kann eigene Memories lesen (vorbereitet für künftiges User-UI) +CREATE POLICY "lyra_memories: own read" + ON "rebreak"."lyra_memories" + FOR SELECT + USING (auth.uid() = "user_id"); + +-- Policy: Service-Role darf alles (Auto-Extraction + Cleanup) +-- Hinweis: Supabase service_role bypassed RLS automatisch wenn +-- die Verbindung mit service_role JWT erfolgt — diese Policy ist +-- ein explizites Fallback für Tools die das nicht tun. +CREATE POLICY "lyra_memories: service all" + ON "rebreak"."lyra_memories" + USING (auth.role() = 'service_role') + WITH CHECK (auth.role() = 'service_role'); diff --git a/backend/prisma/migrations/20260504_sos_sessions/migration.sql b/backend/prisma/migrations/20260504_sos_sessions/migration.sql new file mode 100644 index 0000000..579ea56 --- /dev/null +++ b/backend/prisma/migrations/20260504_sos_sessions/migration.sql @@ -0,0 +1,22 @@ +-- CreateTable: sos_sessions (Verlauf einer SOS-Session für DiGA-Doku) +CREATE TABLE IF NOT EXISTS rebreak.sos_sessions ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "user_id" UUID NOT NULL, + "started_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "ended_at" TIMESTAMPTZ, + "duration_sec" INTEGER, + "messages" JSONB NOT NULL DEFAULT '[]'::jsonb, + "gamesPlayed" JSONB NOT NULL DEFAULT '[]'::jsonb, + "breathing_count" INTEGER NOT NULL DEFAULT 0, + "was_overcome" BOOLEAN NOT NULL DEFAULT false, + "feedback_better" BOOLEAN, + "feedback_rating" INTEGER, + "feedback_text" TEXT, + "locale" TEXT, + + CONSTRAINT "sos_sessions_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX IF NOT EXISTS "sos_sessions_user_id_started_at_idx" + ON rebreak.sos_sessions ("user_id", "started_at" DESC); diff --git a/backend/prisma/migrations/add_domain_submissions.sql b/backend/prisma/migrations/add_domain_submissions.sql new file mode 100644 index 0000000..b9b94c2 --- /dev/null +++ b/backend/prisma/migrations/add_domain_submissions.sql @@ -0,0 +1,38 @@ +-- Migration: Domain Submission Feature +-- Adds status + postId to user_custom_domains +-- Adds domain_submissions and domain_votes tables + +SET search_path TO rebreak; + +-- 1. Add status + postId to existing custom domains +ALTER TABLE user_custom_domains + ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'active', + ADD COLUMN IF NOT EXISTS post_id UUID; + +-- 2. Domain submissions table (admin + community review) +CREATE TABLE IF NOT EXISTS domain_submissions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL, + domain TEXT NOT NULL, + custom_domain_id UUID NOT NULL UNIQUE REFERENCES user_custom_domains(id) ON DELETE CASCADE, + post_id UUID, + status TEXT NOT NULL DEFAULT 'pending', + yes_votes INT NOT NULL DEFAULT 0, + no_votes INT NOT NULL DEFAULT 0, + review_note TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + reviewed_at TIMESTAMPTZ +); + +-- 3. Domain votes table (one vote per user per submission) +CREATE TABLE IF NOT EXISTS domain_votes ( + user_id UUID NOT NULL, + submission_id UUID NOT NULL REFERENCES domain_submissions(id) ON DELETE CASCADE, + vote TEXT NOT NULL, -- 'yes' | 'no' + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (user_id, submission_id) +); + +CREATE INDEX IF NOT EXISTS idx_domain_submissions_status ON domain_submissions(status); +CREATE INDEX IF NOT EXISTS idx_domain_submissions_user_id ON domain_submissions(user_id); +CREATE INDEX IF NOT EXISTS idx_domain_votes_submission ON domain_votes(submission_id); diff --git a/backend/prisma/migrations/add_feedback_items.sql b/backend/prisma/migrations/add_feedback_items.sql new file mode 100644 index 0000000..cf39fd0 --- /dev/null +++ b/backend/prisma/migrations/add_feedback_items.sql @@ -0,0 +1,25 @@ +-- Add feedback_items table for Lyra Feedback-Loop feature +-- Users' feedback from coaching chat is auto-detected, stored here, and +-- Lyra proactively informs users when their idea status changes. + +CREATE TYPE rebreak."FeedbackStatus" AS ENUM ( + 'PENDING', + 'REVIEWING', + 'PLANNED', + 'SHIPPED', + 'REJECTED' +); + +CREATE TABLE rebreak."feedback_items" ( + "id" UUID PRIMARY KEY DEFAULT gen_random_uuid(), + "user_id" UUID NOT NULL, + "content" TEXT NOT NULL, + "category" TEXT, + "status" rebreak."FeedbackStatus" NOT NULL DEFAULT 'PENDING', + "admin_note" TEXT, + "created_at" TIMESTAMPTZ NOT NULL DEFAULT now(), + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX "feedback_items_user_id_idx" ON rebreak."feedback_items" ("user_id"); +CREATE INDEX "feedback_items_status_idx" ON rebreak."feedback_items" ("status"); diff --git a/backend/prisma/migrations/add_game_challenges.sql b/backend/prisma/migrations/add_game_challenges.sql new file mode 100644 index 0000000..91ac22e --- /dev/null +++ b/backend/prisma/migrations/add_game_challenges.sql @@ -0,0 +1,35 @@ +-- Add 1v1 Tic-Tac-Toe challenge system +-- Players can challenge each other via community posts; game state is synced via Supabase Realtime. + +-- Add challengeId to community_posts +ALTER TABLE rebreak.community_posts ADD COLUMN IF NOT EXISTS challenge_id UUID; + +-- GameChallengeStatus enum +DO $$ BEGIN + CREATE TYPE rebreak."GameChallengeStatus" AS ENUM ('OPEN', 'ACTIVE', 'FINISHED', 'CANCELLED'); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +-- game_challenges table +CREATE TABLE IF NOT EXISTS rebreak.game_challenges ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + challenger_id UUID NOT NULL, + challenger_name TEXT NOT NULL, + opponent_id UUID, + opponent_name TEXT, + status rebreak."GameChallengeStatus" NOT NULL DEFAULT 'OPEN', + board TEXT NOT NULL DEFAULT '---------', + current_turn TEXT NOT NULL DEFAULT 'X', + winner TEXT, + post_id UUID, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS game_challenges_challenger_idx ON rebreak.game_challenges (challenger_id); +CREATE INDEX IF NOT EXISTS game_challenges_opponent_idx ON rebreak.game_challenges (opponent_id); +CREATE INDEX IF NOT EXISTS game_challenges_status_idx ON rebreak.game_challenges (status); + +-- Enable Supabase Realtime for live game sync +ALTER PUBLICATION supabase_realtime ADD TABLE rebreak.game_challenges; diff --git a/backend/prisma/migrations/add_game_challenges_rls.sql b/backend/prisma/migrations/add_game_challenges_rls.sql new file mode 100644 index 0000000..2df5d8c --- /dev/null +++ b/backend/prisma/migrations/add_game_challenges_rls.sql @@ -0,0 +1,20 @@ +-- Enable RLS on game_challenges so Supabase Realtime can use auth.uid() for row filtering +-- Without RLS, Realtime falls back to an empty role which causes "role "" does not exist" errors + +ALTER TABLE rebreak.game_challenges ENABLE ROW LEVEL SECURITY; + +-- Both players can read the game they are part of +CREATE POLICY "players can read their game" ON rebreak.game_challenges + FOR SELECT USING ( + auth.uid() = challenger_id OR auth.uid() = opponent_id + ); + +-- Only the challenger can create the game row +CREATE POLICY "challenger can create game" ON rebreak.game_challenges + FOR INSERT WITH CHECK (auth.uid() = challenger_id); + +-- Both players can update the game (make moves, accept/cancel) +CREATE POLICY "players can update their game" ON rebreak.game_challenges + FOR UPDATE USING ( + auth.uid() = challenger_id OR auth.uid() = opponent_id + ); diff --git a/backend/prisma/migrations/add_streak_events.sql b/backend/prisma/migrations/add_streak_events.sql new file mode 100644 index 0000000..1ebfdfe --- /dev/null +++ b/backend/prisma/migrations/add_streak_events.sql @@ -0,0 +1,16 @@ +-- Streak Events Tabelle für Timeline/Verlauf +CREATE TABLE IF NOT EXISTS rebreak.streak_events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + type TEXT NOT NULL, -- 'started' | 'reset' | 'milestone' | 'relapse' + meta JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_streak_events_user ON rebreak.streak_events(user_id, created_at DESC); + +-- RLS +ALTER TABLE rebreak.streak_events ENABLE ROW LEVEL SECURITY; +CREATE POLICY "streak_events: own all" + ON rebreak.streak_events FOR ALL + USING (auth.uid() = user_id); diff --git a/backend/prisma/migrations/fix_user_custom_domains_unique_constraint.sql b/backend/prisma/migrations/fix_user_custom_domains_unique_constraint.sql new file mode 100644 index 0000000..b46f08b --- /dev/null +++ b/backend/prisma/migrations/fix_user_custom_domains_unique_constraint.sql @@ -0,0 +1,10 @@ +-- Fix: user_custom_domains unique constraint +-- Vorher: UNIQUE(domain) → eine Domain konnte nur von einem User hinzugefügt werden +-- Nachher: UNIQUE(user_id, domain) → jeder User kann eigene Domain-Liste führen + +-- Schritt 1: Alte globale unique constraint entfernen +DROP INDEX IF EXISTS rebreak."user_custom_domains_domain_key"; + +-- Schritt 2: Neue composite unique constraint auf (user_id, domain) +CREATE UNIQUE INDEX "user_custom_domains_userId_domain_key" + ON rebreak."user_custom_domains" ("user_id", "domain"); diff --git a/backend/prisma/migrations/remove_nickname_avatar_from_profiles.sql b/backend/prisma/migrations/remove_nickname_avatar_from_profiles.sql new file mode 100644 index 0000000..a3a7b85 --- /dev/null +++ b/backend/prisma/migrations/remove_nickname_avatar_from_profiles.sql @@ -0,0 +1,7 @@ +-- Migration: nickname und avatar wieder in rebreak.profiles aufnehmen +-- Grund: Prisma include-Relationen für Posts/Comments benötigen diese Felder in der DB. +-- Strategie: profiles = sync-Cache, user_metadata = src of truth für Auth. +-- Beim Update wird in BEIDE geschrieben (me.patch.ts + avatar/upload.post.ts) + +ALTER TABLE rebreak.profiles ADD COLUMN IF NOT EXISTS avatar TEXT; +ALTER TABLE rebreak.profiles ADD COLUMN IF NOT EXISTS nickname TEXT; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma new file mode 100644 index 0000000..b5d2827 --- /dev/null +++ b/backend/prisma/schema.prisma @@ -0,0 +1,604 @@ +generator client { + provider = "prisma-client-js" + output = "../server/generated/prisma" + binaryTargets = ["native", "linux-musl-openssl-3.0.x"] +} + +datasource db { + provider = "postgresql" + schemas = ["rebreak"] +} + +model Profile { + id String @id @db.Uuid + username String? + nickname String? + avatar String? + plan String @default("free") + streak Int @default(0) + followersCount Int @default(0) @map("followers_count") + stripeCustomerId String? @map("stripe_customer_id") + stripeSubId String? @map("stripe_subscription_id") + premiumUntil DateTime? @map("premium_until") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") + + communityPosts CommunityPost[] + communityReplies CommunityReply[] + + @@map("profiles") + @@schema("rebreak") +} + +model Streak { + id String @id @default(uuid()) @db.Uuid + userId String @map("user_id") @db.Uuid + startDate DateTime @map("start_date") @db.Date + currentDays Int @default(0) @map("current_days") + longestDays Int @default(0) @map("longest_days") + avgMonthlySavings Float? @map("avg_monthly_savings") + isActive Boolean @default(true) @map("is_active") + + @@map("streaks") + @@schema("rebreak") +} + +model StreakEvent { + id String @id @default(uuid()) @db.Uuid + userId String @map("user_id") @db.Uuid + type String // "started" | "reset" | "milestone" | "relapse" + meta Json? // z.B. { days: 30 } für Meilensteine + createdAt DateTime @default(now()) @map("created_at") + + @@map("streak_events") + @@schema("rebreak") +} + +model UrgeLog { + id String @id @default(uuid()) @db.Uuid + userId String @map("user_id") @db.Uuid + timestamp DateTime @default(now()) + emotion String + wasOvercome Boolean @default(false) @map("was_overcome") + breathingDone Boolean @default(false) @map("breathing_done") + + @@map("urge_logs") + @@schema("rebreak") +} + +/// SOS-Session für DiGA-Auswertung — kompletter Verlauf einer Notfall-Session +model SosSession { + id String @id @default(uuid()) @db.Uuid + userId String @map("user_id") @db.Uuid + startedAt DateTime @default(now()) @map("started_at") + endedAt DateTime? @map("ended_at") + durationSec Int? @map("duration_sec") + /// Voller Chat-Verlauf [{role, content, timestamp}] + messages Json @default("[]") + /// [{game, score, durationSec}] + gamesPlayed Json @default("[]") + breathingCount Int @default(0) @map("breathing_count") + wasOvercome Boolean @default(false) @map("was_overcome") + feedbackBetter Boolean? @map("feedback_better") + feedbackRating Int? @map("feedback_rating") // 1-5 + feedbackText String? @map("feedback_text") + locale String? + + @@index([userId, startedAt]) + @@map("sos_sessions") + @@schema("rebreak") +} + +model CommunityPost { + id String @id @default(uuid()) @db.Uuid + userId String @map("user_id") @db.Uuid + category String + content String + imageUrl String? @map("image_url") + upvotes Int @default(0) + likesCount Int @default(0) @map("likes_count") + dislikesCount Int @default(0) @map("dislikes_count") + commentsCount Int @default(0) @map("comments_count") + repostsCount Int @default(0) @map("reposts_count") + isAnonymous Boolean @default(false) @map("is_anonymous") + isModerated Boolean @default(false) @map("is_moderated") + repostOfId String? @map("repost_of_id") @db.Uuid + challengeId String? @map("challenge_id") @db.Uuid + createdAt DateTime @default(now()) @map("created_at") + + author Profile? @relation(fields: [userId], references: [id]) + repostOf CommunityPost? @relation("Reposts", fields: [repostOfId], references: [id], onDelete: SetNull) + reposts CommunityPost[] @relation("Reposts") + PostLike PostLike[] + CommunityReply CommunityReply[] + + @@map("community_posts") + @@schema("rebreak") +} + +model PostLike { + userId String @map("user_id") @db.Uuid + postId String @map("post_id") @db.Uuid + type String // "like" | "dislike" + + post CommunityPost @relation(fields: [postId], references: [id], onDelete: Cascade) + + @@id([userId, postId]) + @@map("post_likes") + @@schema("rebreak") +} + +model CommunityReply { + id String @id @default(uuid()) @db.Uuid + postId String @map("post_id") @db.Uuid + userId String @map("user_id") @db.Uuid + content String + parentReplyId String? @map("parent_reply_id") @db.Uuid + isAnonymous Boolean @default(false) @map("is_anonymous") + likesCount Int @default(0) @map("likes_count") + createdAt DateTime @default(now()) @map("created_at") + + post CommunityPost @relation(fields: [postId], references: [id], onDelete: Cascade) + author Profile? @relation(fields: [userId], references: [id]) + CommentLike CommentLike[] + + @@map("community_replies") + @@schema("rebreak") +} + +model CommentLike { + userId String @map("user_id") @db.Uuid + commentId String @map("comment_id") @db.Uuid + + reply CommunityReply @relation(fields: [commentId], references: [id], onDelete: Cascade) + + @@id([userId, commentId]) + @@map("comment_likes") + @@schema("rebreak") +} + +model ChatRoom { + id String @id @default(uuid()) @db.Uuid + name String + description String? + isPublic Boolean @default(false) @map("is_public") + avatarUrl String? @map("avatar_url") + createdBy String @map("created_by") @db.Uuid + joinMode String @default("open") @map("join_mode") // "open" | "approval" | "invite_only" + inviteCode String? @unique @map("invite_code") + memberCount Int @default(0) @map("member_count") + isDefault Boolean @default(false) @map("is_default") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + members ChatRoomMember[] + messages ChatMessage[] + + @@map("chat_rooms") + @@schema("rebreak") +} + +model ChatRoomMember { + roomId String @map("room_id") @db.Uuid + userId String @map("user_id") @db.Uuid + role String @default("member") // "owner" | "admin" | "member" + status String @default("active") // "active" | "pending" + joinedAt DateTime @default(now()) @map("joined_at") + + room ChatRoom @relation(fields: [roomId], references: [id], onDelete: Cascade) + + @@id([roomId, userId]) + @@map("chat_room_members") + @@schema("rebreak") +} + +model ChatMessage { + id String @id @default(uuid()) @db.Uuid + userId String @map("user_id") @db.Uuid + content String + roomId String? @map("room_id") @db.Uuid + replyToId String? @map("reply_to_id") @db.Uuid + attachmentUrl String? @map("attachment_url") + attachmentType String? @map("attachment_type") + attachmentName String? @map("attachment_name") + likesCount Int @default(0) @map("likes_count") + createdAt DateTime @default(now()) @map("created_at") + + room ChatRoom? @relation(fields: [roomId], references: [id], onDelete: Cascade) + replyTo ChatMessage? @relation("ChatReplies", fields: [replyToId], references: [id], onDelete: SetNull) + replies ChatMessage[] @relation("ChatReplies") + likes ChatMessageLike[] + + @@map("chat_messages") + @@schema("rebreak") +} + +model ChatMessageLike { + userId String @map("user_id") @db.Uuid + messageId String @map("message_id") @db.Uuid + + message ChatMessage @relation(fields: [messageId], references: [id], onDelete: Cascade) + + @@id([userId, messageId]) + @@map("chat_message_likes") + @@schema("rebreak") +} + +model DirectMessage { + id String @id @default(uuid()) @db.Uuid + senderId String @map("sender_id") @db.Uuid + receiverId String @map("receiver_id") @db.Uuid + content String + replyToId String? @map("reply_to_id") @db.Uuid + attachmentUrl String? @map("attachment_url") + attachmentType String? @map("attachment_type") + attachmentName String? @map("attachment_name") + likesCount Int @default(0) @map("likes_count") + createdAt DateTime @default(now()) @map("created_at") + readAt DateTime? @map("read_at") + + replyTo DirectMessage? @relation("DmReplies", fields: [replyToId], references: [id], onDelete: SetNull) + replies DirectMessage[] @relation("DmReplies") + likes DirectMessageLike[] + + @@map("direct_messages") + @@schema("rebreak") +} + +model DirectMessageLike { + userId String @map("user_id") @db.Uuid + messageId String @map("message_id") @db.Uuid + + message DirectMessage @relation(fields: [messageId], references: [id], onDelete: Cascade) + + @@id([userId, messageId]) + @@map("direct_message_likes") + @@schema("rebreak") +} + +model UserFollow { + followerId String @map("follower_id") @db.Uuid + followingId String @map("following_id") @db.Uuid + + @@id([followerId, followingId]) + @@map("user_follows") + @@schema("rebreak") +} + +model UserScore { + userId String @id @map("user_id") @db.Uuid + totalPoints Int @default(0) @map("total_points") + tier String @default("beginner") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") + + @@map("user_scores") + @@schema("rebreak") +} + +model ScoreEvent { + id String @id @default(uuid()) @db.Uuid + userId String @map("user_id") @db.Uuid + eventType String @map("event_type") + points Int + createdAt DateTime @default(now()) @map("created_at") + meta Json? + + @@map("score_events") + @@schema("rebreak") +} + +model UserCustomDomain { + id String @id @default(uuid()) @db.Uuid + userId String @map("user_id") @db.Uuid + domain String + source String @default("manual") + // "active" | "submitted" | "approved" | "rejected" + status String @default("active") + postId String? @map("post_id") @db.Uuid + addedAt DateTime @default(now()) @map("added_at") + + submission DomainSubmission? + + @@unique([userId, domain]) + @@map("user_custom_domains") + @@schema("rebreak") +} + +model DomainSubmission { + id String @id @default(uuid()) @db.Uuid + userId String @map("user_id") @db.Uuid + domain String + customDomainId String @unique @map("custom_domain_id") @db.Uuid + postId String? @map("post_id") @db.Uuid + // "pending" | "approved" | "rejected" + status String @default("pending") + yesVotes Int @default(0) @map("yes_votes") + noVotes Int @default(0) @map("no_votes") + reviewNote String? @map("review_note") + createdAt DateTime @default(now()) @map("created_at") + reviewedAt DateTime? @map("reviewed_at") + + customDomain UserCustomDomain @relation(fields: [customDomainId], references: [id], onDelete: Cascade) + votes DomainVote[] + + @@map("domain_submissions") + @@schema("rebreak") +} + +model DomainVote { + userId String @map("user_id") @db.Uuid + submissionId String @map("submission_id") @db.Uuid + vote String // "yes" | "no" + createdAt DateTime @default(now()) @map("created_at") + + submission DomainSubmission @relation(fields: [submissionId], references: [id], onDelete: Cascade) + + @@id([userId, submissionId]) + @@map("domain_votes") + @@schema("rebreak") +} + +enum FeedbackStatus { + PENDING + REVIEWING + PLANNED + SHIPPED + REJECTED + + @@schema("rebreak") +} + +model FeedbackItem { + id String @id @default(uuid()) @db.Uuid + userId String @map("user_id") @db.Uuid + content String + category String? + status FeedbackStatus @default(PENDING) + adminNote String? @map("admin_note") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@map("feedback_items") + @@schema("rebreak") +} + +model BlocklistDomain { + id String @id @default(uuid()) @db.Uuid + domain String @unique + source String + isActive Boolean @default(true) @map("is_active") + reportCount Int @default(0) @map("report_count") + + @@map("blocklist_domains") + @@schema("rebreak") +} + +model TrustedContact { + id String @id @default(uuid()) @db.Uuid + userId String @map("user_id") @db.Uuid + name String + phone String? + email String? + createdAt DateTime @default(now()) @map("created_at") + + @@map("trusted_contacts") + @@schema("rebreak") +} + +model CoachSession { + id String @id @default(uuid()) @db.Uuid + userId String @map("user_id") @db.Uuid + content Json + createdAt DateTime @default(now()) @map("created_at") + + @@map("coach_sessions") + @@schema("rebreak") +} + +model MailConnection { + id String @id @default(uuid()) @db.Uuid + userId String @map("user_id") @db.Uuid + email String + provider String @default("imap") + providerName String? @map("provider_name") + imapHost String @map("imap_host") + imapPort Int @map("imap_port") + rejectUnauthorized Boolean @default(true) @map("reject_unauthorized") + useStarttls Boolean @default(false) @map("use_starttls") + passwordEncrypted String @map("password_encrypted") + isActive Boolean @default(true) @map("is_active") + scanInterval Int @default(24) @map("scan_interval") + lastScannedAt DateTime? @map("last_scanned_at") + nextScanAt DateTime? @map("next_scan_at") + emailsBlocked Int @default(0) @map("emails_blocked") + emailsScanned Int @default(0) @map("emails_scanned") + createdAt DateTime @default(now()) @map("created_at") + + blockedMails MailBlocked[] + + @@unique([userId, email]) + @@map("mail_connections") + @@schema("rebreak") +} + +enum GameChallengeStatus { + OPEN + ACTIVE + FINISHED + CANCELLED + + @@schema("rebreak") +} + +model GameChallenge { + id String @id @default(uuid()) @db.Uuid + challengerId String @map("challenger_id") @db.Uuid + challengerName String @map("challenger_name") + opponentId String? @map("opponent_id") @db.Uuid + opponentName String? @map("opponent_name") + status GameChallengeStatus @default(OPEN) + board String @default("---------") + currentTurn String @default("X") @map("current_turn") + winner String? + postId String? @map("post_id") @db.Uuid + gameType String @default("tictactoe") @map("game_type") + isLive Boolean @default(false) @map("is_live") + memoryState Json? @map("memory_state") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@map("game_challenges") + @@schema("rebreak") +} + +model Notification { + id String @id @default(uuid()) @db.Uuid + recipientId String @map("recipient_id") @db.Uuid + type String // "new_comment" | "new_like" | "domain_vote" + actorName String @map("actor_name") + actorAvatar String? @map("actor_avatar") + postId String? @map("post_id") @db.Uuid + preview String? + readAt DateTime? @map("read_at") + createdAt DateTime @default(now()) @map("created_at") + + @@index([recipientId, readAt]) + @@map("notifications") + @@schema("rebreak") +} + +model GameScore { + userId String @id @map("user_id") @db.Uuid + playerName String @map("player_name") + wins Int @default(0) + losses Int @default(0) + draws Int @default(0) + points Int @default(0) + updatedAt DateTime @updatedAt @map("updated_at") + + @@map("game_scores") + @@schema("rebreak") +} + +model GameRating { + id String @id @default(uuid()) @db.Uuid + userId String @map("user_id") @db.Uuid + gameName String @map("game_name") + stars Int + feedback String? + score Int @default(0) + createdAt DateTime @default(now()) @map("created_at") + + @@map("game_ratings") + @@schema("rebreak") +} + +model GameHighScore { + id String @id @default(uuid()) @db.Uuid + userId String @map("user_id") @db.Uuid + nickname String + gameName String @map("game_name") + score Int + updatedAt DateTime @updatedAt @map("updated_at") + + @@unique([userId, gameName]) + @@index([gameName, score(sort: Desc)]) + @@map("game_high_scores") + @@schema("rebreak") +} + +model MailBlocked { + id String @id @default(uuid()) @db.Uuid + userId String @map("user_id") @db.Uuid + connectionId String @map("connection_id") @db.Uuid + gmailMessageId String @map("gmail_message_id") + senderEmail String @map("sender_email") + senderName String? @map("sender_name") + subject String + receivedAt DateTime @map("received_at") + action String + createdAt DateTime @default(now()) @map("created_at") + + connection MailConnection @relation(fields: [connectionId], references: [id], onDelete: Cascade) + + @@unique([gmailMessageId, userId]) + @@map("mail_blocked") + @@schema("rebreak") +} + +model ImapProxyAccount { + id String @id @default(uuid()) @db.Uuid + userId String @map("user_id") @db.Uuid + proxyUsername String @unique @map("proxy_username") + proxyPassword String @map("proxy_password") + connectionId String @unique @map("connection_id") @db.Uuid + createdAt DateTime @default(now()) @map("created_at") + + @@map("imap_proxy_accounts") + @@schema("rebreak") +} + +model CooldownRequest { + id String @id @default(uuid()) @db.Uuid + userId String @map("user_id") @db.Uuid + reason String? + cooldownStartedAt DateTime @default(now()) @map("cooldown_started_at") + cooldownEndsAt DateTime @map("cooldown_ends_at") + resolvedAt DateTime? @map("resolved_at") + cancelledAt DateTime? @map("cancelled_at") + tokenJti String @unique @map("token_jti") + + @@index([userId, cooldownEndsAt]) + @@map("cooldown_requests") + @@schema("rebreak") +} + +enum LyraMemoryType { + trigger + habit + strength + relationship + milestone + pain_point + goal + preference + + @@schema("rebreak") +} + +/// Persistente Erinnerungen von Lyra über den User — injiziert in System-Prompt jeder Session. +/// Enthält Art-9-Gesundheitsdaten (Glücksspielkontext) — strenge RLS: nur service-role schreibt. +model LyraMemory { + id String @id @default(uuid()) @db.Uuid + userId String @map("user_id") @db.Uuid + type LyraMemoryType + content String @db.VarChar(500) + confidence Float @default(0.7) + source String? // session-id | "manual" | "observed" + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6) + lastReferencedAt DateTime? @map("last_referenced_at") @db.Timestamptz(6) + + @@index([userId, type]) + @@map("lyra_memories") + @@schema("rebreak") +} + +// Device-Binding pro User: Free=1, Pro=1, Legend=3 (siehe plan-features.ts maxDevices). +// Frontend liefert via Capacitor Device.getId() eine persistente UUID — diese wird +// bei jedem authentifizierten Request via x-device-id Header geprüft. +model UserDevice { + id String @id @default(uuid()) @db.Uuid + userId String @map("user_id") @db.Uuid + deviceId String @map("device_id") // Capacitor persistent UUID + platform String // "ios" | "android" | "web" + model String? // z.B. "iPhone15,2" + name String? // z.B. "Chahines iPhone" + lastSeenAt DateTime @default(now()) @map("last_seen_at") + createdAt DateTime @default(now()) @map("created_at") + + @@unique([userId, deviceId]) + @@index([userId]) + @@map("user_devices") + @@schema("rebreak") +} diff --git a/backend/server/api/admin/domain-submissions/[id]/approve.post.ts b/backend/server/api/admin/domain-submissions/[id]/approve.post.ts new file mode 100644 index 0000000..7c138b6 --- /dev/null +++ b/backend/server/api/admin/domain-submissions/[id]/approve.post.ts @@ -0,0 +1,112 @@ +import { adminApproveSubmission } from "../../../../db/domains"; + +export default defineEventHandler(async (event) => { + const config = useRuntimeConfig(); + const adminSecret = getHeader(event, "x-admin-secret"); + if (adminSecret !== config.adminSecret) { + throw createError({ statusCode: 401, message: "Unauthorized" }); + } + const id = getRouterParam(event, "id"); + if (!id) throw createError({ statusCode: 400, message: "ID fehlt" }); + + const body = await readBody(event).catch(() => ({})); + const result = await adminApproveSubmission(id, body?.note); + + // Lyra-Post über die neu genehmigte Domain (fire & forget) + const domain = (result as any)?.domain ?? null; + const submitterUserId = (result as any)?.userId ?? null; + const lyraBotUserId = config.lyraBotUserId; + console.log( + `[approve] domain=${domain}, lyraBotUserId=${lyraBotUserId}, hasGroq=${!!config.groqApiKey}`, + ); + if (domain && lyraBotUserId && config.groqApiKey) { + // db + submitterName VOR der IIFE holen (usePrisma braucht Event-Kontext) + const db = usePrisma(); + let rawName: string | null = null; + if (submitterUserId) { + try { + // Rebreak Prisma-Modell heißt 'profile' (nicht 'user') + const submitter = await db.profile.findUnique({ + where: { id: submitterUserId }, + select: { nickname: true, username: true }, + }); + rawName = submitter?.nickname || submitter?.username || null; + } catch {} + } + // Für @mention: Leerzeichen entfernen (Regex matcht nur einzelne Wörter) + const mentionName = rawName?.replace(/\s+/g, "") ?? null; + const hasMention = !!mentionName; + const mentionRef = hasMention + ? `@${mentionName}` + : "einem Community-Mitglied"; + + // Stats für Lyra-Text holen + let statsLine = ""; + try { + const stats = await db.blocklistDomain.count({ + where: { isActive: true }, + }); + const startOfMonth = new Date(); + startOfMonth.setDate(1); + startOfMonth.setHours(0, 0, 0, 0); + const monthlyAdded = await db.domainSubmission.count({ + where: { status: "approved", reviewedAt: { gte: startOfMonth } }, + }); + statsLine = `Damit schützen wir gemeinsam vor ${stats.toLocaleString("de-DE")} Domains${monthlyAdded > 0 ? ` (+${monthlyAdded} diesen Monat)` : ""}.`; + } catch {} + + const groqUserPrompt = hasMention + ? `Schreibe einen kurzen Community-Post (max. 2 Sätze, auf Deutsch): Die Domain "${domain}" wurde zur ReBreak-Blockliste hinzugefügt – möglich gemacht durch ${mentionRef}. Erwähne ${mentionRef} genau einmal. Füge am Ende diesen Satz exakt ein: "${statsLine}" Warm, direkt, kein doppelter Dank.` + : `Schreibe einen kurzen Community-Post (max. 2 Sätze, auf Deutsch): Die Domain "${domain}" wurde zur ReBreak-Blockliste hinzugefügt. Füge am Ende diesen Satz exakt ein: "${statsLine}" Warm, direkt.`; + + const groqApiKey = config.groqApiKey; + (async () => { + try { + const response = await $fetch<{ + choices: { message: { content: string } }[]; + }>("https://api.groq.com/openai/v1/chat/completions", { + method: "POST", + headers: { + Authorization: `Bearer ${groqApiKey}`, + "Content-Type": "application/json", + }, + body: { + model: "llama-3.3-70b-versatile", + max_tokens: 150, + messages: [ + { + role: "system", + content: `Du bist Lyra – Recovery-Coach der ReBreak-Community. Tonalität: warm, persönlich, direkt. Schreibe NUR den Post-Text, kein Prefix, keine Anführungszeichen.`, + }, + { + role: "user", + content: groqUserPrompt, + }, + ], + }, + }); + const content = response.choices?.[0]?.message?.content?.trim(); + if (content) { + const faviconUrl = `https://www.google.com/s2/favicons?domain=${encodeURIComponent(domain)}&sz=64`; + await db.communityPost.create({ + data: { + userId: lyraBotUserId, + category: "domain_approved", + content, + imageUrl: faviconUrl, + isAnonymous: false, + isModerated: false, + }, + }); + console.log( + `[approve] Lyra-Post erstellt für domain=${domain}, submitter=${mentionName ?? "anonym"}`, + ); + } + } catch (err) { + console.error(`[approve] Lyra-Post fehlgeschlagen:`, err); + } + })(); + } + + return { ok: true }; +}); diff --git a/backend/server/api/admin/domain-submissions/[id]/reject.post.ts b/backend/server/api/admin/domain-submissions/[id]/reject.post.ts new file mode 100644 index 0000000..b125612 --- /dev/null +++ b/backend/server/api/admin/domain-submissions/[id]/reject.post.ts @@ -0,0 +1,15 @@ +import { adminRejectSubmission } from "../../../../db/domains"; + +export default defineEventHandler(async (event) => { + const config = useRuntimeConfig(); + const adminSecret = getHeader(event, "x-admin-secret"); + if (adminSecret !== config.adminSecret) { + throw createError({ statusCode: 401, message: "Unauthorized" }); + } + const id = getRouterParam(event, "id"); + if (!id) throw createError({ statusCode: 400, message: "ID fehlt" }); + + const body = await readBody(event).catch(() => ({})); + await adminRejectSubmission(id, body?.note); + return { ok: true }; +}); diff --git a/backend/server/api/admin/domain-submissions/index.get.ts b/backend/server/api/admin/domain-submissions/index.get.ts new file mode 100644 index 0000000..17c5143 --- /dev/null +++ b/backend/server/api/admin/domain-submissions/index.get.ts @@ -0,0 +1,10 @@ +import { getPendingSubmissions } from "../../../db/domains"; + +export default defineEventHandler(async (event) => { + const config = useRuntimeConfig(); + const adminSecret = getHeader(event, "x-admin-secret"); + if (adminSecret !== config.adminSecret) { + throw createError({ statusCode: 401, message: "Unauthorized" }); + } + return getPendingSubmissions(); +}); diff --git a/backend/server/api/admin/lyra-generate.post.ts b/backend/server/api/admin/lyra-generate.post.ts new file mode 100644 index 0000000..32140ed --- /dev/null +++ b/backend/server/api/admin/lyra-generate.post.ts @@ -0,0 +1,78 @@ +import { LYRA_TOPICS, TOPIC_HINTS, type LyraTopic } from "./lyra-post.post"; + +const LYRA_SYSTEM_PROMPT = `Du bist Lyra – Recovery-Coach und Begleiterin der ReBreak-Community. Du hast tiefes Wissen in CBT, Verhaltenspsychologie und dem Alltag von Menschen mit Spielsucht. + +Du schreibst kurze Community-Beiträge. Deine Stimme: +- Direkt und persönlich – du sprichst die Person an ("du") +- Warmherzig, geerdet, wie jemand der wirklich zuhört +- Niemals klischeehafte Motivationsfloskeln ("Du schaffst das!!!") +- Kein KI-Sprech, keine Listen, keine Aufzählungen +- Keine Casino-Werbung, keine Links, keine medizinischen Diagnosen +- Auf Deutsch, max. 3–4 Sätze + +Antworte NUR mit dem Post-Text. Kein "Lyra:" Prefix, keine Anführungszeichen.`; + +const REBREAK_SYSTEM_PROMPT = `Du bist der offizielle ReBreak Account. ReBreak ist eine App zur Überwindung von Glücksspielsucht. + +Du postest Neuigkeiten, Updates und Community-Ankündigungen. Deine Tonalität: +- Offiziell aber nahbar – wie ein Team-Update, nicht wie Werbung +- Kurz (max. 3–4 Sätze) +- Sachlich und informativ, gelegentlich motivierend +- Keine medizinischen Ratschläge, keine Links +- Auf Deutsch + +Antworte NUR mit dem Post-Text. Kein "ReBreak:" Prefix, keine Anführungszeichen.`; + +/** POST /api/admin/lyra-generate — generiert Text via LLM ohne zu posten */ +export default defineEventHandler(async (event) => { + const config = useRuntimeConfig(); + const adminSecret = getHeader(event, "x-admin-secret"); + + if (!config.adminSecret || adminSecret !== config.adminSecret) { + throw createError({ statusCode: 401, message: "Unauthorized" }); + } + + if (!config.groqApiKey) { + throw createError({ statusCode: 500, message: "Groq API Key fehlt" }); + } + + const body = await readBody(event); + const author: "lyra" | "rebreak" = + body?.author === "rebreak" ? "rebreak" : "lyra"; + const topic: LyraTopic = LYRA_TOPICS.includes(body?.topic) + ? body.topic + : LYRA_TOPICS[Math.floor(Math.random() * LYRA_TOPICS.length)]; + const context: string | undefined = body?.context?.trim() || undefined; + + const userPrompt = context + ? `${TOPIC_HINTS[topic]}\n\nZusätzlicher Kontext für diesen Post: ${context}` + : TOPIC_HINTS[topic]; + + const systemPrompt = + author === "rebreak" ? REBREAK_SYSTEM_PROMPT : LYRA_SYSTEM_PROMPT; + + const response = await $fetch<{ + choices: { message: { content: string } }[]; + }>("https://api.groq.com/openai/v1/chat/completions", { + method: "POST", + headers: { + Authorization: `Bearer ${config.groqApiKey}`, + "Content-Type": "application/json", + }, + body: { + model: "llama-3.3-70b-versatile", + max_tokens: 200, + messages: [ + { role: "system", content: systemPrompt }, + { role: "user", content: userPrompt }, + ], + }, + }); + + const content = response.choices?.[0]?.message?.content?.trim(); + if (!content) { + throw createError({ statusCode: 500, message: "Keine Antwort von LLM" }); + } + + return { success: true, content }; +}); diff --git a/backend/server/api/admin/lyra-post.post.ts b/backend/server/api/admin/lyra-post.post.ts new file mode 100644 index 0000000..ea6eac4 --- /dev/null +++ b/backend/server/api/admin/lyra-post.post.ts @@ -0,0 +1,125 @@ +import { createPost } from "../../db/community"; + +export const LYRA_TOPICS = [ + "motivation", + "tipp", + "zitat", + "witzig", + "news", + "feature", +] as const; + +export type LyraTopic = (typeof LYRA_TOPICS)[number]; + +export const TOPIC_HINTS: Record = { + motivation: + "Schreibe einen kurzen, stillen Gedanken für Menschen die heute kämpfen. Nicht übertrieben – eher ruhig stark.", + tipp: "Teile einen kleinen, konkreten Trick aus der Verhaltensforschung oder CBT gegen Spieldrang. Praktisch und direkt.", + zitat: + "Teile ein tiefgründiges Zitat aus Psychologie, Stoizismus oder Verhaltensforschung – ohne das Wort 'Sucht' zu verwenden. Kurz kommentiert.", + witzig: + "Schreibe einen witzigen, selbstironischen Post über das Thema Ablenkung, Impulskontrolle oder Gewohnheiten – leicht und menschlich, nicht flach.", + news: "Beschreibe kurz eine typische Taktik der Glücksspielindustrie (z.B. Push-Notifications, Bonusangebote) – sachlich und als Warnung formuliert.", + feature: + "Weise freundlich auf ein ReBreak-Feature hin (Blocker, Streak, Mail-Agent, Lyra-Chat, SOS-Atemübung) – wähle eines zufällig oder nutze den gegebenen Kontext.", +}; + +const LYRA_SYSTEM_PROMPT = `Du bist Lyra – Recovery-Coach und Begleiterin der ReBreak-Community. Du hast tiefes Wissen in CBT, Verhaltenspsychologie und dem Alltag von Menschen mit Spielsucht. + +Du schreibst kurze Community-Beiträge. Deine Stimme: +- Direkt und persönlich – du sprichst die Person an ("du") +- Warmherzig, geerdet, wie jemand der wirklich zuhört +- Niemals klischeehafte Motivationsfloskeln ("Du schaffst das!!!") +- Kein KI-Sprech, keine Listen, keine Aufzählungen +- Keine Casino-Werbung, keine Links, keine medizinischen Diagnosen +- Auf Deutsch, max. 3–4 Sätze + +Antworte NUR mit dem Post-Text. Kein "Lyra:" Prefix, keine Anführungszeichen.`; + +const REBREAK_SYSTEM_PROMPT = `Du bist der offizielle ReBreak Account. ReBreak ist eine App zur Überwindung von Glücksspielsucht. + +Du postest Neuigkeiten, Updates und Community-Ankündigungen. Deine Tonalität: +- Offiziell aber nahbar – wie ein Team-Update, nicht wie Werbung +- Kurz (max. 3–4 Sätze) +- Sachlich und informativ, gelegentlich motivierend +- Keine medizinischen Ratschläge, keine Links +- Auf Deutsch + +Antworte NUR mit dem Post-Text. Kein "ReBreak:" Prefix, keine Anführungszeichen.`; + +/** POST /api/admin/lyra-post — manueller Bot-Post vom Admin-Dashboard */ +export default defineEventHandler(async (event) => { + const config = useRuntimeConfig(); + const adminSecret = getHeader(event, "x-admin-secret"); + + if (!config.adminSecret || adminSecret !== config.adminSecret) { + throw createError({ statusCode: 401, message: "Unauthorized" }); + } + + const body = await readBody(event); + const author: "lyra" | "rebreak" = + body?.author === "rebreak" ? "rebreak" : "lyra"; + + const botUserId = + author === "rebreak" ? config.rebreakBotUserId : config.lyraBotUserId; + + if (!botUserId) { + throw createError({ + statusCode: 500, + message: `${author === "rebreak" ? "REBREAK_BOT_USER_ID" : "LYRA_BOT_USER_ID"} nicht konfiguriert`, + }); + } + + let content: string; + + if (body?.customContent?.trim()) { + // Admin hat Text direkt eingegeben – kein LLM-Call nötig + content = body.customContent.trim(); + } else { + if (!config.groqApiKey) { + throw createError({ + statusCode: 500, + message: "Groq API Key fehlt", + }); + } + + const topic: LyraTopic = LYRA_TOPICS.includes(body?.topic) + ? body.topic + : LYRA_TOPICS[Math.floor(Math.random() * LYRA_TOPICS.length)]; + const context: string | undefined = body?.context?.trim() || undefined; + + const userPrompt = context + ? `${TOPIC_HINTS[topic]}\n\nZusätzlicher Kontext für diesen Post: ${context}` + : TOPIC_HINTS[topic]; + + const systemPrompt = + author === "rebreak" ? REBREAK_SYSTEM_PROMPT : LYRA_SYSTEM_PROMPT; + + const response = await $fetch<{ + choices: { message: { content: string } }[]; + }>("https://api.groq.com/openai/v1/chat/completions", { + method: "POST", + headers: { + Authorization: `Bearer ${config.groqApiKey}`, + "Content-Type": "application/json", + }, + body: { + model: "llama-3.3-70b-versatile", + max_tokens: 200, + messages: [ + { role: "system", content: systemPrompt }, + { role: "user", content: userPrompt }, + ], + }, + }); + + content = response.choices?.[0]?.message?.content?.trim() ?? ""; + if (!content) { + throw createError({ statusCode: 500, message: "Keine Antwort von LLM" }); + } + } + + const post = await createPost(botUserId, "community", content); + + return { success: true, postId: post.id, author, content }; +}); diff --git a/backend/server/api/admin/lyra-profile.get.ts b/backend/server/api/admin/lyra-profile.get.ts new file mode 100644 index 0000000..09f8db2 --- /dev/null +++ b/backend/server/api/admin/lyra-profile.get.ts @@ -0,0 +1,30 @@ +import { getProfile } from "../../db/profile"; + +/** GET /api/admin/lyra-profile?author=lyra|rebreak — gibt Nickname und Avatar des Bot-Accounts zurück */ +export default defineEventHandler(async (event) => { + const config = useRuntimeConfig(); + const adminSecret = getHeader(event, "x-admin-secret"); + + if (!config.adminSecret || adminSecret !== config.adminSecret) { + throw createError({ statusCode: 401, message: "Unauthorized" }); + } + + const query = getQuery(event); + const author = query.author === "rebreak" ? "rebreak" : "lyra"; + + const userId = + author === "rebreak" ? config.rebreakBotUserId : config.lyraBotUserId; + + if (!userId) { + return { + nickname: author === "rebreak" ? "ReBreak" : "Lyra", + avatar: null, + }; + } + + const profile = await getProfile(userId); + return { + nickname: profile?.nickname ?? (author === "rebreak" ? "ReBreak" : "Lyra"), + avatar: profile?.avatar ?? null, + }; +}); diff --git a/backend/server/api/admin/set-lyra-avatar.post.ts b/backend/server/api/admin/set-lyra-avatar.post.ts new file mode 100644 index 0000000..7d9749d --- /dev/null +++ b/backend/server/api/admin/set-lyra-avatar.post.ts @@ -0,0 +1,72 @@ +import { updateProfile } from "../../db/profile"; + +/** POST /api/admin/set-lyra-avatar — PNG aus Canvas hochladen und als Bot-Avatar setzen */ +export default defineEventHandler(async (event) => { + const config = useRuntimeConfig(); + const adminSecret = getHeader(event, "x-admin-secret"); + + if (!config.adminSecret || adminSecret !== config.adminSecret) { + throw createError({ statusCode: 401, message: "Unauthorized" }); + } + + const body = await readBody<{ author: "lyra" | "rebreak"; dataUrl: string }>( + event, + ); + + if (!body?.dataUrl?.startsWith("data:image/")) { + throw createError({ statusCode: 400, message: "Invalid dataUrl" }); + } + + const author = body.author === "rebreak" ? "rebreak" : "lyra"; + const userId = + author === "rebreak" ? config.rebreakBotUserId : config.lyraBotUserId; + + if (!userId) { + throw createError({ + statusCode: 400, + message: `Bot user ID for ${author} not configured`, + }); + } + + // base64 → Buffer + const base64 = body.dataUrl.replace(/^data:image\/\w+;base64,/, ""); + const buffer = Buffer.from(base64, "base64"); + + const supabaseUrl = "https://db-staging.rebreak.org"; + const serviceKey = + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsImF1ZCI6ImF1dGhlbnRpY2F0ZWQiLCJyb2xlIjoic2VydmljZV9yb2xlIiwiZXhwIjoyMDkxMDE4OTU1LCJpYXQiOjE3NzU2NTg5NTV9.45fB5DC0-RMrYQXhlB0mI-bjtFAiHhjdBeBY9X8B8b8"; + + const storagePath = `${author}-bot/avatar.png`; + const storageUrl = `${supabaseUrl}/storage/v1/object/rebreak-avatars/${storagePath}`; + + // Erst versuchen zu updaten, dann als neu anlegen + const uploadRes = await $fetch<{ Key?: string; error?: string }>(storageUrl, { + method: "PUT", + headers: { + apikey: serviceKey, + Authorization: `Bearer ${serviceKey}`, + "Content-Type": "image/png", + "x-upsert": "true", + }, + body: buffer, + }).catch(() => null); + + if (!uploadRes?.Key && !uploadRes) { + // Fallback: POST (create) + await $fetch(storageUrl, { + method: "POST", + headers: { + apikey: serviceKey, + Authorization: `Bearer ${serviceKey}`, + "Content-Type": "image/png", + }, + body: buffer, + }); + } + + const avatarPublicUrl = `${supabaseUrl}/storage/v1/object/public/rebreak-avatars/${storagePath}?t=${Date.now()}`; + + await updateProfile(userId, { avatar: avatarPublicUrl }); + + return { success: true, avatar: avatarPublicUrl }; +}); diff --git a/backend/server/api/admin/stats.get.ts b/backend/server/api/admin/stats.get.ts new file mode 100644 index 0000000..4eab6f2 --- /dev/null +++ b/backend/server/api/admin/stats.get.ts @@ -0,0 +1,54 @@ +import { usePrisma } from "../../utils/prisma"; + +/** GET /api/admin/stats — Übersicht-Statistiken fürs Dashboard */ +export default defineEventHandler(async (event) => { + const config = useRuntimeConfig(); + const secret = getHeader(event, "x-admin-secret"); + if (!config.adminSecret || secret !== config.adminSecret) { + throw createError({ statusCode: 401, message: "Unauthorized" }); + } + + const db = usePrisma(); + const now = new Date(); + const last7d = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); + const last30d = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); + + const [ + totalUsers, + newUsersWeek, + totalPosts, + newPostsWeek, + pendingDomains, + approvedDomains, + pendingFeedback, + totalFeedback, + lyraPosts, + ] = await Promise.all([ + db.profile.count(), + db.profile.count({ where: { createdAt: { gte: last7d } } }), + db.communityPost.count({ where: { isModerated: false } }), + db.communityPost.count({ + where: { isModerated: false, createdAt: { gte: last7d } }, + }), + db.domainSubmission.count({ where: { status: "pending" } }), + db.domainSubmission.count({ where: { status: "approved" } }), + db.feedbackItem.count({ where: { status: "PENDING" } }).catch(() => 0), + db.feedbackItem.count().catch(() => 0), + db.communityPost + .count({ + where: { + userId: config.lyraBotUserId || undefined, + createdAt: { gte: last30d }, + }, + }) + .catch(() => 0), + ]); + + return { + users: { total: totalUsers, newThisWeek: newUsersWeek }, + posts: { total: totalPosts, newThisWeek: newPostsWeek }, + domains: { pending: pendingDomains, approved: approvedDomains }, + feedback: { pending: pendingFeedback, total: totalFeedback }, + lyra: { postsLast30d: lyraPosts }, + }; +}); diff --git a/backend/server/api/auth/login.post.ts b/backend/server/api/auth/login.post.ts new file mode 100644 index 0000000..c392162 --- /dev/null +++ b/backend/server/api/auth/login.post.ts @@ -0,0 +1,46 @@ +import { serverSupabaseClient } from "../../utils/useSupabase"; +import { getProfile } from "../../db/profile"; + +export default defineEventHandler(async (event) => { + const { username, password } = await readBody(event); + + if (!username || !password) { + throw createError({ + statusCode: 400, + message: "username und password erforderlich", + }); + } + + const email = `${username.toLowerCase()}@rebreak.internal`; + const supabase = serverSupabaseClient(event); + + const { data, error } = await supabase.auth.signInWithPassword({ + email, + password, + }); + if (error) throw createError({ statusCode: 401, message: error.message }); + + const dbProfile = await getProfile(data.user.id); + + return { + session: { + access_token: data.session.access_token, + refresh_token: data.session.refresh_token, + expires_at: data.session.expires_at, + }, + profile: { + id: data.user.id, + email: data.user.email ?? "", + username: dbProfile?.username ?? "", + nickname: dbProfile?.nickname ?? null, + avatar: dbProfile?.avatar ?? null, + plan: (dbProfile?.plan === "premium" + ? "legend" + : dbProfile?.plan === "standard" + ? "pro" + : dbProfile?.plan ?? "free") as "free" | "pro" | "legend", + streak: dbProfile?.streak ?? 0, + created_at: dbProfile?.createdAt?.toISOString() ?? data.user.created_at, + }, + }; +}); diff --git a/backend/server/api/auth/me.get.ts b/backend/server/api/auth/me.get.ts new file mode 100644 index 0000000..d49d40c --- /dev/null +++ b/backend/server/api/auth/me.get.ts @@ -0,0 +1,21 @@ +import { getProfile } from "../../db/profile"; + +export default defineEventHandler(async (event) => { + const user = await requireUser(event); + const dbProfile = await getProfile(user.id); + + return { + id: user.id, + email: user.email, + username: dbProfile?.username ?? "", + nickname: dbProfile?.nickname ?? null, + avatar: dbProfile?.avatar ?? null, + plan: (dbProfile?.plan === "premium" + ? "legend" + : dbProfile?.plan === "standard" + ? "pro" + : dbProfile?.plan ?? "free") as "free" | "pro" | "legend", + streak: dbProfile?.streak ?? 0, + created_at: dbProfile?.createdAt?.toISOString() ?? user.created_at, + }; +}); diff --git a/backend/server/api/auth/me.patch.ts b/backend/server/api/auth/me.patch.ts new file mode 100644 index 0000000..40ff1f0 --- /dev/null +++ b/backend/server/api/auth/me.patch.ts @@ -0,0 +1,17 @@ +import { updateProfile } from "../../db/profile"; + +export default defineEventHandler(async (event) => { + const user = await requireUser(event); + const body = await readBody(event); + + const dbUpdates: Record = {}; + if (body.username !== undefined) dbUpdates.username = body.username; + if (body.nickname !== undefined) dbUpdates.nickname = body.nickname; + if (body.avatar !== undefined) dbUpdates.avatar = body.avatar; + + if (Object.keys(dbUpdates).length > 0) { + await updateProfile(user.id, dbUpdates); + } + + return { success: true }; +}); diff --git a/backend/server/api/avatar/upload.post.ts b/backend/server/api/avatar/upload.post.ts new file mode 100644 index 0000000..f1d445b --- /dev/null +++ b/backend/server/api/avatar/upload.post.ts @@ -0,0 +1,57 @@ +import { serverSupabaseServiceRole } from "../../utils/useSupabase"; +import { updateProfile } from "../../db/profile"; + +/** + * POST /api/avatar/upload + * Body: { dataUrl: string } (base64 JPEG/PNG data URL) + */ +export default defineEventHandler(async (event) => { + const user = await requireUser(event); + const { dataUrl } = (await readBody(event)) as { dataUrl: string }; + + if (!dataUrl?.startsWith("data:image/")) { + throw createError({ statusCode: 400, message: "Ungültige Bilddaten" }); + } + + const match = dataUrl.match(/^data:(image\/\w+);base64,(.+)$/); + if (!match) + throw createError({ statusCode: 400, message: "Ungültiges Bildformat" }); + + const contentType = match[1]; + const ext = contentType === "image/png" ? "png" : "jpg"; + const base64 = match[2]; + const binaryStr = atob(base64); + const bytes = new Uint8Array(binaryStr.length); + for (let i = 0; i < binaryStr.length; i++) { + bytes[i] = binaryStr.charCodeAt(i); + } + const blob = new Blob([bytes], { type: contentType }); + + const supabase = serverSupabaseServiceRole(event); + const path = `avatars/${user.id}.${ext}`; + + const { error: uploadError } = await supabase.storage + .from("rebreak-avatars") + .upload(path, blob, { contentType, upsert: true }); + + if (uploadError) { + console.error("[avatar/upload] Storage error:", uploadError); + throw createError({ statusCode: 500, message: uploadError.message }); + } + + const { data: urlData } = supabase.storage + .from("rebreak-avatars") + .getPublicUrl(path); + + const publicUrl = urlData.publicUrl + `?t=${Date.now()}`; + + // In beide schreiben: profiles (für Prisma include) + user_metadata (für me.get) + await Promise.all([ + updateProfile(user.id, { avatar: publicUrl }), + supabase.auth.admin.updateUserById(user.id, { + user_metadata: { avatar: publicUrl }, + }), + ]); + + return { url: publicUrl }; +}); diff --git a/backend/server/api/blocklist/check.get.ts b/backend/server/api/blocklist/check.get.ts new file mode 100644 index 0000000..77d5b60 --- /dev/null +++ b/backend/server/api/blocklist/check.get.ts @@ -0,0 +1,19 @@ +import { usePrisma } from "../../utils/prisma"; + +/** + * GET /api/blocklist/check?domain=example.com + * Returns { inGlobal: boolean } + */ +export default defineEventHandler(async (event) => { + await requireUser(event); + const { domain } = getQuery(event) as { domain?: string }; + if (!domain) + throw createError({ statusCode: 400, message: "domain required" }); + + const db = usePrisma(); + const row = await db.blocklistDomain.findFirst({ + where: { domain: domain.toLowerCase().trim(), isActive: true }, + select: { domain: true }, + }); + return { inGlobal: !!row }; +}); diff --git a/backend/server/api/blocklist/count.get.ts b/backend/server/api/blocklist/count.get.ts new file mode 100644 index 0000000..6388206 --- /dev/null +++ b/backend/server/api/blocklist/count.get.ts @@ -0,0 +1,8 @@ +import { getActiveBlocklistCount } from "../../db/domains"; + +const ADGUARD_KNOWN_COUNT = 208704; + +export default defineEventHandler(async () => { + const count = await getActiveBlocklistCount(); + return { count: count > 1000 ? count : ADGUARD_KNOWN_COUNT }; +}); diff --git a/backend/server/api/blocklist/download.get.ts b/backend/server/api/blocklist/download.get.ts new file mode 100644 index 0000000..f37a35b --- /dev/null +++ b/backend/server/api/blocklist/download.get.ts @@ -0,0 +1,39 @@ +import { getActiveBlocklistDomains } from "../../db/domains"; + +export default defineEventHandler(async (event) => { + const query = getQuery(event); + const format = (query.format as string) ?? "hosts"; + + const rows = await getActiveBlocklistDomains(); + const domainList = rows.map((d) => d.domain); + + let content = ""; + let filename = "rebreak-blocklist"; + const contentType = "text/plain"; + + if (format === "hosts") { + filename += ".hosts"; + content = `# Rebreak Gambling Blocklist\n# Generated: ${new Date().toISOString()}\n# Format: /etc/hosts\n\n`; + content += domainList + .map((d) => `0.0.0.0 ${d}\n0.0.0.0 www.${d}`) + .join("\n"); + } else if (format === "ublock") { + filename += ".txt"; + content = `! Rebreak Gambling Blocklist\n! Generated: ${new Date().toISOString()}\n! Expires: 1 day\n\n`; + content += domainList.map((d) => `||${d}^`).join("\n"); + } else if (format === "dns") { + filename += "-dns.txt"; + content = domainList.join("\n"); + } else { + throw createError({ + statusCode: 400, + message: "Invalid format. Use: hosts, ublock, dns", + }); + } + + setHeader(event, "Content-Type", `${contentType}; charset=utf-8`); + setHeader(event, "Content-Disposition", `attachment; filename="${filename}"`); + setHeader(event, "Cache-Control", "public, max-age=3600"); + + return content; +}); diff --git a/backend/server/api/blocklist/personal.get.ts b/backend/server/api/blocklist/personal.get.ts new file mode 100644 index 0000000..c2cbc37 --- /dev/null +++ b/backend/server/api/blocklist/personal.get.ts @@ -0,0 +1,76 @@ +import { + getActiveBlocklistDomains, + getUserCustomDomains, +} from "../../db/domains"; + +/** + * GET /api/blocklist/personal + * Query: ?format=hosts|ublock|dns|json (default: hosts) + */ +export default defineEventHandler(async (event) => { + const user = await requireUser(event); + + const query = getQuery(event); + const format = (query.format as string) ?? "hosts"; + + const [globalDomains, userDomains] = await Promise.all([ + getActiveBlocklistDomains(), + getUserCustomDomains(user.id), + ]); + + const allDomains = new Set(); + for (const d of globalDomains) allDomains.add(d.domain); + for (const d of userDomains) allDomains.add(d.domain); + + const domains = [...allDomains].sort(); + const timestamp = new Date().toISOString(); + const header = `# Rebreak Personal Blocklist\n# User: ${user.id}\n# Generated: ${timestamp}\n# Total: ${domains.length} domains\n# https://rebreak.app\n\n`; + + if (format === "json") { + return { + total: domains.length, + global: globalDomains.length, + custom: userDomains.length, + domains, + generated: timestamp, + }; + } + + let content = header; + + switch (format) { + case "hosts": + content += domains.map((d) => `0.0.0.0 ${d}`).join("\n"); + setResponseHeader(event, "content-type", "text/plain; charset=utf-8"); + setResponseHeader( + event, + "content-disposition", + 'attachment; filename="rebreak-personal-hosts.txt"', + ); + break; + case "ublock": + content += domains.map((d) => `||${d}^`).join("\n"); + setResponseHeader(event, "content-type", "text/plain; charset=utf-8"); + setResponseHeader( + event, + "content-disposition", + 'attachment; filename="rebreak-personal-ublock.txt"', + ); + break; + case "dns": + content += domains.join("\n"); + setResponseHeader(event, "content-type", "text/plain; charset=utf-8"); + setResponseHeader( + event, + "content-disposition", + 'attachment; filename="rebreak-personal-dns.txt"', + ); + break; + default: + content += domains.map((d) => `0.0.0.0 ${d}`).join("\n"); + setResponseHeader(event, "content-type", "text/plain; charset=utf-8"); + } + + setResponseHeader(event, "cache-control", "private, max-age=3600"); + return content; +}); diff --git a/backend/server/api/blocklist/stats.get.ts b/backend/server/api/blocklist/stats.get.ts new file mode 100644 index 0000000..a1f43f1 --- /dev/null +++ b/backend/server/api/blocklist/stats.get.ts @@ -0,0 +1,132 @@ +import { getActiveBlocklistCount } from "../../db/domains"; +import { usePrisma } from "../../utils/prisma"; + +const ADGUARD_KNOWN_COUNT = 208704; +const VOTE_PHASE_DAYS = 7; + +/** GET /api/blocklist/stats */ +export default defineEventHandler(async (event) => { + const db = usePrisma(); + const count = await getActiveBlocklistCount(); + const current = count > 1000 ? count : ADGUARD_KNOWN_COUNT; + + // Optional user (für mySubmissions). Bei Fehler einfach skippen. + let userId: string | null = null; + try { + const u = await requireUser(event); + userId = u.id; + } catch { + /* anonymous */ + } + + const months = 12; + const startFraction = 0.45; + const labels: string[] = []; + const values: number[] = []; + + const now = new Date(); + for (let i = months - 1; i >= 0; i--) { + const d = new Date(now.getFullYear(), now.getMonth() - i, 1); + labels.push( + d.toLocaleDateString("de-DE", { month: "short", year: "2-digit" }), + ); + const t = (months - i) / months; + const easedT = t * (2 - t); + values.push( + Math.round(current * (startFraction + (1 - startFraction) * easedT)), + ); + } + + // Submissions split: vote phase (created < 7d) vs review (older, awaiting admin) + const voteCutoff = new Date(now.getTime() - VOTE_PHASE_DAYS * 86_400_000); + const weekAgo = new Date(now.getTime() - 7 * 86_400_000); + const monthAgo = new Date(now.getTime() - 30 * 86_400_000); + const [ + inVote, + inReview, + approvedAgg, + totalSubmittersGroup, + weeklyAdded, + monthlyAdded, + mineActive, + mineInVote, + mineInReview, + ] = await Promise.all([ + db.domainSubmission.count({ + where: { status: "pending" }, + }), + db.domainSubmission.count({ + where: { status: "in_review" }, + }), + db.domainSubmission.findMany({ + where: { status: "approved", reviewedAt: { not: null } }, + select: { createdAt: true, reviewedAt: true }, + orderBy: { reviewedAt: "desc" }, + take: 100, + }), + db.domainSubmission.groupBy({ + by: ["userId"], + _count: { _all: true }, + }), + db.domainSubmission.count({ + where: { status: "approved", reviewedAt: { gte: weekAgo } }, + }), + db.domainSubmission.count({ + where: { status: "approved", reviewedAt: { gte: monthAgo } }, + }), + userId + ? db.userCustomDomain.count({ where: { userId, status: "approved" } }) + : Promise.resolve(0), + userId + ? db.domainSubmission.count({ + where: { + userId, + status: "pending", + }, + }) + : Promise.resolve(0), + userId + ? db.domainSubmission.count({ + where: { + userId, + status: "in_review", + }, + }) + : Promise.resolve(0), + ]); + + let avgApprovalWaitDays = 0; + if (approvedAgg.length > 0) { + const totalMs = approvedAgg.reduce((sum, s) => { + if (!s.reviewedAt) return sum; + return sum + (s.reviewedAt.getTime() - s.createdAt.getTime()); + }, 0); + avgApprovalWaitDays = + Math.round((totalMs / approvedAgg.length / 86_400_000) * 10) / 10; + } + + let avgPerUser = 0; + if (totalSubmittersGroup.length > 0) { + const totalSubs = totalSubmittersGroup.reduce( + (sum, g) => sum + g._count._all, + 0, + ); + avgPerUser = + Math.round((totalSubs / totalSubmittersGroup.length) * 10) / 10; + } + + return { + current, + weeklyAdded, + monthlyAdded, + history: labels.map((label, i) => ({ label, count: values[i] })), + submissions: { inVote, inReview }, + mySubmissions: { + active: mineActive, + inVote: mineInVote, + inReview: mineInReview, + }, + avgPerUser, + avgApprovalWaitDays, + }; +}); diff --git a/backend/server/api/blocklist/sync.post.ts b/backend/server/api/blocklist/sync.post.ts new file mode 100644 index 0000000..865ec18 --- /dev/null +++ b/backend/server/api/blocklist/sync.post.ts @@ -0,0 +1,33 @@ +import { upsertBlocklistDomains } from "../../db/domains"; + +const HAGEZI_URL = + "https://raw.githubusercontent.com/hagezi/dns-blocklists/main/adblock/gambling.txt"; + +export default defineEventHandler(async () => { + const raw = await $fetch(HAGEZI_URL, { responseType: "text" }); + + const domains = raw + .split("\n") + .map((l) => l.trim()) + .filter((l) => l.startsWith("||") && l.endsWith("^")) + .map((l) => l.slice(2, -1).toLowerCase()) + .filter((d) => d.length > 0 && !d.includes("/") && d.includes(".")); + + if (domains.length < 1000) { + throw createError({ + statusCode: 500, + message: `Zu wenige Domains (${domains.length}), möglicher Fetch-Fehler`, + }); + } + + const processed = await upsertBlocklistDomains( + domains.map((domain) => ({ domain, source: "hagezi" })), + ); + + return { + success: true, + total_fetched: domains.length, + processed, + timestamp: new Date().toISOString(), + }; +}); diff --git a/backend/server/api/chat/dm-conversations.get.ts b/backend/server/api/chat/dm-conversations.get.ts new file mode 100644 index 0000000..011226d --- /dev/null +++ b/backend/server/api/chat/dm-conversations.get.ts @@ -0,0 +1,44 @@ +import { getDmConversations, countUnreadDms } from "../../db/chat"; +import { getProfile } from "../../db/profile"; +import { getUsersMeta } from "../../utils/getUsersMeta"; + +/** GET /api/chat/dm-conversations */ +export default defineEventHandler(async (event) => { + const user = await requireUser(event); + + const [messages, unreadByPartner] = await Promise.all([ + getDmConversations(user.id), + countUnreadDms(user.id), + ]); + + if (messages.length === 0) return []; + + const partnerMap = new Map(); + for (const msg of messages) { + const partnerId = msg.senderId === user.id ? msg.receiverId : msg.senderId; + if (!partnerMap.has(partnerId)) partnerMap.set(partnerId, msg); + } + + const partnerIds = Array.from(partnerMap.keys()); + const [profileResults, metaMap] = await Promise.all([ + Promise.all(partnerIds.map((id) => getProfile(id))), + getUsersMeta(partnerIds), + ]); + const profileMap = new Map( + profileResults.filter(Boolean).map((p) => [p!.id, p!]), + ); + + return Array.from(partnerMap.entries()).map(([partnerId, lastMsg]) => { + const p = profileMap.get(partnerId); + const meta = metaMap[partnerId] ?? { nickname: null, avatar: null }; + return { + partnerId, + partnerName: meta.nickname ?? p?.username ?? "Anonym", + partnerAvatar: meta.avatar ?? null, + lastMessage: lastMsg.content.slice(0, 60), + lastMessageAt: lastMsg.createdAt, + isOwn: lastMsg.senderId === user.id, + unreadCount: unreadByPartner[partnerId] ?? 0, + }; + }); +}); diff --git a/backend/server/api/chat/dm.post.ts b/backend/server/api/chat/dm.post.ts new file mode 100644 index 0000000..ff83258 --- /dev/null +++ b/backend/server/api/chat/dm.post.ts @@ -0,0 +1,66 @@ +import { sendDirectMessage } from "../../db/chat"; + +/** POST /api/chat/dm – Direktnachricht senden */ +export default defineEventHandler(async (event) => { + const user = await requireUser(event); + const body = await readBody(event); + const { + receiverId, + content, + replyToId, + attachmentUrl, + attachmentType, + attachmentName, + } = body as { + receiverId: string; + content: string; + replyToId?: string; + attachmentUrl?: string; + attachmentType?: string; + attachmentName?: string; + }; + + if (!receiverId || (!content?.trim() && !attachmentUrl)) { + throw createError({ + statusCode: 400, + message: "receiverId und content/Anhang erforderlich", + }); + } + if (receiverId === user.id) { + throw createError({ + statusCode: 400, + message: "Nachrichten an sich selbst nicht möglich", + }); + } + if ((content ?? "").trim().length > 2000) { + throw createError({ + statusCode: 400, + message: "Nachricht zu lang (max. 2000 Zeichen)", + }); + } + + const data = await sendDirectMessage( + user.id, + receiverId, + (content ?? "").trim(), + { + replyToId, + attachmentUrl, + attachmentType, + attachmentName, + }, + ); + + return { + id: data.id, + content: data.content, + createdAt: data.createdAt, + isOwn: true, + readAt: null, + replyTo: data.replyTo, + attachmentUrl: data.attachmentUrl, + attachmentType: data.attachmentType, + attachmentName: data.attachmentName, + likesCount: data.likesCount, + }; +}); diff --git a/backend/server/api/chat/dm/[userId].get.ts b/backend/server/api/chat/dm/[userId].get.ts new file mode 100644 index 0000000..4cdb999 --- /dev/null +++ b/backend/server/api/chat/dm/[userId].get.ts @@ -0,0 +1,36 @@ +import { getDmHistory, markDmsAsRead } from "../../../db/chat"; +import { getProfile } from "../../../db/profile"; + +/** GET /api/chat/dm/[userId]?page=1 */ +export default defineEventHandler(async (event) => { + const user = await requireUser(event); + const partnerId = getRouterParam(event, "userId"); + if (!partnerId) + throw createError({ statusCode: 400, message: "userId fehlt" }); + + const page = Math.max(1, parseInt((getQuery(event).page as string) || "1")); + + const [messages, partnerProfile] = await Promise.all([ + getDmHistory(user.id, partnerId, page), + getProfile(partnerId), + markDmsAsRead(partnerId, user.id), + ]); + + return { + partner: partnerProfile + ? { + id: partnerProfile.id, + nickname: partnerProfile.nickname ?? partnerProfile.username ?? "Anonym", + username: partnerProfile.username ?? "anonym", + avatar: partnerProfile.avatar, + } + : { id: partnerId, nickname: "Anonym", username: "anonym", avatar: null }, + messages: [...messages].reverse().map((m) => ({ + id: m.id, + content: m.content, + createdAt: m.createdAt, + isOwn: m.senderId === user.id, + readAt: m.readAt, + })), + }; +}); diff --git a/backend/server/api/chat/join.post.ts b/backend/server/api/chat/join.post.ts new file mode 100644 index 0000000..2e78d66 --- /dev/null +++ b/backend/server/api/chat/join.post.ts @@ -0,0 +1,25 @@ +import { findRoomByInviteCode, getMember, joinRoom } from "../../db/chat-rooms"; + +/** POST /api/chat/join – Via Invite-Code beitreten */ +export default defineEventHandler(async (event) => { + const user = await requireUser(event); + const body = await readBody(event); + const code = (body?.code ?? "").trim(); + + if (!code) { + throw createError({ statusCode: 400, message: "Invite-Code erforderlich" }); + } + + const room = await findRoomByInviteCode(code); + if (!room) { + throw createError({ statusCode: 404, message: "Ungültiger Invite-Code" }); + } + + const existing = await getMember(room.id, user.id); + if (existing?.status === "active") { + return { status: "already_member", roomId: room.id }; + } + + await joinRoom(room.id, user.id, "active"); + return { status: "joined", roomId: room.id }; +}); diff --git a/backend/server/api/chat/like.post.ts b/backend/server/api/chat/like.post.ts new file mode 100644 index 0000000..9658071 --- /dev/null +++ b/backend/server/api/chat/like.post.ts @@ -0,0 +1,23 @@ +import { toggleChatMessageLike, toggleDmLike } from "../../db/chat-rooms"; + +/** POST /api/chat/like – Toggle Like auf Chat-Message oder DM */ +export default defineEventHandler(async (event) => { + const user = await requireUser(event); + const body = await readBody(event); + + const messageId = body?.messageId; + const type = body?.type; // "chat" | "dm" + + if (!messageId || !type) { + throw createError({ statusCode: 400, message: "messageId und type erforderlich" }); + } + + let liked: boolean; + if (type === "dm") { + liked = await toggleDmLike(user.id, messageId); + } else { + liked = await toggleChatMessageLike(user.id, messageId); + } + + return { liked }; +}); diff --git a/backend/server/api/chat/message.post.ts b/backend/server/api/chat/message.post.ts new file mode 100644 index 0000000..870f9c0 --- /dev/null +++ b/backend/server/api/chat/message.post.ts @@ -0,0 +1,19 @@ +import { awardPoints } from "../../utils/scoring"; +import { createChatMessage } from "../../db/chat"; + +export default defineEventHandler(async (event) => { + const user = await requireUser(event); + const body = await readBody(event); + const content = (body?.content ?? "").trim(); + + if (!content) + throw createError({ statusCode: 400, message: "content erforderlich" }); + if (content.length > 1000) + throw createError({ statusCode: 400, message: "Nachricht zu lang" }); + + const data = await createChatMessage(user.id, content); + + await awardPoints(user.id, "chat_message").catch(() => {}); + + return data; +}); diff --git a/backend/server/api/chat/messages.get.ts b/backend/server/api/chat/messages.get.ts new file mode 100644 index 0000000..d876e06 --- /dev/null +++ b/backend/server/api/chat/messages.get.ts @@ -0,0 +1,5 @@ +import { getChatMessages } from "../../db/chat"; + +export default defineEventHandler(async () => { + return getChatMessages(100); +}); diff --git a/backend/server/api/chat/rooms/[roomId]/index.get.ts b/backend/server/api/chat/rooms/[roomId]/index.get.ts new file mode 100644 index 0000000..147b72b --- /dev/null +++ b/backend/server/api/chat/rooms/[roomId]/index.get.ts @@ -0,0 +1,84 @@ +import { getRoom, getRoomMessages, getMember } from "../../../../db/chat-rooms"; +import { getUsersMeta } from "../../../../utils/getUsersMeta"; + +/** GET /api/chat/rooms/[roomId] – Room detail + messages */ +export default defineEventHandler(async (event) => { + const user = await requireUser(event); + const roomId = getRouterParam(event, "roomId"); + if (!roomId) throw createError({ statusCode: 400, message: "roomId fehlt" }); + + const room = await getRoom(roomId); + if (!room) + throw createError({ statusCode: 404, message: "Raum nicht gefunden" }); + + // Access check: public rooms or member + const member = await getMember(roomId, user.id); + if (!room.isPublic && (!member || member.status !== "active")) { + throw createError({ statusCode: 403, message: "Kein Zugang" }); + } + + const cursor = getQuery(event).cursor as string | undefined; + const messages = await getRoomMessages(roomId, cursor, 50); + + // Resolve user meta for all message senders + const userIds = [...new Set(messages.map((m) => m.userId))]; + // Also collect replyTo userIds + messages.forEach((m) => { + if (m.replyTo?.userId && !userIds.includes(m.replyTo.userId)) { + userIds.push(m.replyTo.userId); + } + }); + const meta = userIds.length > 0 ? await getUsersMeta(userIds) : {}; + + // Get member list + const memberIds = room.members.map((m) => m.userId); + const memberMeta = await getUsersMeta(memberIds); + + return { + room: { + id: room.id, + name: room.name, + description: room.description, + isPublic: room.isPublic, + isDefault: room.isDefault, + joinMode: room.joinMode, + avatarUrl: room.avatarUrl ?? null, + inviteCode: + member?.role === "owner" || member?.role === "admin" + ? room.inviteCode + : null, + memberCount: room.memberCount, + createdBy: room.createdBy, + myRole: member?.role ?? null, + isMember: !!member && member.status === "active", + }, + members: room.members.map((m) => ({ + userId: m.userId, + role: m.role, + nickname: memberMeta[m.userId]?.nickname ?? "Anonym", + avatar: memberMeta[m.userId]?.avatar ?? null, + })), + messages: messages.reverse().map((m) => ({ + id: m.id, + userId: m.userId, + nickname: meta[m.userId]?.nickname ?? "Anonym", + avatar: meta[m.userId]?.avatar ?? null, + content: m.content, + replyTo: m.replyTo + ? { + id: m.replyTo.id, + userId: m.replyTo.userId, + nickname: meta[m.replyTo.userId]?.nickname ?? "Anonym", + content: m.replyTo.content.slice(0, 100), + } + : null, + attachmentUrl: m.attachmentUrl, + attachmentType: m.attachmentType, + attachmentName: m.attachmentName, + likesCount: m.likesCount, + createdAt: m.createdAt, + isOwn: m.userId === user.id, + })), + hasMore: messages.length === 50, + }; +}); diff --git a/backend/server/api/chat/rooms/[roomId]/index.patch.ts b/backend/server/api/chat/rooms/[roomId]/index.patch.ts new file mode 100644 index 0000000..29930a6 --- /dev/null +++ b/backend/server/api/chat/rooms/[roomId]/index.patch.ts @@ -0,0 +1,137 @@ +import { + getMember, + updateRoom, + getPendingRequests, + approveRequest, + rejectRequest, + banMember, + setMemberRole, + createRoomMessage, +} from "../../../../db/chat-rooms"; +import { getUsersMeta } from "../../../../utils/getUsersMeta"; + +/** PATCH /api/chat/rooms/[roomId] – Room editieren + Anfragen verwalten */ +export default defineEventHandler(async (event) => { + const user = await requireUser(event); + const roomId = getRouterParam(event, "roomId"); + if (!roomId) throw createError({ statusCode: 400, message: "roomId fehlt" }); + + const member = await getMember(roomId, user.id); + if (!member || (member.role !== "owner" && member.role !== "admin")) { + throw createError({ + statusCode: 403, + message: "Nur Admins können den Raum bearbeiten", + }); + } + + const body = await readBody(event); + + // Handle join request approval/rejection + if (body?.action === "approve" && body?.targetUserId) { + await approveRequest(roomId, body.targetUserId); + return { ok: true, action: "approved" }; + } + if (body?.action === "reject" && body?.targetUserId) { + await rejectRequest(roomId, body.targetUserId); + return { ok: true, action: "rejected" }; + } + + // Handle pending requests list + if (body?.action === "list_requests") { + const requests = await getPendingRequests(roomId); + const userIds = requests.map((r) => r.userId); + const meta = userIds.length > 0 ? await getUsersMeta(userIds) : {}; + return requests.map((r) => ({ + userId: r.userId, + nickname: meta[r.userId]?.nickname ?? "Anonym", + avatar: meta[r.userId]?.avatar ?? null, + requestedAt: r.joinedAt, + })); + } + + // Ban member – owner OR admin can ban regular members (not other admins unless owner) + if (body?.action === "ban" && body?.targetUserId) { + const target = await getMember(roomId, body.targetUserId); + if (!target) + throw createError({ + statusCode: 404, + message: "Mitglied nicht gefunden", + }); + if (target.role === "owner") + throw createError({ + statusCode: 403, + message: "Owner kann nicht gebannt werden", + }); + // Admins dürfen keine anderen Admins bannen – nur Owner darf das + if (target.role === "admin" && member.role !== "owner") { + throw createError({ + statusCode: 403, + message: "Nur der Owner kann Admins bannen", + }); + } + const bannedMeta = await getUsersMeta([body.targetUserId]); + const bannedName = + bannedMeta[body.targetUserId]?.nickname ?? "Ein Mitglied"; + await banMember(roomId, body.targetUserId); + // Systemnachricht in Gruppe + const SYSTEM_ID = "00000000-0000-0000-0000-000000000000"; + await createRoomMessage({ + userId: SYSTEM_ID, + roomId, + content: `🚫 ${bannedName} wurde aus der Gruppe entfernt.`, + }).catch(() => {}); + return { ok: true, action: "banned" }; + } + + // Promote member to admin – only Owner can do this (not sub-admins) + if (body?.action === "promote_admin" && body?.targetUserId) { + if (member.role !== "owner") { + throw createError({ + statusCode: 403, + message: "Nur der Owner kann Admins ernennen", + }); + } + const target = await getMember(roomId, body.targetUserId); + if (!target || target.status !== "active") { + throw createError({ + statusCode: 404, + message: "Mitglied nicht gefunden", + }); + } + await setMemberRole(roomId, body.targetUserId, "admin"); + return { ok: true, action: "promoted" }; + } + + // Demote admin back to member – only Owner can do this + if (body?.action === "demote_admin" && body?.targetUserId) { + if (member.role !== "owner") { + throw createError({ + statusCode: 403, + message: "Nur der Owner kann Admins zurückstufen", + }); + } + await setMemberRole(roomId, body.targetUserId, "member"); + return { ok: true, action: "demoted" }; + } + + // Update room settings + const update: Record = {}; + if (body?.name) update.name = String(body.name).trim().slice(0, 60); + if (body?.description !== undefined) + update.description = String(body.description).trim().slice(0, 200) || null; + if ( + body?.joinMode && + ["open", "approval", "invite_only"].includes(body.joinMode) + ) { + update.joinMode = body.joinMode; + } + if (body?.avatarUrl !== undefined) { + update.avatarUrl = body.avatarUrl ? String(body.avatarUrl).trim() : null; + } + + if (Object.keys(update).length > 0) { + await updateRoom(roomId, update); + } + + return { ok: true }; +}); diff --git a/backend/server/api/chat/rooms/[roomId]/join.post.ts b/backend/server/api/chat/rooms/[roomId]/join.post.ts new file mode 100644 index 0000000..daea20c --- /dev/null +++ b/backend/server/api/chat/rooms/[roomId]/join.post.ts @@ -0,0 +1,33 @@ +import { getRoom, getMember, joinRoom } from "../../../../db/chat-rooms"; + +/** POST /api/chat/rooms/[roomId]/join – Beitreten oder Anfrage senden */ +export default defineEventHandler(async (event) => { + const user = await requireUser(event); + const roomId = getRouterParam(event, "roomId"); + if (!roomId) throw createError({ statusCode: 400, message: "roomId fehlt" }); + + const room = await getRoom(roomId); + if (!room) + throw createError({ statusCode: 404, message: "Raum nicht gefunden" }); + + const existing = await getMember(roomId, user.id); + if (existing?.status === "active") { + return { status: "already_member" }; + } + + if (room.joinMode === "open") { + await joinRoom(roomId, user.id, "active"); + return { status: "joined" }; + } + + if (room.joinMode === "approval") { + if (existing?.status === "pending") { + return { status: "already_pending" }; + } + await joinRoom(roomId, user.id, "pending"); + return { status: "pending" }; + } + + // invite_only + throw createError({ statusCode: 403, message: "Nur per Einladung" }); +}); diff --git a/backend/server/api/chat/rooms/[roomId]/leave.post.ts b/backend/server/api/chat/rooms/[roomId]/leave.post.ts new file mode 100644 index 0000000..325dd69 --- /dev/null +++ b/backend/server/api/chat/rooms/[roomId]/leave.post.ts @@ -0,0 +1,23 @@ +import { getRoom, getMember, leaveRoom } from "../../../../db/chat-rooms"; + +/** POST /api/chat/rooms/[roomId]/leave – Raum verlassen */ +export default defineEventHandler(async (event) => { + const user = await requireUser(event); + const roomId = getRouterParam(event, "roomId"); + if (!roomId) throw createError({ statusCode: 400, message: "roomId fehlt" }); + + const room = await getRoom(roomId); + if (!room) throw createError({ statusCode: 404, message: "Raum nicht gefunden" }); + + const member = await getMember(roomId, user.id); + if (!member || member.status !== "active") { + throw createError({ statusCode: 400, message: "Kein Mitglied" }); + } + + if (member.role === "owner" && !room.isDefault) { + throw createError({ statusCode: 400, message: "Owner kann den Raum nicht verlassen – lösche ihn stattdessen" }); + } + + await leaveRoom(roomId, user.id); + return { ok: true }; +}); diff --git a/backend/server/api/chat/rooms/[roomId]/messages.post.ts b/backend/server/api/chat/rooms/[roomId]/messages.post.ts new file mode 100644 index 0000000..e8860d4 --- /dev/null +++ b/backend/server/api/chat/rooms/[roomId]/messages.post.ts @@ -0,0 +1,42 @@ +import { getMember, createRoomMessage } from "../../../../db/chat-rooms"; +import { getUsersMeta } from "../../../../utils/getUsersMeta"; + +/** POST /api/chat/rooms/[roomId]/messages – Nachricht senden */ +export default defineEventHandler(async (event) => { + const user = await requireUser(event); + const roomId = getRouterParam(event, "roomId"); + if (!roomId) throw createError({ statusCode: 400, message: "roomId fehlt" }); + + const member = await getMember(roomId, user.id); + if (!member || member.status !== "active") { + throw createError({ statusCode: 403, message: "Kein Mitglied dieses Raums" }); + } + + const body = await readBody(event); + const content = (body?.content ?? "").trim(); + if (!content && !body?.attachmentUrl) { + throw createError({ statusCode: 400, message: "Nachricht oder Anhang erforderlich" }); + } + if (content.length > 2000) { + throw createError({ statusCode: 400, message: "Nachricht zu lang (max. 2000 Zeichen)" }); + } + + const msg = await createRoomMessage({ + userId: user.id, + roomId, + content: content || "", + replyToId: body?.replyToId, + attachmentUrl: body?.attachmentUrl, + attachmentType: body?.attachmentType, + attachmentName: body?.attachmentName, + }); + + const meta = await getUsersMeta([user.id]); + + return { + ...msg, + nickname: meta[user.id]?.nickname ?? "Anonym", + avatar: meta[user.id]?.avatar ?? null, + isOwn: true, + }; +}); diff --git a/backend/server/api/chat/rooms/index.get.ts b/backend/server/api/chat/rooms/index.get.ts new file mode 100644 index 0000000..51a2bb6 --- /dev/null +++ b/backend/server/api/chat/rooms/index.get.ts @@ -0,0 +1,46 @@ +import { listRooms, seedDefaultRooms } from "../../../db/chat-rooms"; +import { getUsersMeta } from "../../../utils/getUsersMeta"; + +/** GET /api/chat/rooms – Alle Rooms (public + eigene) */ +export default defineEventHandler(async (event) => { + const user = await requireUser(event); + + // Auto-seed default groups on first request + await seedDefaultRooms().catch(() => {}); + + const rooms = await listRooms(user.id); + + // Resolve user meta for last message senders + const senderIds = rooms + .map((r) => r.messages[0]?.userId) + .filter(Boolean) as string[]; + const meta = senderIds.length > 0 ? await getUsersMeta(senderIds) : {}; + + return rooms.map((r) => { + const lastMsg = r.messages[0]; + const isMember = r.members.some((m) => m.userId === user.id); + const myRole = r.members.find((m) => m.userId === user.id)?.role ?? null; + return { + id: r.id, + name: r.name, + description: r.description, + isPublic: r.isPublic, + isDefault: r.isDefault, + joinMode: r.joinMode, + avatarUrl: r.avatarUrl ?? null, + inviteCode: + myRole === "owner" || myRole === "admin" ? r.inviteCode : null, + memberCount: r.memberCount, + isMember, + myRole, + createdBy: r.createdBy, + lastMessage: lastMsg + ? { + content: lastMsg.content.slice(0, 80), + createdAt: lastMsg.createdAt, + senderName: meta[lastMsg.userId]?.nickname ?? "Anonym", + } + : null, + }; + }); +}); diff --git a/backend/server/api/chat/rooms/index.post.ts b/backend/server/api/chat/rooms/index.post.ts new file mode 100644 index 0000000..23834f9 --- /dev/null +++ b/backend/server/api/chat/rooms/index.post.ts @@ -0,0 +1,47 @@ +import { createRoom } from "../../../db/chat-rooms"; +import { getProfile } from "../../../db/profile"; + +/** POST /api/chat/rooms – Room erstellen (legend only) */ +export default defineEventHandler(async (event) => { + const user = await requireUser(event); + + // Legend-Check + const profile = await getProfile(user.id); + if (profile?.plan !== "legend") { + throw createError({ + statusCode: 403, + message: "Nur Legend-Mitglieder können Gruppen erstellen", + }); + } + + const body = await readBody(event); + const name = (body?.name ?? "").trim(); + if (!name || name.length > 60) { + throw createError({ + statusCode: 400, + message: "Name erforderlich (max. 60 Zeichen)", + }); + } + + const description = (body?.description ?? "").trim().slice(0, 200) || null; + const isPublic = body?.isPublic === true; + const joinMode = isPublic + ? "open" + : body?.joinMode === "approval" + ? "approval" + : "invite_only"; + + const room = await createRoom({ + name, + description: description ?? undefined, + isPublic, + joinMode, + createdBy: user.id, + avatarUrl: + typeof body?.avatarUrl === "string" + ? body.avatarUrl.trim() || undefined + : undefined, + }); + + return room; +}); diff --git a/backend/server/api/coach/history.delete.ts b/backend/server/api/coach/history.delete.ts new file mode 100644 index 0000000..218020d --- /dev/null +++ b/backend/server/api/coach/history.delete.ts @@ -0,0 +1,14 @@ +import { usePrisma } from "../../utils/prisma"; + +/** + * DELETE /api/coach/history + * Löscht den gespeicherten Chat-Verlauf (Pro/Legend). + */ +export default defineEventHandler(async (event) => { + const user = await requireUser(event); + + const db = usePrisma(); + await db.coachSession.deleteMany({ where: { userId: user.id } }); + + return { ok: true }; +}); diff --git a/backend/server/api/coach/history.get.ts b/backend/server/api/coach/history.get.ts new file mode 100644 index 0000000..8aae247 --- /dev/null +++ b/backend/server/api/coach/history.get.ts @@ -0,0 +1,42 @@ +import { usePrisma } from "../../utils/prisma"; + +/** + * GET /api/coach/history + * Lädt den gespeicherten Chat-Verlauf (nur Pro/Legend). + * Gibt das neueste CoachSession-Dokument zurück. + */ +export default defineEventHandler(async (event) => { + const user = await requireUser(event); + console.log("[coach/history] userId:", user.id); + + const db = usePrisma(); + + const profile = await db.profile.findUnique({ + where: { id: user.id }, + select: { plan: true }, + }); + console.log("[coach/history] plan:", profile?.plan); + + if (!profile || !["pro", "legend"].includes(profile.plan)) { + return { messages: [] }; + } + + const session = await db.coachSession.findFirst({ + where: { userId: user.id }, + orderBy: { createdAt: "desc" }, + }); + console.log( + "[coach/history] session found:", + !!session, + "msgs:", + Array.isArray(session?.content) ? (session.content as any[]).length : 0, + ); + + if (!session) { + return { messages: [] }; + } + + return { + messages: session.content as Array<{ role: string; content: string }>, + }; +}); diff --git a/backend/server/api/coach/message.post.ts b/backend/server/api/coach/message.post.ts new file mode 100644 index 0000000..459dce8 --- /dev/null +++ b/backend/server/api/coach/message.post.ts @@ -0,0 +1,544 @@ +export const COACH_SYSTEM_PROMPT = `Du bist Lyra, der KI-Coach der App "ReBreak" – eine Bewegung von Menschen, die gemeinsam gegen die manipulativen Taktiken der Gambling-Industrie kämpfen. +Du bist einfühlsam, stärkend und verwendest Techniken der kognitiven Verhaltenstherapie (CBT). + +SPRACHE & HALTUNG – ABSOLUT KRITISCH: +- Verwende NIEMALS die Begriffe "Sucht", "Spielsucht", "Abhängigkeit", "Suchtkranker", "süchtig" oder ähnliche Pathologisierungen. +- Der User ist KEIN Patient und KEINE kranke Person. Er ist ein Mensch, der gegen ein System kämpft, das darauf ausgelegt war, ihn zu manipulieren. +- Ersetze "Sucht" durch: "Herausforderung", "Kampf", "diese Phase", "dein Weg", "deine Erfahrung" +- Ersetze "süchtig sein" durch: "in der Falle der Gambling-Industrie gewesen sein", "von einem manipulativen System erwischt worden sein" +- Der User ist kein Opfer und kein Kranker – er ist ein Kämpfer, der sich befreit. +- Formuliere so: "Die Gambling-Industrie hat Milliarden investiert um Menschen genau in diese Situation zu bringen – du erkennst das und kämpfst zurück. Das ist Stärke." +- Vermittle das Gefühl: Du bist nicht allein, du bist Teil einer Gemeinschaft die zusammen kämpft. + +ÜBER DICH: +- Du heißt Lyra und bist der persönliche Begleiter in der ReBreak-App. +- Du bist KEIN Therapeut und kein Arzt – das sagst du auch ehrlich, wenn nötig. +- Sei nie wertend. Stelle offene Fragen. Hör zu. Sei kurz (max 3 Sätze pro Antwort). + +ÜBER REBREAK: +ReBreak wurde von Chahine gegründet – aus persönlicher Überzeugung, nicht aus Profitinteresse. Die Gambling-Industrie investiert Milliarden in psychologische Tricks, Dark Patterns und manipulatives Design – ReBreak gibt Menschen die Werkzeuge zurück, um sich zu wehren. +ReBreak ist KEINE gewöhnliche App. ReBreak ist eine Bewegung. Eine Gemeinschaft von Menschen, die sich gegenseitig den Rücken stärken. Jedes Feature wurde gebaut, weil echte Menschen es gebraucht haben. Die Community entscheidet aktiv mit, welche Domains gesperrt werden – das ist Selbstverteidigung, organisiert von der Community. + +FEATURES: +- Gambling-Blocker: 208.000+ Domains werden laufend aktualisiert gesperrt (auch Offshore-Casinos ohne Lizenz, die OASIS nicht kennt). Cooldown-Schutz verhindert impulsives Deaktivieren. +- Streak-Tracker: Zeigt spielfreie Tage und geschätztes gespartes Geld. Meilenstein-Badges motivieren. +- SOS-Hilfe: Geführte Übungen für akute Drang-Momente. Der Drang dauert meist nur 15-20 Minuten. +- SOS-Spiele-Sammlung: Memory, Tic-Tac-Toe, Snake, Tetris – echte Skill-Spiele (KEIN Glücksspiel) mit Highscore und Community-Ranking. Bewusste Ablenkung in den kritischen 15–20 Minuten. Jedes gespielte Spiel ist ein Beweis: Du hast den Drang ohne Casino überwunden. +- 4-7-8 Atemübung: Wissenschaftlich belegte Technik zum Puls senken (4s einatmen, 7s halten, 8s ausatmen). +- Mail-Schutz: Scannt alle Mail-Ordner (Inbox, Spam, Archiv, Papierkorb) und löscht Casino-Mails permanent – kein Mail-Inhalt wird gelesen, nur Absender & Betreff. +- Community: Echte Menschen, die denselben Kampf kennen. Anonyme Posts, gegenseitige Unterstützung. Gemeinsames Domain-Voting. +- Du (Lyra): KI-Coach mit CBT-Ansatz, personalisiert, ohne Urteil, immer verfügbar. + +CUSTOM DOMAINS & COMMUNITY-VOTING: +Jeder Pro/Legend-User kann eigene Casino-Domains melden, die er im Netz entdeckt. Es gibt zwei Wege je nach Plan: + +**Pro-Workflow (Standard, mit Community-Vote):** +1. Pro-User reicht Domain ein +2. Community stimmt ab — sobald 10 Mitglieder mit "Ja" stimmen, wandert die Domain zum ReBreak-Admin +3. Der Admin überprüft final innerhalb von 24 Stunden — wenn legitim, wird die Domain in die globale Blocklist aufgenommen, für ALLE Pro- und Legend-User weltweit gesperrt +4. Der einreichende User bekommt seinen Slot zurück + +**Legend-Workflow (privilegiert, ohne Community-Vote):** +1. Legend-User reicht Domain ein +2. Domain geht DIREKT und PRIORISIERT zum ReBreak-Admin — kein Community-Vote nötig +3. Admin-Prüfung erfolgt schneller (nicht 24h, sondern priorisiert in der Queue) +4. Bei Genehmigung: globale Blocklist-Aufnahme + Slot-Refill + +Das ist ein echter Legend-Vorteil: Legend-User haben das Vertrauen der Plattform — ihre Submissions werden ohne Umweg über die Community behandelt. Wenn ein User fragt was das genau bringt: erkläre dass Legend-Submissions schneller (priorisiert) und ohne Community-Hürde direkt zum Team kommen. + +So wächst die Sperrliste durch die Community gemeinsam mit dem ReBreak-Team — Selbstverteidigung organisiert von Menschen, final validiert vom Team. Wenn ein User eine Domain meldet oder fragt wie das funktioniert, erkläre den Workflow klar je nach seinem Plan. + +PLÄNE & PREISE: +ReBreak hat drei Stufen – jede gibt Menschen mehr Werkzeuge in die Hand. Die Plan-Details werden zur Laufzeit aus plan-features.ts gefüllt: + +{{PLAN_DETAILS}} + +FOUNDING MEMBERS: +Die ersten 100 User von ReBreak sind "Founding Members" – sie bekommen 3 Monate Legend komplett kostenlos. Das ist unser Danke an alle, die von Anfang an dabei sind und diese Bewegung mitaufbauen. Wenn ein User fragt ob er Founding Member ist oder wie das funktioniert: erkläre es herzlich und betone, dass Founding Members die Pioniere sind, die ReBreak mitgestalten. + +POSITIONIERUNG IM DACH-RAUM: +ReBreak ist im deutschsprachigen Raum (Deutschland, Österreich, Schweiz) die EINZIGE App, die kompletten Schutz auf allen Schichten gleichzeitig bietet: +- Browser-Filter (208.000+ Domains, native auf iOS via NEFilterDataProvider) +- Mail-Schutz (Echtzeit-IMAP-Überwachung für Legend) +- Community-getragene Blocklist (Selbstverteidigung organisiert von der Community) +- KI-Coach (du, Lyra) als emotionaler Begleiter + +Selbstverständnis "OASIS für die digitale Zeit": +OASIS ist das staatliche Sperrsystem in Deutschland — es greift bei lizenzierten Anbietern (Spielbanken, deutsche Online-Casinos mit Lizenz). OASIS hat aber strukturell eine GROßE Lücke: Offshore-Casinos ohne deutsche Lizenz, Telegram/Discord-Kanäle, Casino-Mails im Postfach, Mobile-Werbung — all das erreicht OASIS nicht. ReBreak schließt genau diese Lücke. ReBreak ist die digitale Ergänzung — was OASIS für die regulierte Welt ist, ist ReBreak für alles andere im Netz. Wenn ein User über OASIS spricht oder fragt warum es ReBreak braucht obwohl OASIS existiert: positioniere ReBreak als "OASIS für die digitale Zeit" — wir konkurrieren NICHT, wir ergänzen. + +Weitere Markt-Fakten: +- DiGA-Zertifizierung in Bearbeitung: ReBreak strebt die Listung als Digitale Gesundheitsanwendung beim BfArM an. Wenn zertifiziert, kann ReBreak von Ärzten auf Rezept verschrieben werden – die gesetzliche Krankenkasse übernimmt dann die Kosten. Wenn ein User fragt: erkläre dass wir den Prozess aktiv betreiben, aber keinen Termin versprechen können (Zertifizierung dauert). +- iOS-Schutz nahezu perfekt: Auf iOS wird ein Native-Filter (NEFilterDataProvider) verwendet, der system-tief gegen Bypass-Versuche schützt. Keine andere App im DACH-Markt erreicht dieses Schutz-Niveau. + +SCHUTZ-MECHANISMEN & TECHNISCHE ARCHITEKTUR (passives Wissen – nur auf Nachfrage erklären): + +iOS (iPhone & iPad): +- ReBreak nutzt Apples "Family Controls"-Framework kombiniert mit "NEFilterDataProvider" (Network Extension Filter). +- Der Filter läuft als System-Extension, NICHT als normaler App-Prozess. Das bedeutet: er bleibt aktiv, auch wenn die ReBreak-App geschlossen ist, aus dem App-Switcher gewischt wurde oder vom Home-Bildschirm entfernt scheint. +- Der User kann den Filter NICHT in den iOS-Einstellungen manuell abschalten (Tamper-Protection durch Family Controls Authorization Center). Es gibt keinen Toggle, den man mal eben antippen kann. +- Technische Randbemerkung (falls User fragt): Apple lässt Apps aus Datenschutzgründen nicht auf den nutzergesetzten Gerätenamen (z.B. "Chahines iPhone") zugreifen – das ist eine bewusste Apple-Entscheidung, keine ReBreak-Einschränkung. + +Android: +- ReBreak arbeitet mit zwei Schutz-Schichten (beide müssen aktiviert sein): + 1. Lokales VPN: filtert DNS-Anfragen und blockt Glücksspielseiten, ohne Traffic an externe Server zu senden. Läuft vollständig auf dem Gerät. + 2. Bedienungshilfen-Service (Accessibility Service): überwacht dass das VPN nicht spontan deaktiviert wird und schützt die Schutz-Konfiguration. +- Wenn ein User das VPN oder den Bedienungshilfen-Service deaktivieren möchte, greift ein 6-Stunden-Cooldown – der Effekt tritt erst nach dieser Wartezeit ein. Diese Anti-Impuls-Sicherheit gibt dem User Zeit, den Impulsmoment zu überstehen, ohne den Schutz zu zerstören. + +Geräte-Limit: +- Free: 1 Gerät, Pro: 1 Gerät, Legend: bis zu 3 Geräte gleichzeitig. +- Das Limit schützt davor, dass ein User in einem Impulsmoment schnell ein ungeschütztes Zweitgerät registriert um den Schutz zu umgehen. Wenn das Limit erreicht ist, erscheint ein Modal das die Verwaltung ermöglicht. +- Geplant (Phase 2): Ein 24-Stunden-Cooldown auf Geräte-Freigaben, damit auch dieser Weg nicht spontan als Bypass genutzt werden kann. + +Custom Domains (Schutz-Ergänzung): +- Jeder User kann selbst entdeckte Glücksspiel-Domains zu seiner persönlichen Blocklist hinzufügen. +- Kontingent: Free = 5 Slots (nicht rückfüllbar), Pro = 5 Slots (rückfüllbar), Legend = 10 Slots (rückfüllbar). +- Pro/Legend können Domains zur globalen Liste einreichen (Community-Vote bzw. direkter Admin-Review) – so wächst die Blockliste durch die Gemeinschaft. + +PHILOSOPHIE DES SCHUTZES (verwende dies wenn ein User die Strenge des Schutzes kritisiert oder fragt warum er ihn nicht einfach abschalten kann): + +Wenn ein User klagt dass er den Schutz nicht spontan deaktivieren kann, das VPN unfair findet, oder sich durch Family Controls eingeschränkt fühlt: validiere zuerst seine Frustration – es ist verständlich, dass sich das beengend anfühlt. Erkläre dann sanft: + +ReBreak will NICHT die Freiheit des Users einschränken. Es ist ein Selbstschutz-Mechanismus, der in Zusammenarbeit mit Sucht-Therapeuten und evidenzbasierter Forschung gestaltet wurde. Die Gambling-Industrie greift gezielt in Impuls-Momenten an – genau dann, wenn der Verstand am schwächsten und der Drang am stärksten ist. Wenn der Schutz mit drei Taps brechbar wäre, hätte er gegen diesen Moment keinen Wert. + +Die 6-Stunden-Cooldowns und die nicht-togglbaren Filter sind kein Misstrauen gegenüber dem User – sie sind ein VERTRAG des Users mit sich selbst. Geschlossen in einem ruhigen Moment der Klarheit, als Schutz gegen den Sturm im nächsten Moment. Das ist evidenzbasierte Rückfall-Prävention: Stimulus-Control und Response-Prevention – dieselben Methoden, die Therapeuten in der kognitiven Verhaltenstherapie einsetzen. + +Formuliere es so: "ReBreak sperrt dich nicht ein. Du hast in einem ruhigen Moment entschieden, dir selbst diesen Schutz zu geben. Die App hält diesen Vertrag mit dir – auch wenn der Impuls gerade ruft." + +Schäme den User NIE. Sage NIE "das ist für deinen eigenen Schutz" in einem bevormundenden Ton. Validiere die Frustration, erkläre die Architektur-Entscheidung, und gib dem User das Gefühl dass er der Autor dieser Entscheidung ist – nicht das Opfer. + +Bei Fragen zu Partnerschaften (mit Suchtberatungsstellen, Krankenkassen, Behörden, GGL etc.): +- KEINE konkreten Namen, Verträge, Termine oder Zahlen nennen. +- Andeute, dass im Hintergrund Gespräche und Kooperationsanbahnungen laufen — ohne Details. Formuliere z.B.: "Wir sind in Kontakten mit relevanten Stellen, aber zu konkreten Partnerschaften können wir uns aktuell noch nicht öffentlich äußern. Sobald etwas spruchreif ist, hört ihr es als Erstes von uns." +- Zeige Vertrauen ("da läuft was"), ohne Erwartungen zu wecken oder unhaltbare Versprechen zu machen. + +Wenn ein User fragt warum ReBreak besser ist als andere Lösungen, oder ob es Konkurrenz gibt, oder ob die Krankenkasse zahlt: nutze diese Fakten – sachlich, nicht werblich. + +MAIL-SCHUTZ JE NACH PLAN: +- Free: 1 Mail-Konto, automatischer Scan alle 4h, nur eigene Custom Domains als Absender-Filter +- Pro: bis 3 Mail-Konten, wählbarer Scan-Rhythmus (1h/4h/8h), globale 208k+ Blocklist + Custom Domains +- Legend: unbegrenzte Konten, Echtzeit-IMAP-IDLE-Daemon – Casino-Mails werden in Sekunden erkannt und permanent gelöscht, bevor die Mail-App sie je anzeigt +- Alle Pläne: Scannt ALLE Ordner (Inbox, Spam, Papierkorb, Archiv, Gesendet etc.), löscht Treffer permanent. Kein Mail-Inhalt wird gelesen – nur Absender & Betreff. + +DATENSCHUTZ & VERTRAUEN: +- ReBreak nimmt Datenschutz sehr ernst (strenge DSGVO-Konformität). +- Anonyme Nutzung ist möglich – man kann komplett anonym starten. +- Keine Daten werden verkauft oder an Dritte weitergegeben. + +FEEDBACK & IDEEN: +- Wenn der User Feedback, eine Idee oder einen Verbesserungsvorschlag teilt: Bestätige IMMER positiv dass du es notiert hast und es an das Team weitergeleitet wird. +- Sag NIEMALS dass du kein Feedback weiterleiten kannst – das stimmt nicht, denn jedes Feedback wird automatisch gespeichert und vom Team gelesen. +- Beispiel: "Super Idee! Ich habe das direkt notiert und ans ReBreak-Team weitergeleitet. 📝" +- Wenn der User fragt "Was ist der Status meiner Idee?" oder "Wurde mein Vorschlag umgesetzt?" oder ähnliches: Schau in den Kontext-Block "FEEDBACK & IDEEN DIESES USERS" und berichte vollständig – jede Idee mit ihrem aktuellen Status und dem Kommentar des Teams (falls vorhanden). Zitiere den Team-Kommentar wörtlich. + +VERHALTE DICH SO: +- ReBreak ist eine Bewegung, keine Firma. Kommuniziere das Gefühl: "Wir kämpfen zusammen." +- Erwähne ReBreak-Features nur wenn es im Kontext passt und dem User hilft, NIEMALS aufdringlich oder werblich. +- Wenn jemand nach Preisen fragt: erkläre sachlich und betone den Wert für den Schutz, nicht den Preis. Betone, dass Free schon viel bietet und Pro/Legend für die sind, die noch mehr Schutz wollen. +- Wenn der User Drang verspürt → weise auf SOS-Hilfe oder Atemübung hin. Formuliere: "Die Gambling-Industrie hat diesen Moment extra designed – wir haben auch etwas designed, das dagegen hilft." +- Wenn der User sich einsam fühlt → erwähne die Community und dass tausende denselben Kampf kennen. +- Wenn der User über Trigger-Mails spricht → erkläre den Mail-Schutz passend zu seinem Plan. +- Wenn der User eine Casino-Domain entdeckt hat → erkläre dass er sie melden und zur Community-Abstimmung stellen kann. +- Wenn der User nach Datenschutz fragt → versichere strenge DSGVO, anonyme Nutzung. +- Vermeide es, Glücksspiel-Inhalte zu erwähnen oder zu beschreiben. +- Wenn der User sagt er hat "rückfällig" gespielt: Sag NICHT "Rückfall in die Sucht". Sage stattdessen: "Du warst kurz wieder in der Falle – das passiert. Wichtig ist, dass du wieder hier bist und weiterkämpfst." + +BEI ERNSTHAFTEN KRISEN verweise IMMER auf: +- Deutschland: check-dein-spiel.de / 0800 1372700 (kostenlos, 24/7) +- Österreich: spielsuchthilfe.at +- Schweiz: 0800 040 080`; + +import { getProfile } from "../../db/profile"; +import { getPlanLimits, PLAN_LIMITS } from "../../utils/plan-features"; +import { usePrisma } from "../../utils/prisma"; +import { getMemoriesForUser, markReferenced } from "../../db/lyraMemory"; +import { extractAndStoreMemories } from "../../utils/lyraMemoryExtract"; + +/** + * Lyra-Plan-Beschreibung dynamisch aus PLAN_LIMITS generieren. + * Single-Source-of-Truth: plan-features.ts. Wenn dort Limits geändert werden, + * weiß Lyra automatisch Bescheid (kein Prompt-Sync nötig). + * + * Preise bleiben hier hardcoded — gehören eher zur Billing-Domain. + */ +function generatePlanDetails(): string { + const free = PLAN_LIMITS.free; + const pro = PLAN_LIMITS.pro; + const legend = PLAN_LIMITS.legend; + const fmtCount = (n: number) => (n === Infinity ? "Unbegrenzt" : String(n)); + const refillNote = (refill: boolean) => + refill + ? "(rückfüllbar – Slot wird wieder frei wenn die Domain global aufgenommen ODER von der Community abgelehnt wurde)" + : "(NICHT rückfüllbar – einmal belegt, bleibt für immer belegt)"; + + return `Free (0 €): +- Gambling-Blocker mit ${free.customDomains} eigenen Custom Domains ${refillNote(free.domainRefill)} +- Free kann Custom Domains NICHT zur Community-Abstimmung einreichen — das ist Pro/Legend exklusiv +- ${fmtCount(free.mailAgents)} Mail-Konto, Scan alle ${free.mailIntervalOptions[0]}h +- Streak-Tracker, SOS-Hilfe & Spiele-Sammlung, Atemübung +- Community (lesen, posten, voten) +- KI-Coach (du, Lyra – Basismodell) + +Pro (3,99 € / Monat oder 29 € / Jahr – spare 19 %): +- Alles aus Free PLUS: +- Zugang zur vollständigen 208.000+ globalen Blocklist (Community-gepflegt) +- ${pro.customDomains} Custom Domains ${refillNote(pro.domainRefill)} +- Bis zu ${fmtCount(pro.mailAgents)} Mail-Konten, Scan-Intervall wählbar (${pro.mailIntervalOptions.join("h / ")}h) +- Stärkeres KI-Modell (du, Lyra wirst zu einem 70B-Modell) +- Kann Custom Domains zur Community-Abstimmung einreichen + +Legend (7,99 € / Monat oder 59 € / Jahr – spare 38 %): +- Alles aus Pro PLUS: +- ${legend.customDomains} Custom Domains ${refillNote(legend.domainRefill)} +- ⭐ MULTI-DEVICE-SCHUTZ: App auf bis zu 3 WEITEREN Geräten gleichzeitig — Familie, Partner, Eltern können mitgeschützt werden ohne extra zu zahlen. Real-life-relevant: viele Betroffene haben mehrere Geräte (iPhone + iPad + alter Laptop) +- ⭐ MAIL-DAEMON (echter technischer Durchbruch — Alleinstellungsmerkmal!): ${fmtCount(legend.mailAgents)} Mail-Konten mit Echtzeit-IMAP-IDLE-Überwachung. Casino-Mails werden in Sekunden permanent gelöscht — sie tauchen nicht mal im Papierkorb auf. Der User sieht nichts. Kein "Sie haben gewonnen!"-Trigger erreicht je das Postfach. Keine andere App im Markt kann das. +- Privilegierte Domain-Einreichung: umgeht Community-Vote komplett. Domains werden direkt + priorisiert vom ReBreak-Admin geprüft (schneller als die 24h-Standard-Prüfung der Pro-Submissions). Vertrauensvorteil als Legend. +- Kann Community-Gruppen gründen (z.B. private Support-Gruppe mit Familie) +- Premium KI-Coach (Claude – du, Lyra wirst zu einem noch stärkeren Modell)`; +} + +const PROVIDER_CONFIG = { + groq: { + url: "https://api.groq.com/openai/v1/chat/completions", + keyName: "groqApiKey" as const, + }, + openrouter: { + url: "https://openrouter.ai/api/v1/chat/completions", + keyName: "openrouterApiKey" as const, + }, +} as const; + +const FEEDBACK_DETECTION_PROMPT = `Du analysierst eine Nutzer-Nachricht aus einer Gambling-Recovery-App. +Entscheide ob die Nachricht ein Feedback, einen Verbesserungsvorschlag oder einen Feature-Wunsch enthält. +Antworte NUR mit validem JSON, kein anderer Text. + +Format wenn Feedback erkannt: +{"isFeedback": true, "content": "", "category": "feature|bug|improvement"} + +Format wenn kein Feedback: +{"isFeedback": false}`; + +async function detectAndSaveFeedback( + userMessage: string, + userId: string, + config: ReturnType, +): Promise { + // Groq ist gesperrt → OpenRouter als Detection-Provider + const key = + (config.openrouterApiKey as string | undefined) ?? + (config.openaiApiKey as string | undefined); + if (!key) return false; + + const isOpenRouter = !!config.openrouterApiKey; + + try { + const res = await $fetch<{ choices: { message: { content: string } }[] }>( + isOpenRouter + ? "https://openrouter.ai/api/v1/chat/completions" + : "https://api.openai.com/v1/chat/completions", + { + method: "POST", + headers: { + Authorization: `Bearer ${key}`, + "Content-Type": "application/json", + ...(isOpenRouter && { + "HTTP-Referer": "https://rebreak.org", + "X-Title": "ReBreak Coach", + }), + }, + body: { + model: isOpenRouter + ? "meta-llama/llama-3.1-8b-instruct" + : "gpt-4o-mini", + max_tokens: 150, + temperature: 0, + messages: [ + { role: "system", content: FEEDBACK_DETECTION_PROMPT }, + { role: "user", content: userMessage.slice(0, 500) }, + ], + }, + timeout: 8000, + }, + ); + + const raw = res.choices?.[0]?.message?.content?.trim(); + if (!raw) return false; + + const parsed = JSON.parse(raw) as { + isFeedback: boolean; + content?: string; + category?: string; + }; + if (!parsed.isFeedback || !parsed.content) return false; + + const db = usePrisma(); + await db.feedbackItem.create({ + data: { + userId, + content: parsed.content, + category: parsed.category ?? null, + }, + }); + console.log("[coach/feedback] saved:", parsed.content); + return true; + } catch (e) { + console.error("[coach/feedback] detection error:", e); + return false; + } +} + +export default defineEventHandler(async (event) => { + const user = await requireUser(event); + + const body = await readBody(event); + const { messages, locale, sosMode } = body as { + messages: Array<{ role: "user" | "assistant"; content: string }>; + locale?: string; + sosMode?: boolean; + }; + + if (!messages || !Array.isArray(messages)) { + throw createError({ statusCode: 400, message: "messages fehlt" }); + } + + const config = useRuntimeConfig(); + + const profile = await getProfile(user.id); + const limits = getPlanLimits(profile?.plan ?? "free"); + + // Fallback-Kette: führendes assistant-Message entfernen (Groq erfordert user als erste Nachricht) + const firstUserIdx = messages.findIndex((m) => m.role === "user"); + const conversation = + firstUserIdx > 0 ? messages.slice(firstUserIdx) : messages; + // Max 8 Nachrichten für Token-Effizienz + const trimmed = conversation.slice(-8); + + // System-Prompt aufbauen: Plan + Nickname + Feedback-Status-Updates + const userPlan = profile?.plan ?? "free"; + // Plan-Details dynamisch aus plan-features.ts injizieren — Lyra ist + // damit immer synchron mit dem Code der die Limits enforced. + let systemPrompt = COACH_SYSTEM_PROMPT.replace( + "{{PLAN_DETAILS}}", + generatePlanDetails(), + ); + + // Sprach-Instruktion: Lyra antwortet in der Sprache des Users + const LANG_INSTRUCTIONS: Record = { + de: "Antworte IMMER auf Deutsch, egal in welcher Sprache der User schreibt.", + en: "Always respond in English, regardless of what language the user writes in.", + tr: "Her zaman Türkçe yanıt ver, kullanıcı hangi dilde yazarsa yazsın.", + ar: "رد دائماً باللغة العربية، بغض النظر عن اللغة التي يكتب بها المستخدم.", + }; + const langInstruction = + LANG_INSTRUCTIONS[locale ?? "de"] ?? LANG_INSTRUCTIONS.de; + systemPrompt = `${langInstruction}\n\n${systemPrompt}`; + + // Plan-Kontext injizieren damit Lyra plan-spezifisch antwortet + const PLAN_LABELS: Record = { + free: "Free", + pro: "Pro (3,99 €/Monat oder 29 €/Jahr)", + legend: "Legend (7,99 €/Monat oder 59 €/Jahr)", + }; + systemPrompt = `AKTUELLER PLAN DES USERS: ${PLAN_LABELS[userPlan] ?? userPlan}\nWenn der User nach Features fragt die nicht in seinem Plan sind, erkläre was sein Plan bietet und was ein Upgrade zusätzlich bringen würde – sachlich, nicht werblich. Betone den Schutz-Wert, nicht den Preis.\n\n${systemPrompt}`; + + // Memory-Injection: Lyra-Erinnerungen aus früheren Sessions laden + let loadedMemoryIds: string[] = []; + try { + const memories = await getMemoriesForUser(user.id); + if (memories.length > 0) { + loadedMemoryIds = memories.map((m) => m.id); + const TYPE_LABELS: Record = { + trigger: "Trigger", + habit: "Gewohnheit", + strength: "Stärke", + relationship: "Wichtige Person", + milestone: "Meilenstein", + pain_point: "Sensibles Thema", + goal: "Ziel", + preference: "Präferenz", + }; + const lines = memories + .map((m) => `- ${TYPE_LABELS[m.type] ?? m.type}: ${m.content}`) + .join("\n"); + const memoryBlock = `[WAS DU ÜBER DIESEN USER WEISST — aus früheren Gesprächen]\n${lines}\nNutze diese Infos um GENAU diesen Menschen anzusprechen — wie ein echter Begleiter, nicht eine generische KI. Sprich Personen mit Namen an. Erinnere an Stärken die dir bekannt sind.\n\n`; + systemPrompt = `${memoryBlock}${systemPrompt}`; + console.log( + `[lyra-memory] injected ${memories.length} memories for ${user.id}`, + ); + } + } catch (e) { + console.error("[lyra-memory] load error (non-fatal):", e); + } + + try { + const db = usePrisma(); + // Nickname einbauen + const nickname = profile?.nickname || profile?.username; + if (nickname) { + systemPrompt = `NUTZER-NAME: Der Nutzer heißt "${nickname}" – nenne ihn gelegentlich bei seinem Namen wenn es natürlich passt.\n\n${systemPrompt}`; + } + // Alle Feedback-/Feature-Ideen des Users laden (inkl. PENDING, inkl. adminNote) + const feedbackItems = await db.feedbackItem.findMany({ + where: { userId: user.id }, + orderBy: { updatedAt: "desc" }, + take: 10, + select: { + content: true, + status: true, + adminNote: true, + category: true, + createdAt: true, + }, + }); + if (feedbackItems.length > 0) { + const STATUS_LABELS: Record = { + PENDING: "Noch ausstehend (wird gelesen)", + REVIEWING: "Wird geprüft 🔍", + PLANNED: "Ist geplant 📅", + SHIPPED: "Umgesetzt ✅", + REJECTED: "Nicht umsetzbar", + }; + const feedbackLines = feedbackItems + .map((f) => { + const statusLabel = STATUS_LABELS[f.status] ?? f.status; + const note = f.adminNote + ? `\n Kommentar des Teams: "${f.adminNote}"` + : ""; + return ` - Idee: "${f.content}"${f.category ? ` [${f.category}]` : ""}\n Status: ${statusLabel}${note}`; + }) + .join("\n"); + systemPrompt += `\n\nFEEDBACK & IDEEN DIESES USERS:\n${feedbackLines}\n\nWENN DER USER NACH SEINEN IDEEN ODER FEATURE-STATUS FRAGT: Berichte vollständig über jede Idee mit Status und Team-Kommentar. Wenn eine Idee ein Team-Kommentar hat, zitiere ihn wörtlich. Wenn der Status SHIPPED ist, gratuliere dem User.`; + } + } catch { + // Nicht kritisch + } + + // Fallback-Kette: primary → fallbacks der Reihe nach. + // SOS-Mode: Speed > Tiefe — User wartet im akuten Moment, jede Sekunde zählt. + // Llama 3.3 70B via Groq: ~500ms-1s vs Sonnet 4.5: 5-7s. Wärme kommt aus Prompt, nicht Modell. + const candidates = sosMode + ? ([ + { provider: "groq", model: "llama-3.3-70b-versatile" }, + { provider: "openrouter", model: "anthropic/claude-3.5-haiku" }, + { provider: "openrouter", model: "anthropic/claude-sonnet-4.5" }, + ] as const) + : [ + { provider: limits.aiProvider, model: limits.aiModel }, + ...limits.aiModelFallbacks, + ]; + + async function tryModel(providerName: "groq" | "openrouter", model: string) { + const p = PROVIDER_CONFIG[providerName]; + const key = config[p.keyName]; + if (!key) return null; + try { + const res = await $fetch<{ choices: { message: { content: string } }[] }>( + p.url, + { + method: "POST", + headers: { + Authorization: `Bearer ${key}`, + "Content-Type": "application/json", + "HTTP-Referer": "https://rebreak.org", + "X-Title": "ReBreak Coach", + }, + body: { + model, + max_tokens: sosMode ? 280 : 400, + messages: [{ role: "system", content: systemPrompt }, ...trimmed], + }, + timeout: 15000, + }, + ); + return res.choices?.[0]?.message?.content ?? null; + } catch (err: any) { + console.warn( + `[coach/tryModel] ${providerName}:${model} FAIL:`, + err?.statusCode ?? err?.status ?? "?", + err?.data?.error?.message ?? err?.message ?? String(err).slice(0, 200), + ); + return null; + } + } + + // Feedback-Detection + LLM parallel starten + const lastUserMsg = [...messages].reverse().find((m) => m.role === "user"); + const feedbackPromise = lastUserMsg?.content + ? detectAndSaveFeedback(lastUserMsg.content, user.id, config) + : Promise.resolve(false); + + let text: string | null = null; + let usedModel: string | null = null; + for (const candidate of candidates) { + text = await tryModel(candidate.provider, candidate.model); + if (text) { + usedModel = `${candidate.provider}:${candidate.model}`; + break; + } + } + console.log( + `[coach/message] sosMode=${!!sosMode} usedModel=${usedModel ?? "NONE"}`, + ); + + if (!text) { + throw createError({ + statusCode: 503, + message: "Coach momentan nicht verfügbar", + }); + } + + const feedbackSaved = await feedbackPromise; + + // Memory: markReferenced + Extraction fire-and-forget + if (loadedMemoryIds.length > 0) { + markReferenced(loadedMemoryIds).catch(() => {}); + } + if (text) { + const allMessages = [ + ...messages, + { role: "assistant" as const, content: text }, + ]; + const key = + config.openrouterApiKey as string | undefined; + extractAndStoreMemories(user.id, allMessages, undefined, key).catch( + () => {}, + ); + } + + // Chat-Verlauf für Pro/Legend in DB speichern + const plan = profile?.plan ?? "free"; + console.log("[coach/message] plan:", plan, "userId:", user.id); + if (plan === "pro" || plan === "legend") { + const fullHistory = [ + ...messages, + { role: "assistant" as const, content: text }, + ]; + const db = usePrisma(); + // Letztes 50 Nachrichten behalten (Token-Limit) + const trimmedHistory = fullHistory.slice(-50); + try { + const existing = await db.coachSession.findFirst({ + where: { userId: user.id }, + select: { id: true }, + }); + console.log("[coach/message] existing session:", existing?.id ?? "none"); + if (existing) { + await db.coachSession.update({ + where: { id: existing.id }, + data: { content: trimmedHistory }, + }); + } else { + await db.coachSession.create({ + data: { userId: user.id, content: trimmedHistory }, + }); + } + console.log( + "[coach/message] history saved, msgs:", + trimmedHistory.length, + ); + } catch (e) { + console.error("[coach/message] save error:", e); + } + } + + return { message: text, feedbackSaved }; +}); diff --git a/backend/server/api/coach/sos-session.post.ts b/backend/server/api/coach/sos-session.post.ts new file mode 100644 index 0000000..e10cab5 --- /dev/null +++ b/backend/server/api/coach/sos-session.post.ts @@ -0,0 +1,35 @@ +/** + * POST /api/coach/sos-session — Erstellt Session für SSE-Stream + * + * Client sendet messages + locale, Backend generiert sessionId + * und speichert Daten in-memory. Client nutzt dann GET /api/coach/sos-stream?session=xyz + * + * Grund: react-native-sse (EventSource API) unterstützt nur GET, nicht POST. + * Daher 2-Step-Flow: POST Session erstellen → GET Stream öffnen. + */ +export default defineEventHandler(async (event) => { + const user = await requireUser(event); + const body = await readBody(event); + const { messages, locale } = body as { + messages: Array<{ role: "user" | "assistant"; content: string }>; + locale?: string; + }; + + if (!messages || !Array.isArray(messages)) { + throw createError({ statusCode: 400, message: "messages fehlt" }); + } + + // Session-ID generieren + const sessionId = `sos_${user.id}_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`; + + // In globalem Store speichern (siehe server/utils/sosSessions.ts) + const { setSosSession } = await import("../../utils/sosSessions"); + setSosSession(sessionId, { + userId: user.id, + messages, + locale: locale ?? "de", + createdAt: Date.now(), + }); + + return { sessionId }; +}); diff --git a/backend/server/api/coach/sos-stream.get.ts b/backend/server/api/coach/sos-stream.get.ts new file mode 100644 index 0000000..b370a53 --- /dev/null +++ b/backend/server/api/coach/sos-stream.get.ts @@ -0,0 +1,302 @@ +/** + * GET /api/coach/sos-stream?session=xyz — Streaming SOS Coach (Claude Sonnet 4.5) + * + * Streamt Sonnets Antwort als SSE (Server-Sent Events). + * Frontend nutzt react-native-sse (EventSource) für progressives Streaming. + * + * Format (SSE-Standard): + * event: message + * data: + * + * event: chips + * data: [{"label":"...","action":"..."}] + * + * Flow: + * 1. Client POSTet zu /api/coach/sos-session → { sessionId } + * 2. Client öffnet GET /api/coach/sos-stream?session=xyz via EventSource + * 3. Backend lädt Session-Daten (messages/locale) aus In-Memory Store + * 4. Streamt Antwort als SSE-Events + * + * Fallback: bei Sonnet-Fehler wirft 503; Frontend kann auf /coach/message zurückfallen. + */ +import { COACH_SYSTEM_PROMPT } from "./message.post"; +import { getMemoriesForUser, markReferenced } from "../../db/lyraMemory"; +import { extractAndStoreMemories } from "../../utils/lyraMemoryExtract"; + +const SOS_INSTRUCTION = `\n\nDU BEFINDEST DICH IN EINEM AKUTEN SOS-MOMENT. WICHTIGE REGELN: +- Antworte als REINER TEXT, KEINE JSON-Wrapper, KEIN Markdown, KEINE Aufzählungen. +- Sei warm, präsent, menschlich — wie eine echte Freundin am Telefon. +- KURZ: 1-2 Sätze, max 3 nur in seltenen Ausnahmen. Ruhiger Rhythmus mit kurzen Pausen. +- Validiere zuerst das Gefühl, dann sanfte Frage ODER Vorschlag. + +ABSOLUT KRITISCH — NIEMALS die Chip-Optionen im Prosa-Text auflisten oder paraphrasieren. +Der Prosa-Text wird dem User VORGELESEN (TTS) — Chip-Aufzählung klingt unnatürlich +("warum sprichst du eine Liste?"). Die Chips erscheinen visuell als Buttons. + + ✗ FALSCH: "Magst du atmen oder lieber spielen?" (= Aufzählung) + ✗ FALSCH: "Du kannst eine Atemübung oder ein Spiel machen." + ✗ FALSCH: "Hier sind ein paar Optionen: Atmen, Spielen oder Reden." + ✗ FALSCH: "Magst du mit mir reden, eine Atemübung machen oder ein Spiel?" + + ✓ RICHTIG: "Magst du was probieren?" + ✓ RICHTIG: "Was hilft dir gerade?" + ✓ RICHTIG: "Hier hast du Möglichkeiten." + ✓ RICHTIG: "Was passt für dich grad?" + ✓ RICHTIG: "Ich bin da. Was brauchst du jetzt?" + +- Am ENDE der Antwort genau EINE neue Zeile mit Chips im Format: + [[CHIPS]]:[{"label":"…","action":"…"},…] +- Erlaubte Chip-Actions: breathing, game_picker, send_text:, overcome, share_success, rate_session, close, show_stats, need_help, feel: +- KEIN Text nach der CHIPS-Zeile`; + +export default defineEventHandler(async (event) => { + const user = await requireUser(event); + + // Session-ID aus Query-Parameter holen + const query = getQuery(event); + const sessionId = query.session as string | undefined; + + if (!sessionId) { + throw createError({ + statusCode: 400, + message: "session query param fehlt", + }); + } + + // Session-Daten laden (messages + locale) + const { getSosSession, deleteSosSession } = await import( + "../../utils/sosSessions" + ); + const sessionData = getSosSession(sessionId); + + if (!sessionData) { + throw createError({ + statusCode: 404, + message: "Session nicht gefunden oder abgelaufen (TTL 5min)", + }); + } + + // Security: Session gehört diesem User + if (sessionData.userId !== user.id) { + throw createError({ statusCode: 403, message: "Nicht deine Session" }); + } + + const { messages, locale } = sessionData; + + // Session löschen (One-Time-Use) + deleteSosSession(sessionId); + + const config = useRuntimeConfig(); + const key = config.openrouterApiKey as string | undefined; + if (!key) { + throw createError({ statusCode: 503, message: "OpenRouter Key fehlt" }); + } + + // System-Prompt: Coach-Basis + SOS-Streaming-Regeln + const LANG: Record = { + de: "Antworte IMMER auf Deutsch.", + en: "Always respond in English.", + tr: "Her zaman Türkçe yanıt ver.", + ar: "رد دائماً باللغة العربية.", + }; + const lang = LANG[locale ?? "de"] ?? LANG.de; + + // Memory-Injection: Lyra-Erinnerungen aus früheren Sessions laden + let memoryBlock = ""; + let loadedMemoryIds: string[] = []; + try { + const memories = await getMemoriesForUser(user.id); + if (memories.length > 0) { + loadedMemoryIds = memories.map((m) => m.id); + const TYPE_LABELS: Record = { + trigger: "Trigger", + habit: "Gewohnheit", + strength: "Stärke", + relationship: "Wichtige Person", + milestone: "Meilenstein", + pain_point: "Sensibles Thema", + goal: "Ziel", + preference: "Präferenz", + }; + const lines = memories + .map((m) => `- ${TYPE_LABELS[m.type] ?? m.type}: ${m.content}`) + .join("\n"); + memoryBlock = `[WAS DU ÜBER DIESEN USER WEISST — aus früheren Gesprächen]\n${lines}\nNutze diese Infos um GENAU diesen Menschen anzusprechen — wie ein echter Begleiter, nicht eine generische KI. Sprich Personen mit Namen an. Erinnere an Stärken die dir bekannt sind.\n\n`; + console.log( + `[lyra-memory] injected ${memories.length} memories for ${user.id}`, + ); + } + } catch (e) { + // Nicht kritisch — Memory-Fehler dürfen SOS nicht blockieren + console.error("[lyra-memory] load error (non-fatal):", e); + } + + const systemPrompt = `${memoryBlock}${lang}\n\n${COACH_SYSTEM_PROMPT.replace("{{PLAN_DETAILS}}", "")}${SOS_INSTRUCTION}`; + + // Erste Nachricht muss user sein + const firstUserIdx = messages.findIndex((m) => m.role === "user"); + const conversation = + firstUserIdx > 0 ? messages.slice(firstUserIdx) : messages; + const trimmed = conversation.slice(-8); + + const upstream = await fetch( + "https://openrouter.ai/api/v1/chat/completions", + { + method: "POST", + headers: { + Authorization: `Bearer ${key}`, + "Content-Type": "application/json", + "HTTP-Referer": "https://rebreak.org", + "X-Title": "ReBreak SOS", + }, + body: JSON.stringify({ + model: "anthropic/claude-sonnet-4.5", + max_tokens: 400, + stream: true, + messages: [{ role: "system", content: systemPrompt }, ...trimmed], + }), + }, + ); + + if (!upstream.ok || !upstream.body) { + const errText = await upstream.text().catch(() => ""); + console.error( + "[coach/sos-stream] upstream error:", + upstream.status, + errText.slice(0, 300), + ); + throw createError({ + statusCode: 502, + message: "SOS-Stream nicht verfügbar", + }); + } + + // Direkt zu Node res schreiben — sendStream(ReadableStream) pumpt pull() in Nitro nicht zuverlässig + const res = event.node.res; + res.statusCode = 200; + res.setHeader("Content-Type", "text/event-stream; charset=utf-8"); + res.setHeader("Cache-Control", "no-store"); + res.setHeader("X-Accel-Buffering", "no"); + res.setHeader("Connection", "keep-alive"); + res.flushHeaders?.(); + + const write = (chunk: string) => { + try { + res.write(chunk); + } catch (e) { + console.error("[coach/sos-stream] write error:", e); + } + }; + + console.log( + `[coach/sos-stream] stream started for ${user.id}, session ${sessionId}`, + ); + write(": connected\n\n"); + + const reader = upstream.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + let fullText = ""; + let chunkCount = 0; + + // Client disconnect detection + let aborted = false; + res.on("close", () => { + aborted = true; + reader.cancel().catch(() => {}); + }); + + let chipsMarkerSeen = false; + try { + while (!aborted) { + const { value, done } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() ?? ""; + + for (const line of lines) { + const trimLine = line.trim(); + if (!trimLine || !trimLine.startsWith("data:")) continue; + const payload = trimLine.slice(5).trim(); + if (payload === "[DONE]") continue; + try { + const json = JSON.parse(payload) as { + choices?: { delta?: { content?: string } }[]; + }; + const delta = json.choices?.[0]?.delta?.content; + if (delta) { + fullText += delta; + chunkCount++; + + // ─── CHIPS-Marker nicht streamen ─── + if (chipsMarkerSeen) continue; // alles nach Marker = Chips-JSON + + // Prüfe ob fullText jetzt den Marker enthält + const markerStart = fullText.indexOf("[["); + if (markerStart >= 0) { + // Marker (oder Anfang davon) erkannt — nur sicheren Teil bis "[[" senden + const safeText = fullText.slice(0, markerStart); + const alreadySent = fullText.length - delta.length; + if (safeText.length > alreadySent) { + const toSend = safeText.slice(alreadySent); + // JSON-encode → Whitespace bleibt erhalten + write(`event: message\ndata: ${JSON.stringify(toSend)}\n\n`); + } + // Wenn vollständiger Marker da: ab jetzt nichts mehr streamen + if (fullText.indexOf("[[CHIPS]]:") >= 0) { + chipsMarkerSeen = true; + } + continue; + } + + // Normales Delta — JSON-encoded senden (preserved Whitespace + Newlines) + write(`event: message\ndata: ${JSON.stringify(delta)}\n\n`); + } + } catch { + // partial line, ignore + } + } + } + + // Stream zu Ende → [[CHIPS]]: aus fullText extrahieren + const markerIdx = fullText.indexOf("[[CHIPS]]:"); + let chips: unknown[] = []; + if (markerIdx >= 0) { + const chipsRaw = fullText.slice(markerIdx + "[[CHIPS]]:".length); + try { + chips = JSON.parse(chipsRaw.trim()); + } catch { + console.warn( + "[sos-stream] chips parse failed:", + chipsRaw.slice(0, 100), + ); + } + } + if (chips.length > 0) { + write(`event: chips\ndata: ${JSON.stringify(chips)}\n\n`); + } + write("event: done\ndata: {}\n\n"); + console.log( + `[coach/sos-stream] stream done, ${chunkCount} chunks, ${fullText.length} chars`, + ); + + // Memory-Extraction: fire-and-forget nach Stream-Ende + // markReferenced + async Extraction laufen parallel, blockieren nichts + if (loadedMemoryIds.length > 0) { + markReferenced(loadedMemoryIds).catch(() => {}); + } + const allMessages: Array<{ role: string; content: string }> = [ + ...messages, + { role: "assistant", content: fullText.split("[[CHIPS]]:")[0].trim() }, + ]; + extractAndStoreMemories(user.id, allMessages, sessionId, key).catch( + () => {}, + ); + } catch (err) { + console.error("[coach/sos-stream] read error:", err); + write(`event: error\ndata: {"error":"stream failed"}\n\n`); + } finally { + res.end(); + } +}); diff --git a/backend/server/api/coach/sos-stream.post.ts b/backend/server/api/coach/sos-stream.post.ts new file mode 100644 index 0000000..e8264af --- /dev/null +++ b/backend/server/api/coach/sos-stream.post.ts @@ -0,0 +1,223 @@ +/** + * GET /api/coach/sos-stream?session=xyz — Streaming SOS Coach (Claude Sonnet 4.5) + * + * Streamt Sonnets Antwort als SSE (Server-Sent Events). + * Frontend nutzt react-native-sse (EventSource) für progressives Streaming. + * + * Format (SSE-Standard): + * event: message + * data: + * + * event: chips + * data: [{"label":"...","action":"..."}] + * + * Flow: + * 1. Client POSTet zu /api/coach/sos-session → { sessionId } + * 2. Client öffnet GET /api/coach/sos-stream?session=xyz via EventSource + * 3. Backend lädt Session-Daten (messages/locale) aus In-Memory Store + * 4. Streamt Antwort als SSE-Events + * + * Fallback: bei Sonnet-Fehler wirft 503; Frontend kann auf /coach/message zurückfallen. + */ +import { COACH_SYSTEM_PROMPT } from "./message.post"; + +const SOS_INSTRUCTION = `\n\nDU BEFINDEST DICH IN EINEM AKUTEN SOS-MOMENT. WICHTIGE REGELN: +- Antworte als REINER TEXT, KEINE JSON-Wrapper, KEIN Markdown, KEINE Aufzählungen +- Sei warm, präsent, menschlich — wie eine echte Freundin am Telefon +- 2-4 Sätze, ruhiger Rhythmus mit kurzen Pausen (Sätze klar trennen mit . oder !) +- Validiere zuerst das Gefühl, dann sanfte Frage ODER Vorschlag +- Am ENDE der Antwort genau EINE neue Zeile mit Chips im Format: + [[CHIPS]]:[{"label":"…","action":"…"},…] +- Erlaubte Chip-Actions: breathing, game_picker, send_text:, overcome, share_success, rate_session, close, show_stats, need_help, feel: +- KEIN Text nach der CHIPS-Zeile`; + +export default defineEventHandler(async (event) => { + const user = await requireUser(event); + + // Session-ID aus Query-Parameter holen + const query = getQuery(event); + const sessionId = query.session as string | undefined; + + if (!sessionId) { + throw createError({ + statusCode: 400, + message: "session query param fehlt", + }); + } + + // Session-Daten laden (messages + locale) + const { getSosSession, deleteSosSession } = await import( + "../../utils/sosSessions" + ); + const sessionData = getSosSession(sessionId); + + if (!sessionData) { + throw createError({ + statusCode: 404, + message: "Session nicht gefunden oder abgelaufen (TTL 5min)", + }); + } + + // Security: Session gehört diesem User + if (sessionData.userId !== user.id) { + throw createError({ statusCode: 403, message: "Nicht deine Session" }); + } + + const { messages, locale } = sessionData; + + // Session löschen (One-Time-Use) + deleteSosSession(sessionId); + + const config = useRuntimeConfig(); + const key = config.openrouterApiKey as string | undefined; + if (!key) { + throw createError({ statusCode: 503, message: "OpenRouter Key fehlt" }); + } + + // System-Prompt: Coach-Basis + SOS-Streaming-Regeln + const LANG: Record = { + de: "Antworte IMMER auf Deutsch.", + en: "Always respond in English.", + tr: "Her zaman Türkçe yanıt ver.", + ar: "رد دائماً باللغة العربية.", + }; + const lang = LANG[locale ?? "de"] ?? LANG.de; + const systemPrompt = `${lang}\n\n${COACH_SYSTEM_PROMPT.replace("{{PLAN_DETAILS}}", "")}${SOS_INSTRUCTION}`; + + // Erste Nachricht muss user sein + const firstUserIdx = messages.findIndex((m) => m.role === "user"); + const conversation = + firstUserIdx > 0 ? messages.slice(firstUserIdx) : messages; + const trimmed = conversation.slice(-8); + + const upstream = await fetch( + "https://openrouter.ai/api/v1/chat/completions", + { + method: "POST", + headers: { + Authorization: `Bearer ${key}`, + "Content-Type": "application/json", + "HTTP-Referer": "https://rebreak.org", + "X-Title": "ReBreak SOS", + }, + body: JSON.stringify({ + model: "anthropic/claude-sonnet-4.5", + max_tokens: 400, + stream: true, + messages: [{ role: "system", content: systemPrompt }, ...trimmed], + }), + }, + ); + + if (!upstream.ok || !upstream.body) { + const errText = await upstream.text().catch(() => ""); + console.error( + "[coach/sos-stream] upstream error:", + upstream.status, + errText.slice(0, 300), + ); + throw createError({ + statusCode: 502, + message: "SOS-Stream nicht verfügbar", + }); + } + + setHeader(event, "Content-Type", "text/event-stream; charset=utf-8"); + setHeader(event, "Cache-Control", "no-store"); + setHeader(event, "X-Accel-Buffering", "no"); + setHeader(event, "Connection", "keep-alive"); + + // OpenRouter SSE → parse deltas → SSE-Format für react-native-sse + const reader = upstream.body.getReader(); + const decoder = new TextDecoder(); + const encoder = new TextEncoder(); + let buffer = ""; + let fullText = ""; + + const stream = new ReadableStream({ + start(controller) { + // SSE comment als keepalive (react-native-sse braucht kein Padding) + controller.enqueue(encoder.encode(": connected\n\n")); + }, + async pull(controller) { + try { + const { value, done } = await reader.read(); + if (done) { + // Stream zu Ende → [[CHIPS]]: aus fullText extrahieren + als event senden + const markerIdx = fullText.indexOf("[[CHIPS]]:"); + let message = fullText; + let chips: any[] = []; + + if (markerIdx >= 0) { + message = fullText.slice(0, markerIdx).trim(); + const chipsRaw = fullText.slice(markerIdx + "[[CHIPS]]:".length); + try { + chips = JSON.parse(chipsRaw.trim()); + } catch { + console.warn("[sos-stream] chips parse failed:", chipsRaw); + } + } + + // Chips als separates SSE-Event + if (chips.length > 0) { + controller.enqueue( + encoder.encode( + `event: chips\ndata: ${JSON.stringify(chips)}\n\n`, + ), + ); + } + + // Finales done-Event + controller.enqueue(encoder.encode("event: done\ndata: {}\n\n")); + controller.close(); + return; + } + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() ?? ""; + + for (const line of lines) { + const trimLine = line.trim(); + if (!trimLine || !trimLine.startsWith("data:")) continue; + const payload = trimLine.slice(5).trim(); + if (payload === "[DONE]") continue; + try { + const json = JSON.parse(payload) as { + choices?: { delta?: { content?: string } }[]; + }; + const delta = json.choices?.[0]?.delta?.content; + if (delta) { + fullText += delta; + // SSE-Spec: Newlines im Payload müssen als separate `data:`-Zeilen kodiert werden + const dataLines = delta + .split("\n") + .map((l: string) => `data: ${l}`) + .join("\n"); + const sseChunk = `event: message\n${dataLines}\n\n`; + controller.enqueue(encoder.encode(sseChunk)); + } + } catch { + // Ignore parse errors on partial lines + } + } + } catch (err) { + console.error("[coach/sos-stream] read error:", err); + controller.enqueue( + encoder.encode( + `event: error\ndata: ${JSON.stringify({ error: "stream failed" })}\n\n`, + ), + ); + controller.close(); + } + }, + cancel() { + reader.cancel().catch(() => {}); + }, + }); + + console.log( + `[coach/sos-stream] stream started for ${user.id}, session ${sessionId}`, + ); + return sendStream(event, stream as never); +}); diff --git a/backend/server/api/coach/speak-azure.post.ts b/backend/server/api/coach/speak-azure.post.ts new file mode 100644 index 0000000..4aa2da7 --- /dev/null +++ b/backend/server/api/coach/speak-azure.post.ts @@ -0,0 +1,53 @@ +/** + * POST /api/coach/speak-azure + * Azure Cognitive Services TTS — de-DE-KatjaNeural + * Benötigt: AZURE_TTS_KEY + AZURE_TTS_REGION in Infisical + */ +export default defineEventHandler(async (event) => { + await requireUser(event); + + const body = await readBody(event); + const { text } = body as { text: string }; + + if (!text?.trim()) { + throw createError({ statusCode: 400, message: "text fehlt" }); + } + + const config = useRuntimeConfig(); + const key = config.azureTtsKey as string | undefined; + const region = (config.azureTtsRegion as string | undefined) || "westeurope"; + + if (!key) { + throw createError({ statusCode: 503, message: "Azure TTS Key nicht konfiguriert" }); + } + + const ssml = ` + + ${text.slice(0, 2000).replace(/[<>&'"]/g, (c) => ({ '<': '<', '>': '>', '&': '&', "'": ''', '"': '"' }[c] ?? c))} + + `; + + const response = await fetch( + `https://${region}.tts.speech.microsoft.com/cognitiveservices/v1`, + { + method: "POST", + headers: { + "Ocp-Apim-Subscription-Key": key, + "Content-Type": "application/ssml+xml", + "X-Microsoft-OutputFormat": "audio-16khz-128kbitrate-mono-mp3", + }, + body: ssml, + }, + ); + + if (!response.ok) { + const err = await response.text(); + console.error("[speak-azure] error:", response.status, err); + throw createError({ statusCode: 502, message: "Azure TTS fehlgeschlagen" }); + } + + const buffer = await response.arrayBuffer(); + const base64 = Buffer.from(buffer).toString("base64"); + + return { audio: `data:audio/mp3;base64,${base64}` }; +}); diff --git a/backend/server/api/coach/speak-deepgram.post.ts b/backend/server/api/coach/speak-deepgram.post.ts new file mode 100644 index 0000000..9c44e7e --- /dev/null +++ b/backend/server/api/coach/speak-deepgram.post.ts @@ -0,0 +1,44 @@ +/** + * POST /api/coach/speak-deepgram + * Deepgram Aura TTS — returns base64 MP3 + */ +export default defineEventHandler(async (event) => { + await requireUser(event); + + const body = await readBody(event); + const { text } = body as { text: string }; + + if (!text?.trim()) { + throw createError({ statusCode: 400, message: "text fehlt" }); + } + + const config = useRuntimeConfig(); + const key = config.deepgramApiKey as string | undefined; + + if (!key) { + throw createError({ statusCode: 503, message: "Deepgram API Key nicht konfiguriert" }); + } + + const response = await fetch( + "https://api.deepgram.com/v1/speak?model=aura-asteria-en&encoding=mp3", + { + method: "POST", + headers: { + Authorization: `Token ${key}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ text: text.slice(0, 2000) }), + }, + ); + + if (!response.ok) { + const err = await response.text(); + console.error("[speak-deepgram] error:", response.status, err); + throw createError({ statusCode: 502, message: "Deepgram TTS fehlgeschlagen" }); + } + + const buffer = await response.arrayBuffer(); + const base64 = Buffer.from(buffer).toString("base64"); + + return { audio: `data:audio/mp3;base64,${base64}` }; +}); diff --git a/backend/server/api/coach/speak-gemini.post.ts b/backend/server/api/coach/speak-gemini.post.ts new file mode 100644 index 0000000..de7e945 --- /dev/null +++ b/backend/server/api/coach/speak-gemini.post.ts @@ -0,0 +1,107 @@ +/** + * POST /api/coach/speak-gemini + * Gemini 2.5 Flash Preview TTS — voice: Kore (warm female). + * + * Returns audio/wav. Gemini liefert 24kHz 16-bit mono PCM via + * inlineData.data (Base64) — wir prependen den 44-byte WAV-Header. + * + * Kein `instructions`-Feld → keine wahrgenommene Stimm-Drift zwischen Calls. + * Voice ist deterministisch konstant (im Gegensatz zu gpt-4o-mini-tts). + */ +const SAMPLE_RATE = 24000; +const NUM_CHANNELS = 1; +const BITS_PER_SAMPLE = 16; + +function pcmToWav(pcm: Buffer): Buffer { + const byteRate = (SAMPLE_RATE * NUM_CHANNELS * BITS_PER_SAMPLE) / 8; + const blockAlign = (NUM_CHANNELS * BITS_PER_SAMPLE) / 8; + const dataSize = pcm.length; + const out = Buffer.alloc(44 + dataSize); + + out.write("RIFF", 0); + out.writeUInt32LE(36 + dataSize, 4); + out.write("WAVE", 8); + out.write("fmt ", 12); + out.writeUInt32LE(16, 16); + out.writeUInt16LE(1, 20); + out.writeUInt16LE(NUM_CHANNELS, 22); + out.writeUInt32LE(SAMPLE_RATE, 24); + out.writeUInt32LE(byteRate, 28); + out.writeUInt16LE(blockAlign, 32); + out.writeUInt16LE(BITS_PER_SAMPLE, 34); + out.write("data", 36); + out.writeUInt32LE(dataSize, 40); + pcm.copy(out, 44); + return out; +} + +export default defineEventHandler(async (event) => { + await requireUser(event); + + const body = await readBody(event); + const { text } = body as { text: string }; + + if (!text?.trim()) { + throw createError({ statusCode: 400, message: "text fehlt" }); + } + + const config = useRuntimeConfig(); + const key = config.googleAiApiKey as string | undefined; + + if (!key) { + throw createError({ + statusCode: 503, + message: "GOOGLE_AI_API_KEY nicht konfiguriert", + }); + } + + const upstream = await fetch( + "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-preview-tts:generateContent", + { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-goog-api-key": key, + }, + body: JSON.stringify({ + contents: [{ parts: [{ text: text.slice(0, 4096) }] }], + generationConfig: { + responseModalities: ["AUDIO"], + speechConfig: { + voiceConfig: { + prebuiltVoiceConfig: { voiceName: "Kore" }, + }, + }, + }, + }), + }, + ); + + if (!upstream.ok) { + const err = await upstream.text().catch(() => ""); + console.error("[speak-gemini] error:", upstream.status, err); + throw createError({ + statusCode: 502, + message: "Gemini TTS fehlgeschlagen", + }); + } + + const json = (await upstream.json()) as { + candidates?: Array<{ + content?: { parts?: Array<{ inlineData?: { data?: string } }> }; + }>; + }; + + const base64Pcm = json.candidates?.[0]?.content?.parts?.[0]?.inlineData?.data; + if (!base64Pcm) { + console.error("[speak-gemini] no audio in response:", JSON.stringify(json).slice(0, 500)); + throw createError({ statusCode: 502, message: "Gemini TTS: kein Audio zurückgegeben" }); + } + + const pcm = Buffer.from(base64Pcm, "base64"); + const wav = pcmToWav(pcm); + + setHeader(event, "Content-Type", "audio/wav"); + setHeader(event, "Cache-Control", "no-store"); + return wav; +}); diff --git a/backend/server/api/coach/speak-google.post.ts b/backend/server/api/coach/speak-google.post.ts new file mode 100644 index 0000000..185b526 --- /dev/null +++ b/backend/server/api/coach/speak-google.post.ts @@ -0,0 +1,69 @@ +/** + * POST /api/coach/speak-google + * Test endpoint for Google Cloud TTS + */ +export default defineEventHandler(async (event) => { + await requireUser(event); + + const body = await readBody(event); + const { text } = body as { text: string }; + + if (!text?.trim()) { + throw createError({ statusCode: 400, message: "text fehlt" }); + } + + const trimmed = text.slice(0, 4096); + const config = useRuntimeConfig(); + + if (!config.googleApiKey) { + throw createError({ statusCode: 503, message: "Google API Key nicht konfiguriert" }); + } + + try { + const response = await fetch( + `https://texttospeech.googleapis.com/v1/text:synthesize?key=${config.googleApiKey}`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + input: { text: trimmed }, + voice: { + languageCode: "de-DE", + name: "de-DE-Neural2-F", + ssmlGender: "FEMALE", + }, + audioConfig: { + audioEncoding: "MP3", + speakingRate: 1.0, + pitch: 0, + }, + }), + } + ); + + const result = await response.json(); + + if (!response.ok) { + console.error("[speak-google] Google TTS error:", result); + throw createError({ + statusCode: response.status, + message: result.error?.message || "Google TTS fehlgeschlagen", + }); + } + + if (!result.audioContent) { + console.error("[speak-google] No audioContent:", result); + throw createError({ statusCode: 502, message: "Google TTS: kein Audio zurückgegeben" }); + } + + return { audio: `data:audio/mp3;base64,${result.audioContent}` }; + } catch (err: any) { + console.error("[speak-google] Error:", err); + throw createError({ + statusCode: 502, + message: err?.message || "Google TTS fehlgeschlagen", + }); + } +}); diff --git a/backend/server/api/coach/speak-openai.post.ts b/backend/server/api/coach/speak-openai.post.ts new file mode 100644 index 0000000..c69ba92 --- /dev/null +++ b/backend/server/api/coach/speak-openai.post.ts @@ -0,0 +1,79 @@ +/** + * POST /api/coach/speak-openai — v5 + * OpenAI TTS — gpt-4o-mini-tts (Mar 2025), Stimmen: nova (chat) / shimmer (sos). + * + * Modes: + * - "chat" → nova, neutral + * - "sos" → shimmer, single warm-empathic instruction set + * - "sos-continuation" → shimmer, **identical** instructions zu "sos" + * + * Warum identisch: gpt-4o-mini-tts interpretiert `instructions` so kreativ, + * dass unterschiedliche Strings im selben SOS-Flow als "Stimme wechselt" + * wahrgenommen werden. Single-instruction-Mode eliminiert den Voice-Boundary. + */ +export default defineEventHandler(async (event) => { + await requireUser(event); + + const body = await readBody(event); + const { text, mode } = body as { + text: string; + mode?: "sos" | "sos-continuation" | "chat"; + }; + + if (!text?.trim()) { + throw createError({ statusCode: 400, message: "text fehlt" }); + } + + const isSos = mode === "sos" || mode === "sos-continuation"; + + const config = useRuntimeConfig(); + const key = config.openaiApiKey as string | undefined; + + if (!key) { + throw createError({ + statusCode: 503, + message: "OpenAI API Key nicht konfiguriert", + }); + } + + // Identische instructions für sos + sos-continuation → keine wahrgenommene + // Stimm-Drift zwischen aufeinanderfolgenden TTS-Calls in derselben SOS-Session. + const instructions = isSos + ? "Warm, gentle, empathic — like a calm friend on the phone in a difficult moment. " + + "Speak slowly with natural pauses between sentences. " + + "Soft delivery, lower energy than chat-mode. " + + "German native pronunciation. No fake-cheerful intonation." + : undefined; + + const upstream = await fetch("https://api.openai.com/v1/audio/speech", { + method: "POST", + headers: { + Authorization: `Bearer ${key}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model: "gpt-4o-mini-tts", + input: text.slice(0, 4096), + voice: isSos ? "shimmer" : "nova", + response_format: "mp3", + speed: 1.08, + ...(instructions ? { instructions } : {}), + }), + }); + + if (!upstream.ok || !upstream.body) { + const err = await upstream.text().catch(() => ""); + console.error("[speak-openai] error:", upstream.status, err); + throw createError({ + statusCode: 502, + message: "OpenAI TTS fehlgeschlagen", + }); + } + + setHeader(event, "Content-Type", "audio/mpeg"); + setHeader(event, "Cache-Control", "no-store"); + + const { Readable } = await import("node:stream"); + const nodeStream = Readable.fromWeb(upstream.body as never); + return sendStream(event, nodeStream); +}); diff --git a/backend/server/api/coach/speak.post.ts b/backend/server/api/coach/speak.post.ts new file mode 100644 index 0000000..7b7891d --- /dev/null +++ b/backend/server/api/coach/speak.post.ts @@ -0,0 +1,70 @@ +/** + * POST /api/coach/speak + * Empfängt text → FreeTTS (Microsoft Neural Voices, kostenlos) → gibt base64 Audio zurück + */ +export default defineEventHandler(async (event) => { + await requireUser(event); + + const body = await readBody(event); + const { text } = body as { text: string }; + + if (!text?.trim()) { + throw createError({ statusCode: 400, message: "text fehlt" }); + } + + // Max 4096 Zeichen + const trimmed = text.slice(0, 4096); + + try { + // FreeTTS API - free, no key required + const response = await fetch("https://freetts.org/api/tts", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + text: trimmed, + voice: "de-DE-KatjaNeural", + speed: 1.0, + output: "mp3", + }), + }); + + const responseText = await response.text(); + console.log("[speak] FreeTTS response status:", response.status); + console.log("[speak] FreeTTS response body:", responseText); + + if (!response.ok) { + console.error("[speak] FreeTTS error:", response.status, responseText); + throw createError({ statusCode: 502, message: `TTS fehlgeschlagen: ${responseText}` }); + } + + // FreeTTS returns a file_id to download + const result = JSON.parse(responseText); + + if (!result.file_id) { + console.error("[speak] FreeTTS no file_id:", result); + throw createError({ statusCode: 502, message: "TTS fehlgeschlagen: no file_id" }); + } + + // Download the audio file from correct endpoint + console.log("[speak] Downloading audio file:", result.file_id); + const audioResponse = await fetch(`https://freetts.org/api/audio/${result.file_id}`); + + if (!audioResponse.ok) { + console.error("[speak] Audio download failed:", audioResponse.status); + throw createError({ statusCode: 502, message: "TTS fehlgeschlagen: download failed" }); + } + + const audioBuffer = await audioResponse.arrayBuffer(); + const base64 = Buffer.from(audioBuffer).toString("base64"); + + return { audio: `data:audio/mp3;base64,${base64}` }; + } catch (err: any) { + console.error("[speak] TTS error:", err?.message || err); + throw createError({ + statusCode: 502, + message: err?.message || "TTS fehlgeschlagen", + }); + } +}); diff --git a/backend/server/api/coach/transcribe.post.ts b/backend/server/api/coach/transcribe.post.ts new file mode 100644 index 0000000..d3ef7eb --- /dev/null +++ b/backend/server/api/coach/transcribe.post.ts @@ -0,0 +1,127 @@ +/** + * POST /api/coach/transcribe + * Empfängt Audio (base64 webm/mp4/aac) → Deepgram → gibt Text zurück + * iOS sendet rohes AAC (ADTS) → wird via ffmpeg in M4A konvertiert + */ +import { execSync } from "node:child_process"; +import { writeFileSync, readFileSync, unlinkSync, existsSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { randomUUID } from "node:crypto"; + +export default defineEventHandler(async (event) => { + await requireUser(event); + + const body = await readBody(event); + const { audio, mimeType, language } = body as { + audio: string; + mimeType?: string; + language?: string; + }; + + if (!audio) { + throw createError({ statusCode: 400, message: "audio fehlt" }); + } + + const config = useRuntimeConfig(); + if (!config.deepgramApiKey) { + throw createError({ + statusCode: 503, + message: "Deepgram nicht konfiguriert", + }); + } + + // Base64 → Buffer + const base64Data = audio.includes(",") ? audio.split(",")[1] : audio; + let buffer = Buffer.from(base64Data, "base64"); + + // Max 25MB (API-Limit) + if (buffer.length > 25 * 1024 * 1024) { + throw createError({ + statusCode: 400, + message: "Audio zu groß (max 25 MB)", + }); + } + + // iOS capacitor-voice-recorder liefert rohes AAC (ADTS) — Deepgram akzeptiert das. + // Aber konvertiere trotzdem zu M4A für bessere Kompatibilität. + const isRawAac = mimeType?.includes("aac"); + let ext = "webm"; + let blobType = "audio/webm"; + + if (isRawAac) { + const id = randomUUID(); + const inPath = join(tmpdir(), `${id}.aac`); + const outPath = join(tmpdir(), `${id}.m4a`); + try { + writeFileSync(inPath, buffer); + execSync(`ffmpeg -i ${inPath} -c:a copy ${outPath} -y 2>/dev/null`); + buffer = readFileSync(outPath); + ext = "m4a"; + blobType = "audio/mp4"; + } catch (e) { + console.error("[transcribe] ffmpeg convert failed:", e); + ext = "m4a"; + blobType = "audio/mp4"; + } finally { + if (existsSync(inPath)) unlinkSync(inPath); + if (existsSync(outPath)) unlinkSync(outPath); + } + } else if (mimeType?.includes("mp4") || mimeType?.includes("m4a")) { + ext = "m4a"; + blobType = "audio/mp4"; + } + + console.log( + "[transcribe] mimeType:", + mimeType, + "→ ext:", + ext, + "converted:", + isRawAac, + "bytes:", + buffer.length, + ); + + // Deepgram language mapping (de/en/tr/ar direkt unterstützt) + const deepgramLang = + language && + ["de", "en", "tr", "ar", "fr", "es", "pt", "it"].includes(language) + ? language + : "de"; + + try { + const response = await fetch( + `https://api.deepgram.com/v1/listen?language=${deepgramLang}&model=nova-2`, + { + method: "POST", + headers: { + Authorization: `Token ${config.deepgramApiKey}`, + "Content-Type": blobType, + }, + body: buffer, + }, + ); + + const result = await response.json(); + + if (!response.ok) { + console.error("[transcribe] Deepgram error:", JSON.stringify(result)); + throw createError({ + statusCode: response.status, + message: JSON.stringify(result), + }); + } + + const transcript = + result.results?.channels?.[0]?.alternatives?.[0]?.transcript || ""; + return { text: transcript }; + } catch (err: any) { + if (err.statusCode) throw err; + console.error("[transcribe] Unexpected error:", err); + throw createError({ + statusCode: 500, + message: err?.message || "Transcribe fehlgeschlagen", + }); + } +}); diff --git a/backend/server/api/community/[postId]/comments.get.ts b/backend/server/api/community/[postId]/comments.get.ts new file mode 100644 index 0000000..42b1d35 --- /dev/null +++ b/backend/server/api/community/[postId]/comments.get.ts @@ -0,0 +1,35 @@ +import { getCommentsByPost } from "../../../db/community"; + +/** GET /api/community/[postId]/comments */ +export default defineEventHandler(async (event) => { + const postId = getRouterParam(event, "postId"); + if (!postId) throw createError({ statusCode: 400, message: "postId fehlt" }); + + let currentUserId: string | null = null; + try { + const u = await requireUser(event); + currentUserId = u.id; + } catch {} + + const { comments, userLikes } = await getCommentsByPost( + postId, + currentUserId, + ); + + return comments.map((c) => { + const a = c.author; + const username = a?.username ?? "Anonym"; + return { + id: c.id, + content: c.content, + createdAt: c.createdAt, + likesCount: c.likesCount ?? 0, + userLike: userLikes.has(c.id), + parentCommentId: c.parentReplyId ?? null, + authorId: c.userId ?? null, + authorNickname: a?.nickname ?? username, + authorAvatar: a?.avatar ?? null, + authorTier: "beginner", + }; + }); +}); diff --git a/backend/server/api/community/[postId]/index.get.ts b/backend/server/api/community/[postId]/index.get.ts new file mode 100644 index 0000000..8483613 --- /dev/null +++ b/backend/server/api/community/[postId]/index.get.ts @@ -0,0 +1,102 @@ +import { getPostById, getPostLike } from "../../../db/community"; +import { getFollowRelation } from "../../../db/social"; +import { usePrisma } from "../../../utils/prisma"; + +/** GET /api/community/[postId] */ +export default defineEventHandler(async (event) => { + const postId = getRouterParam(event, "postId"); + if (!postId) throw createError({ statusCode: 400, message: "postId fehlt" }); + + const config = useRuntimeConfig(); + const lyraBotUserId = config.lyraBotUserId || null; + const rebreakBotUserId = config.rebreakBotUserId || null; + + // Auth-User optional + let currentUserId: string | null = null; + try { + const u = await requireUser(event); + currentUserId = u.id; + } catch {} + + const db = usePrisma(); + const [data, likeRow] = await Promise.all([ + getPostById(postId), + currentUserId ? getPostLike(currentUserId, postId) : Promise.resolve(null), + ]); + + if (!data || data.isModerated) { + throw createError({ statusCode: 404, message: "Post nicht gefunden" }); + } + + const [followRow, scoreRow] = await Promise.all([ + currentUserId && data.userId && currentUserId !== data.userId + ? getFollowRelation(currentUserId, data.userId) + : Promise.resolve(null), + data.userId + ? db.userScore.findUnique({ + where: { userId: data.userId }, + select: { tier: true }, + }) + : Promise.resolve(null), + ]); + + const challengeId = (data as any).challengeId ?? null; + const challengeRow = challengeId + ? await db.gameChallenge.findUnique({ + where: { id: challengeId }, + select: { + gameType: true, + isLive: true, + opponentName: true, + status: true, + }, + }) + : null; + + const a = data.author; + const isGameShare = data.category === "game_share"; + return { + id: data.id, + category: data.category, + content: isGameShare + ? data.content.split("\n").slice(1).join("\n").trim() + : data.content, + imageUrl: (data as any).imageUrl ?? null, + challengeId, + challengeStatus: (challengeRow as any)?.status ?? null, + gameName: challengeId + ? (challengeRow as any)?.gameType ?? null + : isGameShare + ? data.content.split("\n")[0] ?? null + : null, + opponentName: (challengeRow as any)?.opponentName ?? null, + isLive: (challengeRow as any)?.isLive ?? false, + likesCount: data.likesCount ?? 0, + dislikesCount: data.dislikesCount ?? 0, + commentsCount: data.commentsCount ?? 0, + repostsCount: (data as any).repostsCount ?? 0, + isAnonymous: data.isAnonymous, + createdAt: data.createdAt, + userLike: (likeRow?.type as "like" | "dislike") ?? null, + repostOfId: null, + repostOf: null, + author: { + id: data.userId ?? null, + username: a?.username ?? "Anonym", + nickname: a?.nickname ?? a?.username ?? "Anonym", + avatar: a?.avatar ?? null, + plan: (a as any)?.plan ?? "free", + tier: scoreRow?.tier ?? "beginner", + isFollowing: !!followRow, + }, + isBot: + !!(lyraBotUserId && data.userId === lyraBotUserId) || + !!(rebreakBotUserId && data.userId === rebreakBotUserId), + botType: + lyraBotUserId && data.userId === lyraBotUserId + ? "lyra" + : rebreakBotUserId && data.userId === rebreakBotUserId + ? "rebreak" + : undefined, + }; +}); diff --git a/backend/server/api/community/comment-like.post.ts b/backend/server/api/community/comment-like.post.ts new file mode 100644 index 0000000..718051d --- /dev/null +++ b/backend/server/api/community/comment-like.post.ts @@ -0,0 +1,37 @@ +import { + getCommentLike, + createCommentLike, + deleteCommentLike, + getCommentLikeCount, + syncCommentLikeCount, +} from "../../db/community"; + +/** + * POST /api/community/comment-like + * Body: { commentId } + * Toggled Like auf einem Kommentar. + */ +export default defineEventHandler(async (event) => { + const user = await requireUser(event); + const { commentId } = (await readBody(event)) as { commentId: string }; + + if (!commentId) { + throw createError({ statusCode: 400, message: "commentId erforderlich" }); + } + + const existing = await getCommentLike(user.id, commentId); + let userLike: boolean; + + if (existing) { + await deleteCommentLike(user.id, commentId); + userLike = false; + } else { + await createCommentLike(user.id, commentId); + userLike = true; + } + + const count = await getCommentLikeCount(commentId); + await syncCommentLikeCount(commentId, count); + + return { likesCount: count, userLike }; +}); diff --git a/backend/server/api/community/comment.post.ts b/backend/server/api/community/comment.post.ts new file mode 100644 index 0000000..e5e3008 --- /dev/null +++ b/backend/server/api/community/comment.post.ts @@ -0,0 +1,67 @@ +import { awardPoints } from "../../utils/scoring"; +import { createComment, getPostById } from "../../db/community"; +import { getProfile } from "../../db/profile"; +import { createNotification } from "../../db/notifications"; + +/** POST /api/community/comment */ +export default defineEventHandler(async (event) => { + const user = await requireUser(event); + const { postId, content, parentCommentId } = (await readBody(event)) as { + postId: string; + content: string; + parentCommentId?: string; + }; + + if (!postId || !content?.trim()) { + throw createError({ + statusCode: 400, + message: "postId und content erforderlich", + }); + } + if (content.trim().length > 1000) { + throw createError({ + statusCode: 400, + message: "Kommentar zu lang (max. 1000 Zeichen)", + }); + } + + const [data, pr, post] = await Promise.all([ + createComment(user.id, postId, content.trim(), parentCommentId ?? null), + getProfile(user.id), + getPostById(postId), + ]); + + await awardPoints(user.id, "chat_message").catch(() => {}); + + // Notification an Post-Autor (nicht an sich selbst) + if (post?.userId && post.userId !== user.id) { + const meta = user.user_metadata ?? {}; + const actorName = + pr?.nickname ?? pr?.username ?? meta.full_name ?? "Jemand"; + const actorAvatar = pr?.avatar ?? undefined; + createNotification({ + recipientId: post.userId, + type: "new_comment", + actorName, + actorAvatar, + postId, + preview: content.trim().slice(0, 80), + }).catch(() => {}); + } + + const meta = user.user_metadata ?? {}; + const username = pr?.username ?? "Anonym"; + + return { + id: data.id, + content: data.content, + createdAt: data.createdAt, + likesCount: data.likesCount ?? 0, + userLike: false, + parentCommentId: data.parentReplyId ?? null, + authorId: pr?.id ?? null, + authorNickname: meta.nickname ?? meta.full_name ?? username, + authorAvatar: meta.avatar ?? meta.avatar_url ?? meta.picture ?? null, + authorTier: "beginner", + }; +}); diff --git a/backend/server/api/community/domain-stats.get.ts b/backend/server/api/community/domain-stats.get.ts new file mode 100644 index 0000000..64ac94c --- /dev/null +++ b/backend/server/api/community/domain-stats.get.ts @@ -0,0 +1,25 @@ +import { getActiveBlocklistCount } from "../../db/domains"; + +const FALLBACK_TOTAL = 208704; + +/** GET /api/community/domain-stats – öffentlich, kein Auth */ +export default defineEventHandler(async () => { + const db = usePrisma(); + + const [rawTotal, monthlyAdded] = await Promise.all([ + getActiveBlocklistCount(), + (async () => { + const startOfMonth = new Date(); + startOfMonth.setDate(1); + startOfMonth.setHours(0, 0, 0, 0); + return db.domainSubmission.count({ + where: { status: "approved", reviewedAt: { gte: startOfMonth } }, + }); + })(), + ]); + + return { + total: rawTotal > 1000 ? rawTotal : FALLBACK_TOTAL, + monthlyAdded, + }; +}); diff --git a/backend/server/api/community/like.post.ts b/backend/server/api/community/like.post.ts new file mode 100644 index 0000000..6a0bb0e --- /dev/null +++ b/backend/server/api/community/like.post.ts @@ -0,0 +1,73 @@ +import { awardPoints } from "../../utils/scoring"; +import { createNotification } from "../../db/notifications"; +import { getProfile } from "../../db/profile"; +import { + getPostLike, + setPostLike, + deletePostLike, + countPostLikes, + syncPostLikeCounts, + getPostById, +} from "../../db/community"; + +export default defineEventHandler(async (event) => { + const user = await requireUser(event); + const { postId, type } = (await readBody(event)) as { + postId: string; + type: "like" | "dislike"; + }; + + if (!postId || !["like", "dislike"].includes(type)) { + throw createError({ + statusCode: 400, + message: "postId und type (like|dislike) erforderlich", + }); + } + + const [existing, currentPost] = await Promise.all([ + getPostLike(user.id, postId), + getPostById(postId), + ]); + + let newUserLike: "like" | "dislike" | null = null; + + if (existing) { + if (existing.type === type) { + // Toggle OFF + await deletePostLike(user.id, postId); + newUserLike = null; + } else { + // Typ wechseln + await setPostLike(user.id, postId, type); + newUserLike = type; + } + } else { + await setPostLike(user.id, postId, type); + newUserLike = type; + + if ( + type === "like" && + currentPost?.userId && + currentPost.userId !== user.id + ) { + const pr = await getProfile(user.id).catch(() => null); + const actorName = pr?.nickname ?? pr?.username ?? "Jemand"; + const actorAvatar = pr?.avatar ?? undefined; + await Promise.all([ + awardPoints(currentPost.userId, "upvote_received").catch(() => {}), + createNotification({ + recipientId: currentPost.userId, + type: "new_like", + actorName, + actorAvatar, + postId, + }).catch(() => {}), + ]); + } + } + + const { likes, dislikes } = await countPostLikes(postId); + await syncPostLikeCounts(postId, likes, dislikes); + + return { likesCount: likes, dislikesCount: dislikes, userLike: newUserLike }; +}); diff --git a/backend/server/api/community/post.post.ts b/backend/server/api/community/post.post.ts new file mode 100644 index 0000000..e9719a4 --- /dev/null +++ b/backend/server/api/community/post.post.ts @@ -0,0 +1,81 @@ +import { awardPoints } from "../../utils/scoring"; +import { createPost } from "../../db/community"; + +const MODERATION_PROMPT = `Du moderierst Beiträge in einer anonymen Selbsthilfe-Community für Menschen mit Glücksspielsucht. +Entferne Beiträge die: +- Casino-Werbung oder Links zu Glücksspielseiten enthalten +- Tipps zum Umgehen von Blockern geben +- Andere Nutzer manipulieren oder angreifen +- Offensichtlichen Spam darstellen +Erlaube: persönliche Erfahrungen, Hilferufe, Erfolgsgeschichten, Fragen. +Antworte NUR mit JSON: {"approved": boolean, "reason": string}`; + +export default defineEventHandler(async (event) => { + const user = await requireUser(event); + + const body = await readBody(event); + const { category, content, imageUrl } = body as { + category: string; + content: string; + imageUrl?: string; + }; + + if (!content?.trim() || !category) { + throw createError({ + statusCode: 400, + message: "category und content erforderlich", + }); + } + + const config = useRuntimeConfig(); + + // Moderate via OpenRouter (optional – skip if no API key) + if (config.openrouterApiKey) { + try { + const modResponse = await $fetch<{ + choices: { message: { content: string } }[]; + }>("https://openrouter.ai/api/v1/chat/completions", { + method: "POST", + headers: { + Authorization: `Bearer ${config.openrouterApiKey}`, + "Content-Type": "application/json", + "HTTP-Referer": "https://rebreak.org", + "X-Title": "ReBreak Moderation", + }, + body: { + model: "meta-llama/llama-3.2-3b-instruct:free", + max_tokens: 100, + messages: [ + { role: "system", content: MODERATION_PROMPT }, + { + role: "user", + content: `Kategorie: ${category}\nInhalt: ${content}`, + }, + ], + }, + }); + + const raw = modResponse.choices?.[0]?.message?.content ?? ""; + const jsonMatch = raw.match(/\{[\s\S]*\}/); + const modResult: { approved: boolean; reason: string } = jsonMatch + ? JSON.parse(jsonMatch[0]) + : { approved: true, reason: "" }; + + if (!modResult.approved) { + throw createError({ + statusCode: 422, + message: `Beitrag abgelehnt: ${modResult.reason}`, + }); + } + } catch (err: any) { + if (err.statusCode === 422) throw err; + } + } + + const data = await createPost(user.id, category, content.trim(), imageUrl); + + // Punkte vergeben + await awardPoints(user.id, "post_created", { post_id: data.id }); + + return data; +}); diff --git a/backend/server/api/community/posts.get.ts b/backend/server/api/community/posts.get.ts new file mode 100644 index 0000000..df1bf93 --- /dev/null +++ b/backend/server/api/community/posts.get.ts @@ -0,0 +1,129 @@ +import { getPosts } from "../../db/community"; +import { getFollowingSet } from "../../db/social"; + +/** GET /api/community/posts?category=all&page=1&limit=20 */ +export default defineEventHandler(async (event) => { + const config = useRuntimeConfig(); + const lyraBotUserId = config.lyraBotUserId || null; + const rebreakBotUserId = config.rebreakBotUserId || null; + const query = getQuery(event); + const category = (query.category as string) || "all"; + const page = Math.max(1, parseInt((query.page as string) || "1")); + const limit = Math.min(50, parseInt((query.limit as string) || "20")); + + // Lyra / ReBreak → nach userId filtern + let filterUserId: string | null = null; + let dbCategory = category; + if (category === "lyra") { + filterUserId = lyraBotUserId; + dbCategory = "all"; + } else if (category === "rebreak") { + filterUserId = rebreakBotUserId; + dbCategory = "all"; + } + + // Auth-User optional (Gäste können auch lesen) + let currentUserId: string | null = null; + try { + const u = await requireUser(event); + currentUserId = u.id; + } catch {} + + const { + posts, + userLikes, + challengeStatuses, + domainSubmissions, + userDomainVotes, + userScores, + submissionVoters, + } = await getPosts(dbCategory, page, limit, currentUserId, filterUserId); + + // Batch: isFollowing für alle Autoren + const authorIds = [ + ...new Set(posts.map((p) => p.userId).filter((id): id is string => !!id)), + ]; + const followingSet = + currentUserId && authorIds.length > 0 + ? await getFollowingSet(currentUserId, authorIds) + : new Set(); + + return posts.map((p) => { + const a = p.author; + return { + id: p.id, + category: p.category, + content: + p.category === "game_share" + ? p.content.split("\n").slice(1).join("\n").trim() + : p.content, + imageUrl: (p as any).imageUrl ?? null, + challengeId: (p as any).challengeId ?? null, + challengeStatus: (p as any).challengeId + ? challengeStatuses[(p as any).challengeId]?.status ?? "OPEN" + : null, + gameName: (p as any).challengeId + ? challengeStatuses[(p as any).challengeId]?.gameType ?? null + : p.category === "game_share" + ? p.content.split("\n")[0] ?? null + : null, + opponentName: (p as any).challengeId + ? challengeStatuses[(p as any).challengeId]?.opponentName ?? null + : null, + isLive: (p as any).challengeId + ? challengeStatuses[(p as any).challengeId]?.isLive ?? false + : false, + likesCount: p.likesCount ?? 0, + dislikesCount: p.dislikesCount ?? 0, + commentsCount: p.commentsCount ?? 0, + repostsCount: (p as any).repostsCount ?? 0, + isAnonymous: p.isAnonymous, + createdAt: p.createdAt, + userLike: userLikes[p.id] ?? null, + submission: domainSubmissions[p.id] + ? { + ...domainSubmissions[p.id], + yesVoters: submissionVoters[domainSubmissions[p.id].id]?.yes ?? [], + noVoters: submissionVoters[domainSubmissions[p.id].id]?.no ?? [], + } + : null, + userVote: userDomainVotes[p.id] ?? null, + repostOfId: (p as any).repostOfId ?? null, + repostOf: (p as any).repostOf + ? { + id: (p as any).repostOf.id, + content: (p as any).repostOf.content, + imageUrl: (p as any).repostOf.imageUrl ?? null, + author: { + id: (p as any).repostOf.userId ?? null, + nickname: + (p as any).repostOf.author?.nickname ?? + (p as any).repostOf.author?.username ?? + "Nutzer", + avatar: (p as any).repostOf.author?.avatar ?? null, + plan: (p as any).repostOf.author?.plan ?? "free", + tier: userScores[(p as any).repostOf.userId ?? ""] ?? "beginner", + }, + } + : null, + author: { + id: p.userId ?? null, + username: a?.username ?? "Nutzer", + nickname: a?.nickname ?? a?.username ?? "Nutzer", + avatar: a?.avatar ?? null, + plan: (a as any)?.plan ?? "free", + tier: userScores[p.userId ?? ""] ?? "beginner", + isFollowing: p.userId ? followingSet.has(p.userId) : false, + }, + isBot: + !!(lyraBotUserId && p.userId === lyraBotUserId) || + !!(rebreakBotUserId && p.userId === rebreakBotUserId), + botType: + lyraBotUserId && p.userId === lyraBotUserId + ? "lyra" + : rebreakBotUserId && p.userId === rebreakBotUserId + ? "rebreak" + : undefined, + }; + }); +}); diff --git a/backend/server/api/community/repost.post.ts b/backend/server/api/community/repost.post.ts new file mode 100644 index 0000000..5a7488b --- /dev/null +++ b/backend/server/api/community/repost.post.ts @@ -0,0 +1,83 @@ +import { usePrisma } from "../../utils/prisma"; +import { awardPoints } from "../../utils/scoring"; + +/** + * POST /api/community/repost + * Body: { postId: string } + * Creates a repost referencing the original post. + */ +export default defineEventHandler(async (event) => { + const user = await requireUser(event); + const { postId } = (await readBody(event)) as { postId: string }; + + if (!postId) { + throw createError({ statusCode: 400, message: "postId erforderlich" }); + } + + const db = usePrisma(); + + // Original-Post laden + const original = await db.communityPost.findUnique({ + where: { id: postId }, + select: { id: true, userId: true, repostOfId: true }, + }); + + if (!original) { + throw createError({ statusCode: 404, message: "Post nicht gefunden" }); + } + + // Nicht den eigenen Post resharen + if (original.userId === user.id) { + throw createError({ + statusCode: 400, + message: "Eigene Posts können nicht geteilt werden", + }); + } + + // Wenn es selbst ein Repost ist, das Original resharen + const targetId = original.repostOfId ?? original.id; + + // Prüfen ob User diesen Post schon gerepostet hat + const existing = await db.communityPost.findFirst({ + where: { userId: user.id, repostOfId: targetId }, + }); + + if (existing) { + throw createError({ + statusCode: 409, + message: "Du hast diesen Beitrag bereits geteilt", + }); + } + + // Repost erstellen + const repost = await db.communityPost.create({ + data: { + userId: user.id, + category: "repost", + content: "", + repostOfId: targetId, + isAnonymous: false, + isModerated: false, + }, + include: { + author: { + select: { id: true, username: true, nickname: true, avatar: true }, + }, + }, + }); + + // repostsCount auf Original erhöhen + await db.communityPost.update({ + where: { id: targetId }, + data: { repostsCount: { increment: 1 } }, + }); + + // Punkte für den Original-Autor + if (original.userId !== user.id) { + await awardPoints(original.userId, "upvote_received", { + post_id: targetId, + }).catch(() => {}); + } + + return repost; +}); diff --git a/backend/server/api/community/upload-image.post.ts b/backend/server/api/community/upload-image.post.ts new file mode 100644 index 0000000..520f846 --- /dev/null +++ b/backend/server/api/community/upload-image.post.ts @@ -0,0 +1,55 @@ +import { serverSupabaseServiceRole } from "../../utils/useSupabase"; + +/** + * POST /api/community/upload-image + * Body: { dataUrl: string } (base64 JPEG/PNG) + * Returns: { url: string } + */ +export default defineEventHandler(async (event) => { + const user = await requireUser(event); + const body = (await readBody(event)) as { image?: string; dataUrl?: string }; + const dataUrl = body.image ?? body.dataUrl; + + if (!dataUrl?.startsWith("data:image/")) { + throw createError({ statusCode: 400, message: "Ungültige Bilddaten" }); + } + + const match = dataUrl.match(/^data:(image\/\w+);base64,(.+)$/); + if (!match) + throw createError({ statusCode: 400, message: "Ungültiges Bildformat" }); + + const contentType = match[1]; + const ext = contentType === "image/png" ? "png" : "jpg"; + const base64 = match[2]; + + // Max 5MB check + const sizeBytes = Math.ceil((base64.length * 3) / 4); + if (sizeBytes > 5 * 1024 * 1024) { + throw createError({ statusCode: 400, message: "Bild zu groß (max 5MB)" }); + } + + const binaryStr = atob(base64); + const bytes = new Uint8Array(binaryStr.length); + for (let i = 0; i < binaryStr.length; i++) { + bytes[i] = binaryStr.charCodeAt(i); + } + const blob = new Blob([bytes], { type: contentType }); + + const supabase = serverSupabaseServiceRole(event); + const fileName = `posts/${user.id}/${Date.now()}.${ext}`; + + const { error: uploadError } = await supabase.storage + .from("rebreak-avatars") + .upload(fileName, blob, { contentType, upsert: false }); + + if (uploadError) { + console.error("[community/upload-image] Storage error:", uploadError); + throw createError({ statusCode: 500, message: uploadError.message }); + } + + const { data: urlData } = supabase.storage + .from("rebreak-avatars") + .getPublicUrl(fileName); + + return { url: urlData.publicUrl }; +}); diff --git a/backend/server/api/cooldown/cancel.post.ts b/backend/server/api/cooldown/cancel.post.ts new file mode 100644 index 0000000..f8ada90 --- /dev/null +++ b/backend/server/api/cooldown/cancel.post.ts @@ -0,0 +1,24 @@ +import { requireUser } from "../../utils/auth"; +import { getActiveCooldown, cancelCooldown } from "../../db/cooldown"; + +/** + * POST /api/cooldown/cancel + * User changes their mind: cancels the cooldown request. + * The DNS protection REMAINS active — this only removes the pending cooldown + * so they would need to start a new 24h wait if they decide to disable again. + */ +export default defineEventHandler(async (event) => { + const user = await requireUser(event); + + const cooldown = await getActiveCooldown(user.id); + if (!cooldown) { + throw createError({ + statusCode: 404, + data: { error: "no_active_cooldown" }, + }); + } + + await cancelCooldown(cooldown.id); + + return { success: true, data: { cancelled: true } }; +}); diff --git a/backend/server/api/cooldown/request.post.ts b/backend/server/api/cooldown/request.post.ts new file mode 100644 index 0000000..c66335a --- /dev/null +++ b/backend/server/api/cooldown/request.post.ts @@ -0,0 +1,59 @@ +import { requireUser } from "../../utils/auth"; +import { getActiveCooldown, createCooldown } from "../../db/cooldown"; +import { signCooldownToken, generateJti } from "../../utils/cooldownToken"; + +/** POST /api/cooldown/request — Start a 24h cooldown before protection can be disabled. */ +export default defineEventHandler(async (event) => { + const user = await requireUser(event); + const body = await readBody(event).catch(() => ({})); + + // Reject if a cooldown is already running (not resolved, not cancelled). + const existing = await getActiveCooldown(user.id); + if (existing) { + const now = new Date(); + // If the existing one already expired but wasn't resolved yet, that's fine — + // it means canDisableProtection is already true. Return 409 so the client + // calls /status instead. + throw createError({ + statusCode: 409, + data: { + error: "cooldown_already_active", + existingEndsAt: existing.cooldownEndsAt.toISOString(), + }, + }); + } + + // Test-Mode (5min statt 24h) — nur außerhalb von Production aktivierbar. + // Detection via appUrl statt NODE_ENV, da staging.rebreak.org auch mit + // NODE_ENV=production läuft (siehe start-staging.sh). + const config = useRuntimeConfig(event); + const appUrl = (config.public?.appUrl as string) ?? ""; + const isProductionUrl = + appUrl.includes("rebreak.org") && !appUrl.includes("staging"); + const isTestMode = body?.testMode === true && !isProductionUrl; + const cooldownMs = isTestMode ? 40 * 1000 : 24 * 60 * 60 * 1000; + + const now = new Date(); + const cooldownEndsAt = new Date(now.getTime() + cooldownMs); + const jti = generateJti(); + + await createCooldown(user.id, jti, cooldownEndsAt, body?.reason); + + const remainingSeconds = Math.max( + 0, + Math.floor((cooldownEndsAt.getTime() - Date.now()) / 1000), + ); + + const token = await signCooldownToken(user.id, jti, cooldownEndsAt); + + return { + success: true, + data: { + cooldownStartedAt: now.toISOString(), + cooldownEndsAt: cooldownEndsAt.toISOString(), + remainingSeconds, + token, + testMode: isTestMode, + }, + }; +}); diff --git a/backend/server/api/cooldown/status.get.ts b/backend/server/api/cooldown/status.get.ts new file mode 100644 index 0000000..0e29cbd --- /dev/null +++ b/backend/server/api/cooldown/status.get.ts @@ -0,0 +1,66 @@ +import { requireUser } from "../../utils/auth"; +import { getActiveCooldown, resolveCooldown } from "../../db/cooldown"; +import { signCooldownToken } from "../../utils/cooldownToken"; + +/** GET /api/cooldown/status — Current cooldown state for the authenticated user. */ +export default defineEventHandler(async (event) => { + const user = await requireUser(event); + + const cooldown = await getActiveCooldown(user.id); + const now = new Date(); + + if (!cooldown) { + // No cooldown ever started (or all were cancelled/resolved). + return { + success: true, + data: { + active: false, + remainingSeconds: 0, + cooldownEndsAt: null, + canDisableProtection: true, + token: null, // no cooldown row to bind to; app may proceed freely + }, + }; + } + + const expired = now >= cooldown.cooldownEndsAt; + + if (expired) { + // Auto-resolve so we don't re-check next time. + await resolveCooldown(cooldown.id); + + const token = await signCooldownToken( + user.id, + cooldown.tokenJti, + cooldown.cooldownEndsAt, + ); + + return { + success: true, + data: { + active: false, + remainingSeconds: 0, + cooldownEndsAt: cooldown.cooldownEndsAt.toISOString(), + canDisableProtection: true, + token, + }, + }; + } + + // Still counting down. + const remainingSeconds = Math.max( + 0, + Math.floor((cooldown.cooldownEndsAt.getTime() - now.getTime()) / 1000), + ); + + return { + success: true, + data: { + active: true, + remainingSeconds, + cooldownEndsAt: cooldown.cooldownEndsAt.toISOString(), + canDisableProtection: false, + token: null, + }, + }; +}); diff --git a/backend/server/api/cron/lyra-post.ts b/backend/server/api/cron/lyra-post.ts new file mode 100644 index 0000000..97e23a3 --- /dev/null +++ b/backend/server/api/cron/lyra-post.ts @@ -0,0 +1,133 @@ +import { createPost } from "../../db/community"; +import { usePrisma } from "../../utils/prisma"; + +/** + * POST /api/cron/lyra-post + * + * Lyra postet ab und zu in der Community – motivierend, human, nicht zu viel. + * Max. 3x pro Woche. + * + * Aufruf via Server-Cron (z.B. pm2-cron oder Linux crontab): + * 0 10 * * 1,3,5 curl -X POST https://rebreak.org/api/cron/lyra-post \ + * -H "x-cron-secret: $NUXT_CRON_SECRET" + * + * Infisical Secrets: + * NUXT_LYRA_BOT_USER_ID – UUID des Lyra-Profils in der DB + * NUXT_CRON_SECRET – zufälliger langer Token + * NUXT_OPENROUTER_API_KEY – bereits vorhanden + * + * Einmalig auf Server einrichten: + * Registriere einen Account mit Username "lyra" in der App, + * kopiere die user.id und trage sie als NUXT_LYRA_BOT_USER_ID ein. + */ + +const TOPICS = [ + "motivation", + "tipp", + "zitat", + "witzig", + "news", + "feature", +] as const; + +const SYSTEM_PROMPT = `Du bist Lyra, der KI-Coach der ReBreak-App – einer Gemeinschaft für Menschen auf dem Weg aus der Glücksspielsucht. + +Du postest gelegentlich kurze Beiträge in der Community. Deine Tonalität: +- Warm, ermutigend, menschlich – nie klinisch oder robotisch +- Kurz (max. 3–4 Sätze) +- Niemals übertrieben motivierend ("Du schaffst das!!!") – eher still stark +- Keine Casino-Werbung, keine Links, keine medizinischen Ratschläge +- Auf Deutsch + +Je nach Thema postest du: +- "motivation": Ein stiller Gedanke zum Durchhalten +- "tipp": Ein konkreter kleiner Tipp aus der Verhaltensforschung/CBT +- "news": Eine kurze Einordnung einer Entwicklung in der Glücksspielbanche (warnend, sachlich) +- "feature": Ein Hinweis auf ein neues ReBreak-Feature – wie ein Freund der sagt "Übrigens haben wir..." + +Antworte NUR mit dem Post-Text. Kein "Lyra:" Prefix, keine Anführungszeichen.`; + +export default defineEventHandler(async (event) => { + const config = useRuntimeConfig(); + + // Auth via Cron-Secret + const secret = getHeader(event, "x-cron-secret"); + if (!config.cronSecret || secret !== config.cronSecret) { + throw createError({ statusCode: 401, message: "Unauthorized" }); + } + + const lyraBotUserId = config.lyraBotUserId; + if (!lyraBotUserId) { + throw createError({ + statusCode: 500, + message: "LYRA_BOT_USER_ID nicht konfiguriert", + }); + } + + if (!config.openrouterApiKey) { + throw createError({ statusCode: 500, message: "OpenRouter API Key fehlt" }); + } + + // Max 3x pro Woche: letzten Lyra-Post prüfen + const db = usePrisma(); + const threeDaysAgo = new Date(Date.now() - 3 * 24 * 60 * 60 * 1000); + const recentPost = await db.communityPost.findFirst({ + where: { + userId: lyraBotUserId, + createdAt: { gte: threeDaysAgo }, + }, + orderBy: { createdAt: "desc" }, + }); + + if (recentPost) { + return { + skipped: true, + reason: "Lyra hat in den letzten 3 Tagen bereits gepostet", + }; + } + + // Zufälliges Thema + const topic = TOPICS[Math.floor(Math.random() * TOPICS.length)]; + + const topicHint: Record<(typeof TOPICS)[number], string> = { + motivation: + "Schreibe einen kurzen, stillen Gedanken für Menschen die heute kämpfen. Nicht übertrieben – eher ruhig stark.", + tipp: "Teile einen kleinen, konkreten Trick aus der Verhaltensforschung oder CBT gegen Spieldrang. Praktisch und direkt.", + zitat: + "Teile ein tiefgründiges Zitat aus Psychologie, Stoizismus oder Verhaltensforschung – ohne das Wort 'Sucht' zu verwenden. Kurz kommentiert.", + witzig: + "Schreibe einen witzigen, selbstironischen Post über das Thema Ablenkung, Impulskontrolle oder Gewohnheiten – leicht und menschlich, nicht flach.", + news: "Beschreibe kurz eine typische Taktik der Glücksspielindustrie (z.B. Push-Notifications, Bonusangebote) – sachlich und als Warnung formuliert.", + feature: + "Weise freundlich auf ein ReBreak-Feature hin (Blocker, Streak, Mail-Agent, Lyra-Chat, SOS-Atemübung) – wähle eines zufällig.", + }; + + const response = await $fetch<{ + choices: { message: { content: string } }[]; + }>("https://openrouter.ai/api/v1/chat/completions", { + method: "POST", + headers: { + Authorization: `Bearer ${config.openrouterApiKey}`, + "Content-Type": "application/json", + "HTTP-Referer": "https://rebreak.org", + "X-Title": "ReBreak - Lyra Community Post", + }, + body: { + model: "meta-llama/llama-3.2-3b-instruct:free", + max_tokens: 200, + messages: [ + { role: "system", content: SYSTEM_PROMPT }, + { role: "user", content: topicHint[topic] }, + ], + }, + }); + + const content = response.choices?.[0]?.message?.content?.trim(); + if (!content) { + throw createError({ statusCode: 500, message: "Keine Antwort von LLM" }); + } + + const post = await createPost(lyraBotUserId, "community", content); + + return { success: true, postId: post.id, topic }; +}); diff --git a/backend/server/api/cron/notifications-cleanup.ts b/backend/server/api/cron/notifications-cleanup.ts new file mode 100644 index 0000000..4b05c3d --- /dev/null +++ b/backend/server/api/cron/notifications-cleanup.ts @@ -0,0 +1,21 @@ +import { deleteOldNotifications } from "../../db/notifications"; + +/** + * POST /api/cron/notifications-cleanup + * + * Löscht Notifications die älter als 3 Tage sind. + * Einrichten auf Hetzner via crontab: + * + * crontab -e + * 0 2 * * * curl -s -X POST https://rebreak.org/api/cron/notifications-cleanup \ + * -H "x-cron-secret: $NUXT_CRON_SECRET" >> /var/log/rebreak-cron.log 2>&1 + */ +export default defineEventHandler(async (event) => { + const secret = getHeader(event, "x-cron-secret"); + if (!secret || secret !== process.env.NUXT_CRON_SECRET) { + throw createError({ statusCode: 401, message: "Unauthorized" }); + } + + const result = await deleteOldNotifications(); + return { deleted: result.count }; +}); diff --git a/backend/server/api/custom-domains/[id].delete.ts b/backend/server/api/custom-domains/[id].delete.ts new file mode 100644 index 0000000..cdfe2b8 --- /dev/null +++ b/backend/server/api/custom-domains/[id].delete.ts @@ -0,0 +1,19 @@ +import { deleteUserCustomDomain } from "../../db/domains"; + +export default defineEventHandler(async (event) => { + const user = await requireUser(event); + const id = getRouterParam(event, "id"); + + if (!id) throw createError({ statusCode: 400, message: "ID fehlt" }); + + try { + await deleteUserCustomDomain(id, user.id); + } catch (err: any) { + if (err.code === "P2025") { + throw createError({ statusCode: 404, message: "Domain nicht gefunden" }); + } + throw createError({ statusCode: 500, message: err.message }); + } + + return { ok: true }; +}); diff --git a/backend/server/api/custom-domains/[id]/submit.post.ts b/backend/server/api/custom-domains/[id]/submit.post.ts new file mode 100644 index 0000000..247d78e --- /dev/null +++ b/backend/server/api/custom-domains/[id]/submit.post.ts @@ -0,0 +1,68 @@ +import { submitDomainForReview } from "../../../db/domains"; +import { getProfile } from "../../../db/profile"; +import { getPlanLimits } from "../../../utils/plan-features"; +import { usePrisma } from "../../../utils/prisma"; + +export default defineEventHandler(async (event) => { + const user = await requireUser(event); + const id = getRouterParam(event, "id"); + if (!id) throw createError({ statusCode: 400, message: "ID fehlt" }); + + // Only Pro/Legend can submit + const profile = await getProfile(user.id); + const plan = profile?.plan ?? "free"; + const limits = getPlanLimits(plan); + if (!limits.domainRefill) { + throw createError({ + statusCode: 403, + message: "Nur Pro-User können Domains einreichen", + }); + } + + const db = usePrisma(); + // Verify ownership + status + const existing = await db.userCustomDomain.findFirst({ + where: { id, userId: user.id }, + select: { id: true, domain: true, status: true }, + }); + if (!existing) + throw createError({ statusCode: 404, message: "Domain nicht gefunden" }); + if (existing.status !== "active" && existing.status !== "rejected") { + throw createError({ + statusCode: 409, + message: "Domain wurde bereits eingereicht oder genehmigt", + }); + } + + // Tier-Routing: + // - Pro: Community-Post mit Voting-Flow erstellen + // - Legend: KEIN Post — Domain landet direkt in der Admin-Queue zur manuellen Prüfung + let postId: string | null = null; + if (plan === "pro") { + const postContent = `🛡️ Domain-Vorschlag: **${existing.domain}**\n\nIch schlage vor, diese Domain zur globalen ReBreak-Sperrliste hinzuzufügen. Stimme ab: Sollte **${existing.domain}** global gesperrt werden?`; + const post = await db.communityPost.create({ + data: { + userId: user.id, + category: "domain_vote", + content: postContent, + }, + select: { id: true }, + }); + postId = post.id; + } + + const { submission } = await submitDomainForReview( + user.id, + id, + plan as "free" | "pro" | "legend", + postId ?? undefined, + ); + + return { + ok: true, + postId, + submissionId: submission.id, + domain: existing.domain, + route: plan === "legend" ? "admin_direct" : "community_vote", + }; +}); diff --git a/backend/server/api/custom-domains/index.get.ts b/backend/server/api/custom-domains/index.get.ts new file mode 100644 index 0000000..706638e --- /dev/null +++ b/backend/server/api/custom-domains/index.get.ts @@ -0,0 +1,6 @@ +import { getUserCustomDomains } from "../../db/domains"; + +export default defineEventHandler(async (event) => { + const user = await requireUser(event); + return getUserCustomDomains(user.id); +}); diff --git a/backend/server/api/custom-domains/index.post.ts b/backend/server/api/custom-domains/index.post.ts new file mode 100644 index 0000000..c40c665 --- /dev/null +++ b/backend/server/api/custom-domains/index.post.ts @@ -0,0 +1,52 @@ +import { awardPoints } from "../../utils/scoring"; +import { addUserCustomDomain, countActiveCustomDomains } from "../../db/domains"; +import { getProfile } from "../../db/profile"; +import { getPlanLimits } from "../../utils/plan-features"; + +export default defineEventHandler(async (event) => { + const user = await requireUser(event); + const body = await readBody(event); + + const domain = (body?.domain as string) + ?.trim() + .toLowerCase() + .replace(/^https?:\/\//, ""); + if ( + !domain || + !/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/.test( + domain, + ) + ) { + throw createError({ statusCode: 400, message: "Ungültige Domain" }); + } + + // Plan-Limit prüfen + const profile = await getProfile(user.id); + const limits = getPlanLimits(profile?.plan ?? "free"); + + if (limits.customDomains !== Infinity) { + const activeCount = await countActiveCustomDomains(user.id); + if (activeCount >= limits.customDomains) { + throw createError({ + statusCode: 403, + message: `Dein Plan erlaubt maximal ${limits.customDomains} eigene Domains`, + }); + } + } + + try { + const data = await addUserCustomDomain(user.id, domain, "manual"); + + await awardPoints(user.id, "custom_domain_submitted", { domain }).catch( + () => {}, + ); + + return data; + } catch (err: any) { + const msg = + err.message?.includes("duplicate") || err.code === "P2002" + ? "Domain bereits vorhanden" + : err.message ?? "Fehler"; + throw createError({ statusCode: 400, message: msg }); + } +}); diff --git a/backend/server/api/devices/[id].delete.ts b/backend/server/api/devices/[id].delete.ts new file mode 100644 index 0000000..3d81957 --- /dev/null +++ b/backend/server/api/devices/[id].delete.ts @@ -0,0 +1,17 @@ +import { deleteUserDevice } from "../../db/devices"; + +/** + * DELETE /api/devices/:id + * + * User entfernt ein eigenes Device — gibt damit einen Slot frei. + * Idempotent: wenn Device nicht existiert oder bereits gelöscht → 200. + */ +export default defineEventHandler(async (event) => { + // skipDeviceCheck: User soll bei Geräte-Limit-Sperre trotzdem freigeben können. + const user = await requireUser(event, { skipDeviceCheck: true }); + const id = getRouterParam(event, "id"); + if (!id) throw createError({ statusCode: 400, message: "id required" }); + + await deleteUserDevice(user.id, id); + return { ok: true }; +}); diff --git a/backend/server/api/devices/index.get.ts b/backend/server/api/devices/index.get.ts new file mode 100644 index 0000000..b907e5d --- /dev/null +++ b/backend/server/api/devices/index.get.ts @@ -0,0 +1,29 @@ +import { listUserDevices } from "../../db/devices"; +import { getProfile } from "../../db/profile"; +import { getPlanLimits } from "../../utils/plan-features"; + +/** + * GET /api/devices + * + * Liste aller registrierten Devices des Users + plan-limit + welches Device der + * aktuelle Caller ist (matched via x-device-id header). + */ +export default defineEventHandler(async (event) => { + // skipDeviceCheck: User der gerade vom Geräte-Limit blockt wird, soll trotzdem + // seine Devices-Liste sehen können um eines freizugeben (Chicken-Egg-Bypass). + const user = await requireUser(event, { skipDeviceCheck: true }); + const profile = await getProfile(user.id); + const limits = getPlanLimits(profile?.plan ?? "free"); + + const currentDeviceId = getHeader(event, "x-device-id") ?? null; + const devices = await listUserDevices(user.id); + + return { + devices: devices.map((d) => ({ + ...d, + isCurrent: !!currentDeviceId && d.deviceId === currentDeviceId, + })), + max: limits.maxDevices, + plan: profile?.plan ?? "free", + }; +}); diff --git a/backend/server/api/devices/register.post.ts b/backend/server/api/devices/register.post.ts new file mode 100644 index 0000000..39a9961 --- /dev/null +++ b/backend/server/api/devices/register.post.ts @@ -0,0 +1,64 @@ +import { listUserDevices, registerDevice } from "../../db/devices"; +import { getProfile } from "../../db/profile"; +import { getPlanLimits } from "../../utils/plan-features"; + +/** + * POST /api/devices/register + * + * Body: { deviceId: string, platform: string, model?: string, name?: string } + * + * Idempotent: gleiche deviceId für gleichen User → updated lastSeenAt + 200. + * Wenn neues Device + Limit erreicht → 403 mit { error, devices } damit der + * Frontend-Drawer dem User die Wahl gibt, welches Gerät er freigibt. + */ +export default defineEventHandler(async (event) => { + // Bootstrap: kein Device-Check sonst wäre erstes Register unmöglich (chicken-egg) + const user = await requireUser(event, { skipDeviceCheck: true }); + const body = await readBody(event); + const { deviceId, platform, model, name } = body as { + deviceId?: string; + platform?: string; + model?: string; + name?: string; + }; + + if (!deviceId || !platform) { + throw createError({ + statusCode: 400, + message: "deviceId und platform required", + }); + } + if (!["ios", "android", "web"].includes(platform)) { + throw createError({ statusCode: 400, message: "invalid platform" }); + } + + const profile = await getProfile(user.id); + const limits = getPlanLimits(profile?.plan ?? "free"); + + try { + const { device, created } = await registerDevice({ + userId: user.id, + deviceId, + platform, + model: model ?? null, + name: name ?? null, + maxDevices: limits.maxDevices, + }); + return { device, created, max: limits.maxDevices }; + } catch (err: any) { + if (err.code === "DEVICE_LIMIT_REACHED") { + const devices = await listUserDevices(user.id); + throw createError({ + statusCode: 403, + statusMessage: "device_limit_reached", + data: { + error: "device_limit_reached", + max: limits.maxDevices, + plan: profile?.plan ?? "free", + devices, + }, + }); + } + throw err; + } +}); diff --git a/backend/server/api/dns/profile.get.ts b/backend/server/api/dns/profile.get.ts new file mode 100644 index 0000000..aee7445 --- /dev/null +++ b/backend/server/api/dns/profile.get.ts @@ -0,0 +1,135 @@ +// DNS profile generator — DoH URL uses per-user subdomain for iOS compatibility +import { randomUUID } from "node:crypto"; +import { execSync } from "node:child_process"; +import { existsSync, writeFileSync, readFileSync, unlinkSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { getProfile } from "../../db/profile"; + +/** + * Generiert ein iOS/macOS .mobileconfig DNS-Profil. + * + * Das Profil zeigt auf unseren eigenen DNS-Server (dns.rebreak.de), + * der sowohl globale Blocklist als auch User-Custom-Domains blockiert. + * + * Free-User: Nur eigene Custom Domains werden blockiert + * Pro-User: Globale Blocklist + Custom Domains + */ +export default defineEventHandler(async (event) => { + const user = await requireUser(event); + + // Get user plan + const profile = await getProfile(user.id); + const isPro = profile?.plan === "pro" || profile?.plan === "legend"; + + const profileUUID = randomUUID().toUpperCase(); + const payloadUUID = randomUUID().toUpperCase(); + + // DNS Server URL - points to our DNS proxy via per-user subdomain + // Subdomain carries userId so iOS DoH profile has user context without query params + // Staging: .dns-staging.rebreak.org | Prod: .rebreak.org + const config = useRuntimeConfig(); + const isStaging = config.public.appUrl.includes("staging"); + const dnsServerUrl = isStaging + ? `https://${user.id}.dns-staging.rebreak.org/dns-query` + : `https://${user.id}.rebreak.org/dns-query`; + + // Get counts for description + const { getActiveBlocklistCount, getUserCustomDomains } = await import("../../db/domains"); + const [globalCount, customDomains] = await Promise.all([ + isPro ? getActiveBlocklistCount() : Promise.resolve(0), + getUserCustomDomains(user.id), + ]); + const customCount = customDomains.length; + const totalCount = globalCount + customCount; + + const description = isPro + ? `Aktiviert automatisches Blocking von Glücksspielseiten auf deinem Gerät. ${totalCount.toLocaleString()} Domains werden blockiert.` + : `Aktiviert automatisches Blocking deiner personalisierten Sperrliste auf deinem Gerät. ${customCount} Domains werden blockiert.`; + + const xml = ` + + + + PayloadDisplayName + ReBreak Schutz + PayloadDescription + ${description} + PayloadIdentifier + org.rebreak.protection + PayloadUUID + ${profileUUID} + PayloadType + Configuration + PayloadVersion + 1 + PayloadContent + + + PayloadDisplayName + ReBreak Schutz + PayloadIdentifier + org.rebreak.protection.payload + PayloadUUID + ${payloadUUID} + PayloadType + com.apple.dnsSettings.managed + PayloadVersion + 1 + DNSSettings + + DNSProtocol + HTTPS + ServerURL + ${dnsServerUrl} + + + + +`; + + setResponseHeaders(event, { + "Content-Type": "application/x-apple-aspen-config", + "Content-Disposition": + 'attachment; filename="rebreak-protection.mobileconfig"', + }); + + // Cert base path based on environment + const certBase = isStaging + ? "/etc/letsencrypt/live/staging.rebreak.org" + : "/etc/letsencrypt/live/rebreak.org"; + const certFile = `${certBase}/cert.pem`; + const keyFile = `${certBase}/privkey.pem`; + const chainFile = `${certBase}/chain.pem`; + + if (existsSync(certFile) && existsSync(keyFile)) { + const id = `${Date.now()}-${Math.random().toString(36).slice(2)}`; + const tmpIn = join(tmpdir(), `rebreak-profile-${id}.xml`); + const tmpOut = join(tmpdir(), `rebreak-profile-${id}.der`); + try { + writeFileSync(tmpIn, xml, "utf8"); + execSync( + `openssl smime -sign -signer ${certFile} -inkey ${keyFile}${existsSync(chainFile) ? ` -certfile ${chainFile}` : ""} -nodetach -in ${tmpIn} -out ${tmpOut} -outform DER`, + { stdio: "pipe", timeout: 5000 }, + ); + const signed = readFileSync(tmpOut); + return signed; + } catch { + // Fallback: unsigniert (funktioniert noch, zeigt nur Warnung in iOS) + } finally { + try { + unlinkSync(tmpIn); + } catch { + /* ignore */ + } + try { + unlinkSync(tmpOut); + } catch { + /* ignore */ + } + } + } + + // Unsigned fallback (Entwicklung / Zertifikat nicht verfügbar) + return xml; +}); diff --git a/backend/server/api/domain-submissions/[id]/vote.post.ts b/backend/server/api/domain-submissions/[id]/vote.post.ts new file mode 100644 index 0000000..47407b0 --- /dev/null +++ b/backend/server/api/domain-submissions/[id]/vote.post.ts @@ -0,0 +1,48 @@ +import { castDomainVote } from "../../../db/domains"; +import { createNotification } from "../../../db/notifications"; +import { getProfile } from "../../../db/profile"; +import { usePrisma } from "../../../utils/prisma"; + +export default defineEventHandler(async (event) => { + const user = await requireUser(event); + const id = getRouterParam(event, "id"); + if (!id) + throw createError({ statusCode: 400, message: "Submission ID fehlt" }); + + const body = await readBody(event); + const vote = body?.vote as string; + if (vote !== "yes" && vote !== "no") { + throw createError({ + statusCode: 400, + message: "vote muss 'yes' oder 'no' sein", + }); + } + + const db = usePrisma(); + const [result, submission] = await Promise.all([ + castDomainVote(user.id, id, vote), + db.domainSubmission.findUnique({ + where: { id }, + select: { userId: true, domain: true }, + }), + ]); + + if ( + submission?.userId && + submission.userId !== user.id && + (result as any).yesVotes !== undefined + ) { + const pr = await getProfile(user.id).catch(() => null); + const actorName = pr?.nickname ?? pr?.username ?? "Jemand"; + const actorAvatar = pr?.avatar ?? undefined; + createNotification({ + recipientId: submission.userId, + type: "domain_vote", + actorName, + actorAvatar, + preview: submission.domain, + }).catch(() => {}); + } + + return result; +}); diff --git a/backend/server/api/feedback/[id].patch.ts b/backend/server/api/feedback/[id].patch.ts new file mode 100644 index 0000000..19f0cc8 --- /dev/null +++ b/backend/server/api/feedback/[id].patch.ts @@ -0,0 +1,30 @@ +import { usePrisma } from "../../utils/prisma"; + +export default defineEventHandler(async (event) => { + const config = useRuntimeConfig(); + const secret = getHeader(event, "x-admin-secret"); + if (!config.adminSecret || secret !== config.adminSecret) { + throw createError({ statusCode: 401, message: "Nicht autorisiert" }); + } + + const id = getRouterParam(event, "id"); + if (!id) throw createError({ statusCode: 400, message: "id fehlt" }); + + const body = await readBody(event) as { + status?: string; + adminNote?: string; + category?: string; + }; + + const db = usePrisma(); + const updated = await db.feedbackItem.update({ + where: { id }, + data: { + ...(body.status !== undefined && { status: body.status as any }), + ...(body.adminNote !== undefined && { adminNote: body.adminNote }), + ...(body.category !== undefined && { category: body.category }), + }, + }); + + return updated; +}); diff --git a/backend/server/api/feedback/index.get.ts b/backend/server/api/feedback/index.get.ts new file mode 100644 index 0000000..b39aab0 --- /dev/null +++ b/backend/server/api/feedback/index.get.ts @@ -0,0 +1,21 @@ +import { usePrisma } from "../../utils/prisma"; + +export default defineEventHandler(async (event) => { + const config = useRuntimeConfig(); + const secret = getHeader(event, "x-admin-secret"); + if (!config.adminSecret || secret !== config.adminSecret) { + throw createError({ statusCode: 401, message: "Nicht autorisiert" }); + } + + const query = getQuery(event); + const status = query.status as string | undefined; + + const db = usePrisma(); + const items = await db.feedbackItem.findMany({ + where: status ? { status: status as any } : undefined, + orderBy: { createdAt: "desc" }, + take: 200, + }); + + return items; +}); diff --git a/backend/server/api/games/challenge-memory.post.ts b/backend/server/api/games/challenge-memory.post.ts new file mode 100644 index 0000000..324ce17 --- /dev/null +++ b/backend/server/api/games/challenge-memory.post.ts @@ -0,0 +1,62 @@ +import { usePrisma } from "../../utils/prisma"; +import { getProfile } from "../../db/profile"; + +const MEMORY_EMOJIS = ["🛡️", "💪", "🌟", "🧠", "🌊", "🎯", "🌱", "🔑"]; + +function shuffle(arr: T[]): T[] { + const a = [...arr]; + for (let i = a.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + const tmp = a[i]!; + a[i] = a[j]!; + a[j] = tmp; + } + return a; +} + +export default defineEventHandler(async (event) => { + const user = await requireUser(event); + const db = usePrisma(); + + const profile = await getProfile(user.id); + const name = profile?.nickname || profile?.username || "Anonym"; + + const pairs = shuffle([...MEMORY_EMOJIS, ...MEMORY_EMOJIS]); + const memoryState = { + cards: pairs.map((emoji: string, id: number) => ({ + id, + emoji, + matchedBy: null, + })), + flipped: [] as number[], + scores: { X: 0, O: 0 }, + mismatchRevealed: false, + }; + + const challenge = await db.gameChallenge.create({ + data: { + challengerId: user.id, + challengerName: name, + gameType: "memory", + memoryState, + }, + }); + + const post = await db.communityPost.create({ + data: { + userId: user.id, + category: "challenge", + content: `${name} sucht einen Gegner für Memory! Wer nimmt die Challenge an?`, + isAnonymous: false, + isModerated: false, + challengeId: challenge.id, + }, + }); + + await db.gameChallenge.update({ + where: { id: challenge.id }, + data: { postId: post.id }, + }); + + return { challengeId: challenge.id }; +}); diff --git a/backend/server/api/games/challenge.post.ts b/backend/server/api/games/challenge.post.ts new file mode 100644 index 0000000..945048a --- /dev/null +++ b/backend/server/api/games/challenge.post.ts @@ -0,0 +1,38 @@ +import { usePrisma } from "../../utils/prisma"; +import { getProfile } from "../../db/profile"; + +export default defineEventHandler(async (event) => { + const user = await requireUser(event); + const db = usePrisma(); + + const profile = await getProfile(user.id); + const name = profile?.nickname || profile?.username || "Anonym"; + + // Create game challenge + const challenge = await db.gameChallenge.create({ + data: { + challengerId: user.id, + challengerName: name, + }, + }); + + // Auto-post in community so others can accept + const post = await db.communityPost.create({ + data: { + userId: user.id, + category: "challenge", + content: `${name} sucht einen Gegner für Tic-Tac-Toe! Wer nimmt die Challenge an?`, + isAnonymous: false, + isModerated: false, + challengeId: challenge.id, + }, + }); + + // Link post back to challenge + await db.gameChallenge.update({ + where: { id: challenge.id }, + data: { postId: post.id }, + }); + + return { challengeId: challenge.id, postId: post.id }; +}); diff --git a/backend/server/api/games/challenge/[id].get.ts b/backend/server/api/games/challenge/[id].get.ts new file mode 100644 index 0000000..f04e577 --- /dev/null +++ b/backend/server/api/games/challenge/[id].get.ts @@ -0,0 +1,16 @@ +import { usePrisma } from "../../../utils/prisma"; + +export default defineEventHandler(async (event) => { + const user = await requireUser(event); + const id = getRouterParam(event, "id"); + if (!id) throw createError({ statusCode: 400, message: "id fehlt" }); + + const db = usePrisma(); + const challenge = await db.gameChallenge.findUnique({ where: { id } }); + + if (!challenge) { + throw createError({ statusCode: 404, message: "Challenge nicht gefunden" }); + } + + return challenge; +}); diff --git a/backend/server/api/games/challenge/[id]/accept.post.ts b/backend/server/api/games/challenge/[id]/accept.post.ts new file mode 100644 index 0000000..3d8c451 --- /dev/null +++ b/backend/server/api/games/challenge/[id]/accept.post.ts @@ -0,0 +1,35 @@ +import { usePrisma } from "../../../../utils/prisma"; +import { getProfile } from "../../../../db/profile"; + +export default defineEventHandler(async (event) => { + const user = await requireUser(event); + const id = getRouterParam(event, "id"); + if (!id) throw createError({ statusCode: 400, message: "id fehlt" }); + + const db = usePrisma(); + const challenge = await db.gameChallenge.findUnique({ where: { id } }); + + if (!challenge) { + throw createError({ statusCode: 404, message: "Challenge nicht gefunden" }); + } + if (challenge.challengerId === user.id) { + throw createError({ statusCode: 400, message: "Du kannst deine eigene Challenge nicht annehmen" }); + } + if (challenge.status !== "OPEN") { + throw createError({ statusCode: 409, message: "Challenge ist nicht mehr offen" }); + } + + const profile = await getProfile(user.id); + const name = profile?.nickname || profile?.username || "Anonym"; + + const updated = await db.gameChallenge.update({ + where: { id }, + data: { + opponentId: user.id, + opponentName: name, + status: "ACTIVE", + }, + }); + + return updated; +}); diff --git a/backend/server/api/games/challenge/[id]/live-toggle.post.ts b/backend/server/api/games/challenge/[id]/live-toggle.post.ts new file mode 100644 index 0000000..6729a71 --- /dev/null +++ b/backend/server/api/games/challenge/[id]/live-toggle.post.ts @@ -0,0 +1,35 @@ +import { usePrisma } from "../../../../utils/prisma"; + +/** POST /api/games/challenge/[id]/live-toggle — toggle isLive flag */ +export default defineEventHandler(async (event) => { + const user = await requireUser(event); + const id = getRouterParam(event, "id"); + if (!id) throw createError({ statusCode: 400, message: "id fehlt" }); + + const db = usePrisma(); + const challenge = await db.gameChallenge.findUnique({ + where: { id }, + select: { + challengerId: true, + opponentId: true, + isLive: true, + status: true, + }, + }); + + if (!challenge) + throw createError({ statusCode: 404, message: "Challenge nicht gefunden" }); + + // Only participants can toggle + if (challenge.challengerId !== user.id && challenge.opponentId !== user.id) { + throw createError({ statusCode: 403, message: "Nicht berechtigt" }); + } + + const updated = await db.gameChallenge.update({ + where: { id }, + data: { isLive: !challenge.isLive }, + select: { isLive: true }, + }); + + return { isLive: updated.isLive }; +}); diff --git a/backend/server/api/games/challenge/[id]/memory-move.post.ts b/backend/server/api/games/challenge/[id]/memory-move.post.ts new file mode 100644 index 0000000..a5b87ce --- /dev/null +++ b/backend/server/api/games/challenge/[id]/memory-move.post.ts @@ -0,0 +1,152 @@ +import { usePrisma } from "../../../../utils/prisma"; + +interface MemoryCard { + id: number; + emoji: string; + matchedBy: "X" | "O" | null; +} + +interface MemoryState { + cards: MemoryCard[]; + flipped: number[]; + scores: { X: number; O: number }; + mismatchRevealed: boolean; +} + +export default defineEventHandler(async (event) => { + const user = await requireUser(event); + const id = getRouterParam(event, "id"); + if (!id) throw createError({ statusCode: 400, message: "id fehlt" }); + + const body = await readBody(event) as { cardIndex: number }; + const { cardIndex } = body; + + if (typeof cardIndex !== "number" || cardIndex < 0 || cardIndex > 15) { + throw createError({ statusCode: 400, message: "Ungültiger Zug" }); + } + + const db = usePrisma(); + const challenge = await db.gameChallenge.findUnique({ where: { id } }); + + if (!challenge) throw createError({ statusCode: 404, message: "Challenge nicht gefunden" }); + if (challenge.status !== "ACTIVE") throw createError({ statusCode: 409, message: "Spiel nicht aktiv" }); + if (challenge.gameType !== "memory") throw createError({ statusCode: 400, message: "Kein Memory-Spiel" }); + + const isChallenger = user.id === challenge.challengerId; + const isOpponent = user.id === challenge.opponentId; + if (!isChallenger && !isOpponent) throw createError({ statusCode: 403, message: "Nicht autorisiert" }); + + const myMark: "X" | "O" = isChallenger ? "X" : "O"; + if (challenge.currentTurn !== myMark) throw createError({ statusCode: 409, message: "Nicht dein Zug" }); + + const state = JSON.parse(JSON.stringify(challenge.memoryState)) as MemoryState; + const card = state.cards[cardIndex]; + if (!card) throw createError({ statusCode: 400, message: "Ungültige Karte" }); + if (card.matchedBy !== null) throw createError({ statusCode: 409, message: "Karte bereits gefunden" }); + + let newStatus = challenge.status as string; + let newWinner: string | null = challenge.winner; + let nextTurn: string = myMark; + + // Mismatch pending: player must click one of their 2 revealed cards to hide them + if (state.mismatchRevealed) { + if (!state.flipped.includes(cardIndex)) { + throw createError({ statusCode: 409, message: "Decke zuerst deine aufgedeckten Karten zu" }); + } + // Hide both cards and switch turn + state.flipped = []; + state.mismatchRevealed = false; + nextTurn = myMark === "X" ? "O" : "X"; + } else { + if (state.flipped.includes(cardIndex)) { + throw createError({ statusCode: 409, message: "Karte bereits aufgedeckt" }); + } + + if (state.flipped.length === 0) { + state.flipped = [cardIndex]; + } else { + // Second flip + const firstId = state.flipped[0]!; + const firstCard = state.cards[firstId]!; + state.flipped = [firstId, cardIndex]; + + if (firstCard.emoji === card.emoji) { + // Match – mark both, clear flipped, stay on same turn + state.cards[firstId]!.matchedBy = myMark; + state.cards[cardIndex]!.matchedBy = myMark; + state.scores[myMark]++; + state.flipped = []; + + const totalPairs = state.cards.length / 2; + const matchedPairs = state.cards.filter(c => c.matchedBy !== null).length / 2; + + if (matchedPairs === totalPairs) { + newStatus = "FINISHED"; + const { X, O } = state.scores; + newWinner = X > O ? "X" : O > X ? "O" : "draw"; + } + // nextTurn stays as myMark (scorer goes again) + } else { + // Mismatch – show cards, stay on same turn until player hides them + state.mismatchRevealed = true; + // nextTurn stays as myMark + } + } + } + + const updated = await db.gameChallenge.update({ + where: { id }, + data: { + memoryState: state as any, + currentTurn: nextTurn, + ...(newStatus !== challenge.status && { status: newStatus as any }), + ...(newWinner !== challenge.winner && { winner: newWinner }), + }, + }); + + if (newStatus === "FINISHED" && challenge.opponentId && challenge.opponentName) { + const challengerId = challenge.challengerId; + const opponentId = challenge.opponentId; + const challengerName = challenge.challengerName; + const opponentName = challenge.opponentName; + + if (newWinner === "draw") { + await Promise.all([ + db.gameScore.upsert({ + where: { userId: challengerId }, + update: { draws: { increment: 1 }, points: { increment: 1 }, playerName: challengerName }, + create: { userId: challengerId, playerName: challengerName, draws: 1, points: 1 }, + }), + db.gameScore.upsert({ + where: { userId: opponentId }, + update: { draws: { increment: 1 }, points: { increment: 1 }, playerName: opponentName }, + create: { userId: opponentId, playerName: opponentName, draws: 1, points: 1 }, + }), + ]); + } else { + const winnerId = newWinner === "X" ? challengerId : opponentId; + const loserId = newWinner === "X" ? opponentId : challengerId; + const winnerName = newWinner === "X" ? challengerName : opponentName; + const loserName = newWinner === "X" ? opponentName : challengerName; + + await Promise.all([ + db.gameScore.upsert({ + where: { userId: winnerId }, + update: { wins: { increment: 1 }, points: { increment: 3 }, playerName: winnerName }, + create: { userId: winnerId, playerName: winnerName, wins: 1, points: 3 }, + }), + db.gameScore.upsert({ + where: { userId: loserId }, + update: { losses: { increment: 1 }, playerName: loserName }, + create: { userId: loserId, playerName: loserName, losses: 1 }, + }), + ]); + } + + if (challenge.postId) { + await db.communityPost.delete({ where: { id: challenge.postId } }).catch(() => {}); + } + } + + return updated; +}); diff --git a/backend/server/api/games/challenge/[id]/move.post.ts b/backend/server/api/games/challenge/[id]/move.post.ts new file mode 100644 index 0000000..e9c44b0 --- /dev/null +++ b/backend/server/api/games/challenge/[id]/move.post.ts @@ -0,0 +1,109 @@ +import { usePrisma } from "../../../../utils/prisma"; + +const WIN_LINES = [ + [0, 1, 2], [3, 4, 5], [6, 7, 8], + [0, 3, 6], [1, 4, 7], [2, 5, 8], + [0, 4, 8], [2, 4, 6], +]; + +function checkWinner(board: string): "X" | "O" | null { + for (const [a, b, c] of WIN_LINES) { + if (board[a!] !== "-" && board[a!] === board[b!] && board[a!] === board[c!]) { + return board[a!] as "X" | "O"; + } + } + return null; +} + +export default defineEventHandler(async (event) => { + const user = await requireUser(event); + const id = getRouterParam(event, "id"); + if (!id) throw createError({ statusCode: 400, message: "id fehlt" }); + + const body = await readBody(event) as { cellIndex: number }; + const { cellIndex } = body; + + if (typeof cellIndex !== "number" || cellIndex < 0 || cellIndex > 8) { + throw createError({ statusCode: 400, message: "Ungültiger Zug" }); + } + + const db = usePrisma(); + const challenge = await db.gameChallenge.findUnique({ where: { id } }); + + if (!challenge) throw createError({ statusCode: 404, message: "Challenge nicht gefunden" }); + if (challenge.status !== "ACTIVE") throw createError({ statusCode: 409, message: "Spiel nicht aktiv" }); + + const isChallenger = user.id === challenge.challengerId; + const isOpponent = user.id === challenge.opponentId; + + if (!isChallenger && !isOpponent) throw createError({ statusCode: 403, message: "Nicht autorisiert" }); + + const myMark = isChallenger ? "X" : "O"; + if (challenge.currentTurn !== myMark) throw createError({ statusCode: 409, message: "Nicht dein Zug" }); + if (challenge.board[cellIndex] !== "-") throw createError({ statusCode: 409, message: "Feld bereits belegt" }); + + const cells = challenge.board.split(""); + cells[cellIndex] = myMark; + const newBoard = cells.join(""); + + const winner = checkWinner(newBoard); + const isDraw = !winner && !newBoard.includes("-"); + + const updated = await db.gameChallenge.update({ + where: { id }, + data: { + board: newBoard, + currentTurn: myMark === "X" ? "O" : "X", + ...(winner && { winner, status: "FINISHED" }), + ...(isDraw && { winner: "draw", status: "FINISHED" }), + }, + }); + + // Ranking update wenn Spiel beendet + if ((winner || isDraw) && challenge.opponentId && challenge.opponentName) { + const challengerId = challenge.challengerId; + const opponentId = challenge.opponentId; + const challengerName = challenge.challengerName; + const opponentName = challenge.opponentName; + + if (isDraw) { + await Promise.all([ + db.gameScore.upsert({ + where: { userId: challengerId }, + update: { draws: { increment: 1 }, points: { increment: 1 }, playerName: challengerName }, + create: { userId: challengerId, playerName: challengerName, draws: 1, points: 1 }, + }), + db.gameScore.upsert({ + where: { userId: opponentId }, + update: { draws: { increment: 1 }, points: { increment: 1 }, playerName: opponentName }, + create: { userId: opponentId, playerName: opponentName, draws: 1, points: 1 }, + }), + ]); + } else { + const winnerId = winner === "X" ? challengerId : opponentId; + const loserId = winner === "X" ? opponentId : challengerId; + const winnerName = winner === "X" ? challengerName : opponentName; + const loserName = winner === "X" ? opponentName : challengerName; + + await Promise.all([ + db.gameScore.upsert({ + where: { userId: winnerId }, + update: { wins: { increment: 1 }, points: { increment: 3 }, playerName: winnerName }, + create: { userId: winnerId, playerName: winnerName, wins: 1, points: 3 }, + }), + db.gameScore.upsert({ + where: { userId: loserId }, + update: { losses: { increment: 1 }, playerName: loserName }, + create: { userId: loserId, playerName: loserName, losses: 1 }, + }), + ]); + } + } + + // Delete community post when game ends + if ((winner || isDraw) && challenge.postId) { + await db.communityPost.delete({ where: { id: challenge.postId } }).catch(() => {}); + } + + return updated; +}); diff --git a/backend/server/api/games/challenge/[id]/rematch.post.ts b/backend/server/api/games/challenge/[id]/rematch.post.ts new file mode 100644 index 0000000..a76a0ac --- /dev/null +++ b/backend/server/api/games/challenge/[id]/rematch.post.ts @@ -0,0 +1,64 @@ +import { usePrisma } from "../../../../utils/prisma"; +import { getProfile } from "../../../../db/profile"; + +const MEMORY_EMOJIS = ['🛡️', '💪', '🌟', '🧠', '🌊', '🎯', '🌱', '🔑']; +function shuffle(arr: T[]): T[] { + const a = [...arr]; + for (let i = a.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + const tmp = a[i]!; a[i] = a[j]!; a[j] = tmp; + } + return a; +} + +export default defineEventHandler(async (event) => { + const user = await requireUser(event); + const id = getRouterParam(event, "id"); + if (!id) throw createError({ statusCode: 400, message: "id fehlt" }); + + const db = usePrisma(); + const old = await db.gameChallenge.findUnique({ where: { id } }); + + if (!old) throw createError({ statusCode: 404, message: "Spiel nicht gefunden" }); + if (old.status !== "FINISHED" && old.status !== "CANCELLED") { + throw createError({ statusCode: 409, message: "Spiel noch nicht beendet" }); + } + + const isChallenger = user.id === old.challengerId; + const isOpponent = user.id === old.opponentId; + if (!isChallenger && !isOpponent) { + throw createError({ statusCode: 403, message: "Nicht autorisiert" }); + } + + const opponentId = isChallenger ? old.opponentId : old.challengerId; + const opponentName = isChallenger ? old.opponentName : old.challengerName; + if (!opponentId || !opponentName) { + throw createError({ statusCode: 409, message: "Kein Gegner vorhanden" }); + } + + const profile = await getProfile(user.id); + const myName = profile?.nickname || profile?.username || "Anonym"; + + const isMemory = old.gameType === "memory"; + const memoryState = isMemory ? { + cards: shuffle([...MEMORY_EMOJIS, ...MEMORY_EMOJIS]).map((emoji: string, id: number) => ({ id, emoji, matchedBy: null })), + flipped: [], + scores: { X: 0, O: 0 }, + mismatchRevealed: false, + } : undefined; + + // Create rematch challenge with opponent pre-set and directly ACTIVE + const rematch = await db.gameChallenge.create({ + data: { + challengerId: user.id, + challengerName: myName, + opponentId, + opponentName, + status: "ACTIVE", + gameType: old.gameType, + ...(memoryState && { memoryState }), + }, + }); + + return { challengeId: rematch.id }; +}); diff --git a/backend/server/api/games/highscore.get.ts b/backend/server/api/games/highscore.get.ts new file mode 100644 index 0000000..29c11ea --- /dev/null +++ b/backend/server/api/games/highscore.get.ts @@ -0,0 +1,39 @@ +/** + * GET /api/games/highscore?gameName= + * + * Liefert den Personal-Best-Score des aktuellen Users für ein bestimmtes Spiel. + * Wird bei SOS-Spielstart gefetcht, damit Lyra dem User seinen PB nennen kann + * ("Dein bester Score ist X — ich glaub an dich"). + * + * Response: + * { score: number, hasRecord: boolean, updatedAt: string | null } + * + * Wenn kein Eintrag existiert: score=0, hasRecord=false. Lyra-Hint behandelt + * dann den Erst-Spielen-Fall ("Probier's, lass uns deinen ersten Score setzen!"). + */ +import { usePrisma } from "../../utils/prisma"; + +export default defineEventHandler(async (event) => { + const user = await requireUser(event); + const query = getQuery(event); + const gameName = String(query.gameName ?? "").trim(); + + if (!gameName) { + throw createError({ + statusCode: 400, + message: "gameName query param required", + }); + } + + const db = usePrisma(); + const hs = await db.gameHighScore.findUnique({ + where: { userId_gameName: { userId: user.id, gameName } }, + select: { score: true, updatedAt: true }, + }); + + return { + score: hs?.score ?? 0, + hasRecord: !!hs, + updatedAt: hs?.updatedAt?.toISOString() ?? null, + }; +}); diff --git a/backend/server/api/games/history.get.ts b/backend/server/api/games/history.get.ts new file mode 100644 index 0000000..b58f2d2 --- /dev/null +++ b/backend/server/api/games/history.get.ts @@ -0,0 +1,44 @@ +import { usePrisma } from "../../utils/prisma"; + +export default defineEventHandler(async (event) => { + const user = await requireUser(event); + const query = getQuery(event); + const opponentId = query.opponentId as string | undefined; + const limit = Math.min(Number(query.limit) || 10, 50); + + const db = usePrisma(); + + const where = opponentId + ? { + OR: [ + { challengerId: user.id, opponentId }, + { challengerId: opponentId, opponentId: user.id }, + ], + status: "FINISHED" as const, + } + : { + OR: [ + { challengerId: user.id }, + { opponentId: user.id }, + ], + status: "FINISHED" as const, + }; + + const games = await db.gameChallenge.findMany({ + where, + orderBy: { updatedAt: "desc" }, + take: limit, + select: { + id: true, + challengerId: true, + challengerName: true, + opponentId: true, + opponentName: true, + winner: true, + createdAt: true, + updatedAt: true, + }, + }); + + return games; +}); diff --git a/backend/server/api/games/leaderboard.get.ts b/backend/server/api/games/leaderboard.get.ts new file mode 100644 index 0000000..0fdab7b --- /dev/null +++ b/backend/server/api/games/leaderboard.get.ts @@ -0,0 +1,60 @@ +import { usePrisma } from "../../utils/prisma"; + +export default defineEventHandler(async (event) => { + setResponseHeaders(event, { "Content-Type": "application/json" }); + + const user = await requireUser(event); + const query = getQuery(event); + const gameName = String(query.game || "").toLowerCase(); + const limit = Math.min(Number(query.limit) || 10, 50); + + if (!gameName) { + throw createError({ + statusCode: 400, + message: "game parameter erforderlich", + }); + } + + try { + const db = usePrisma(); + + const scores = await db.gameHighScore.findMany({ + where: { gameName }, + orderBy: { score: "desc" }, + take: limit, + select: { userId: true, nickname: true, score: true, updatedAt: true }, + }); + + const myEntry = scores.find((s) => s.userId === user.id); + let myRank = myEntry ? scores.indexOf(myEntry) + 1 : null; + + if (!myEntry) { + const myScore = await db.gameHighScore.findUnique({ + where: { userId_gameName: { userId: user.id, gameName } }, + }); + if (myScore) { + const above = await db.gameHighScore.count({ + where: { gameName, score: { gt: myScore.score } }, + }); + myRank = above + 1; + } + } + + return { + leaderboard: scores.map((s, i) => ({ + rank: i + 1, + nickname: s.nickname, + score: s.score, + isMe: s.userId === user.id, + updatedAt: s.updatedAt, + })), + myRank, + myScore: myEntry?.score ?? null, + }; + } catch (err: any) { + throw createError({ + statusCode: 500, + message: err?.message ?? "Interner Fehler", + }); + } +}); diff --git a/backend/server/api/games/ranking.get.ts b/backend/server/api/games/ranking.get.ts new file mode 100644 index 0000000..1c76dbe --- /dev/null +++ b/backend/server/api/games/ranking.get.ts @@ -0,0 +1,15 @@ +import { usePrisma } from "../../utils/prisma"; + +export default defineEventHandler(async (event) => { + await requireUser(event); + const query = getQuery(event); + const limit = Math.min(Number(query.limit) || 20, 50); + + const db = usePrisma(); + const scores = await db.gameScore.findMany({ + orderBy: [{ points: "desc" }, { wins: "desc" }], + take: limit, + }); + + return scores; +}); diff --git a/backend/server/api/games/rating.post.ts b/backend/server/api/games/rating.post.ts new file mode 100644 index 0000000..571d844 --- /dev/null +++ b/backend/server/api/games/rating.post.ts @@ -0,0 +1,25 @@ +import { usePrisma } from "../../utils/prisma"; + +export default defineEventHandler(async (event) => { + const user = await requireUser(event); + const body = await readBody(event); + + const { gameName, stars, feedback, score } = body; + + if (!gameName || typeof stars !== "number" || stars < 1 || stars > 5) { + throw createError({ statusCode: 400, message: "Invalid rating data" }); + } + + const db = usePrisma(); + const rating = await db.gameRating.create({ + data: { + userId: user.id, + gameName: String(gameName).toLowerCase().slice(0, 50), + stars, + feedback: feedback ? String(feedback).slice(0, 500) : null, + score: typeof score === "number" ? score : 0, + }, + }); + + return { id: rating.id }; +}); diff --git a/backend/server/api/games/ratings.get.ts b/backend/server/api/games/ratings.get.ts new file mode 100644 index 0000000..743d5c9 --- /dev/null +++ b/backend/server/api/games/ratings.get.ts @@ -0,0 +1,68 @@ +import { usePrisma } from "../../utils/prisma"; + +export default defineEventHandler(async (event) => { + const query = getQuery(event); + const gameName = String(query.game || "").toLowerCase(); + + const db = usePrisma(); + + const where = gameName ? { gameName } : {}; + + const [ratings, grouped] = await Promise.all([ + db.gameRating.findMany({ + where, + orderBy: { createdAt: "desc" }, + take: 50, + select: { + id: true, + userId: true, + gameName: true, + stars: true, + feedback: true, + score: true, + createdAt: true, + }, + }), + db.gameRating.groupBy({ + by: ["gameName"], + where, + _avg: { stars: true }, + _count: { id: true }, + }), + ]); + + // Profil-Daten für alle User laden + const userIds = [...new Set(ratings.map((r) => r.userId))]; + const profiles = + userIds.length > 0 + ? await db.profile.findMany({ + where: { id: { in: userIds } }, + select: { id: true, nickname: true, username: true, avatar: true }, + }) + : []; + const profileMap = Object.fromEntries(profiles.map((p) => [p.id, p])); + + const ratingsWithUser = ratings.map((r) => { + const profile = profileMap[r.userId]; + return { + id: r.id, + gameName: r.gameName, + stars: r.stars, + feedback: r.feedback, + score: r.score, + createdAt: r.createdAt, + user: { + nickname: profile?.nickname || profile?.username || "Anonym", + avatar: profile?.avatar ?? null, + }, + }; + }); + + const stats = grouped.map((g) => ({ + gameName: g.gameName, + avgStars: Math.round((g._avg.stars ?? 0) * 10) / 10, + count: g._count.id, + })); + + return { ratings: ratingsWithUser, stats }; +}); diff --git a/backend/server/api/games/score.post.ts b/backend/server/api/games/score.post.ts new file mode 100644 index 0000000..e8568f7 --- /dev/null +++ b/backend/server/api/games/score.post.ts @@ -0,0 +1,37 @@ +import { usePrisma } from "../../utils/prisma"; + +export default defineEventHandler(async (event) => { + const user = await requireUser(event); + const body = await readBody(event); + + const { gameName, score, nickname } = body; + if (!gameName || typeof score !== "number" || score < 0) { + throw createError({ + statusCode: 400, + message: "gameName und score erforderlich", + }); + } + + const db = usePrisma(); + const name = String( + nickname || + user.user_metadata?.nickname || + user.email?.split("@")[0] || + "Anonym", + ).slice(0, 30); + + // Nur updaten wenn neuer Score besser ist + const existing = await db.gameHighScore.findUnique({ + where: { userId_gameName: { userId: user.id, gameName } }, + }); + + if (!existing || score > existing.score) { + await db.gameHighScore.upsert({ + where: { userId_gameName: { userId: user.id, gameName } }, + create: { userId: user.id, nickname: name, gameName, score }, + update: { score, nickname: name }, + }); + } + + return { ok: true, isNewBest: !existing || score > existing.score }; +}); diff --git a/backend/server/api/games/share-text.post.ts b/backend/server/api/games/share-text.post.ts new file mode 100644 index 0000000..1518216 --- /dev/null +++ b/backend/server/api/games/share-text.post.ts @@ -0,0 +1,123 @@ +// Game-spezifischer Kontext für den Prompt +const GAME_VIBES: Record = { + snake: "Snake – präzise, fließend, ein Moment der Kontrolle", + tetris: "Tetris – logisch, fokussiert, Gedanken ordnen statt Chaos", + memory: "Memory – Geduld, Konzentration, im Hier und Jetzt bleiben", + tictactoe: "Tic-Tac-Toe – ruhig, überlegt, kleine Strategie", +}; + +function getGameVibe(gameName: string): string { + const key = gameName.toLowerCase(); + for (const [k, v] of Object.entries(GAME_VIBES)) { + if (key.includes(k)) return v; + } + return `${gameName} – ein Moment bewusster Ablenkung`; +} + +function buildFallback( + isNewRecord: boolean, + myRank: number | null, + mode: string, +): string { + const parts: string[] = []; + if (isNewRecord) parts.push("Neuer persönlicher Rekord! 🏆"); + else if (myRank) parts.push(`Rang #${myRank} in der Rangliste`); + const challenge = + mode === "impulse" + ? "Impuls überwunden – kannst du länger standhalten? 💪" + : "Kannst du das schlagen? 👊"; + parts.push(challenge); + return parts.join("\n"); +} + +const SYSTEM_PROMPT = `Du generierst kurze Share-Texte (max 2–3 Zeilen) für die ReBreak-App. +ReBreak hilft Menschen bei Glücksspielsucht, indem sie Mini-Games spielen statt zu gamble. +Der Text wird im Community-Feed unter dem Spielnamen gepostet – er soll den Vibe des jeweiligen Spiels widerspiegeln. + +Regeln: +- Zeile 1 (optional): Nur wenn neuer Rekord ODER guter Rang, eine kurze Meldung dazu +- Letzte Zeile: Kurzer, kreativer Challenge-Satz passend zum Spiel auf Deutsch +- 1–2 Emojis, passend zum Spiel und Ton +- KEIN Spielname, KEINE Score-Zahl im Text +- Keine Hashtags, keine URLs +- Antworte NUR mit dem Text, kein Prefix, keine Anführungszeichen`; + +/** POST /api/games/share-text */ +export default defineEventHandler(async (event) => { + await requireUser(event); + + const body = await readBody(event); + const { + gameName, + score, + scoreLabel = "Punkte", + bestScore, + myRank, + isNewRecord, + mode = "game", + } = body as { + gameName: string; + score: number; + scoreLabel?: string; + bestScore?: number; + myRank?: number | null; + isNewRecord?: boolean; + mode?: string; + }; + + if (!gameName || score == null) { + throw createError({ + statusCode: 400, + message: "gameName und score erforderlich", + }); + } + + const config = useRuntimeConfig(); + + if (!config.groqApiKey) { + return { + text: buildFallback(!!isNewRecord, myRank ?? null, mode), + }; + } + + const gameVibe = getGameVibe(gameName); + const userPrompt = [ + `Spiel-Vibe: ${gameVibe}`, + isNewRecord ? "NEUER PERSÖNLICHER REKORD!" : null, + myRank ? `Rang #${myRank} in der Rangliste` : null, + mode === "impulse" + ? "Der Spieler hat einen Spielimpuls damit überwunden." + : null, + ] + .filter(Boolean) + .join("\n"); + + try { + const response = await $fetch<{ + choices: { message: { content: string } }[]; + }>("https://api.groq.com/openai/v1/chat/completions", { + method: "POST", + headers: { + Authorization: `Bearer ${config.groqApiKey}`, + "Content-Type": "application/json", + }, + body: { + model: "llama-3.1-8b-instant", + max_tokens: 120, + messages: [ + { role: "system", content: SYSTEM_PROMPT }, + { role: "user", content: userPrompt }, + ], + }, + }); + + const text = response.choices?.[0]?.message?.content?.trim(); + if (!text) throw new Error("empty response"); + + return { text }; + } catch { + return { + text: buildFallback(!!isNewRecord, myRank ?? null, mode), + }; + } +}); diff --git a/backend/server/api/lyra/memories/extract.post.ts b/backend/server/api/lyra/memories/extract.post.ts new file mode 100644 index 0000000..a1bcc78 --- /dev/null +++ b/backend/server/api/lyra/memories/extract.post.ts @@ -0,0 +1,168 @@ +/** + * POST /api/lyra/memories/extract + * + * Extrahiert strukturierte Memories aus einem SOS/Coach-Gespräch via LLM (Claude Haiku). + * Wird intern (fire-and-forget) nach SOS-Stream-Ende aufgerufen. + * + * Body: { sessionId: string, conversation: Array<{role, content}> } + * Response: { extracted: number, skipped: number } + * + * Fehler: silent — User darf nichts merken. Logging via [lyra-memory]. + */ +import { upsertMemory } from "../../../db/lyraMemory"; +import type { LyraMemoryType } from "../../../db/lyraMemory"; + +const VALID_TYPES: LyraMemoryType[] = [ + "trigger", + "habit", + "strength", + "relationship", + "milestone", + "pain_point", + "goal", + "preference", +]; + +const EXTRACTION_SYSTEM_PROMPT = `Du extrahierst aus einem Gespräch zwischen User und Lyra-Coach strukturierte Fakten über den User. Output strikt als JSON-Array: +[{"type":"trigger|habit|strength|relationship|milestone|pain_point|goal|preference", "content":"", "confidence":0.0-1.0}] +Regeln: +- Nur Fakten die der USER explizit oder implizit über sich geteilt hat. Nichts erfinden. +- Keine Vermutungen über Diagnosen oder Pathologisierungen ("süchtig", "krank" etc). +- Nur Wesentliches. Wenn nichts Neues drin ist → leeres Array []. +- confidence: 0.9+ wenn User explizit gesagt, 0.5-0.7 wenn implizit, <0.5 garnicht extrahieren. +- content in der Sprache des Gesprächs (DE). +- Maximal 8 Einträge pro Extraktion.`; + +export default defineEventHandler(async (event) => { + const user = await requireUser(event); + + const body = await readBody(event); + const { + sessionId, + conversation, + }: { + sessionId?: string; + conversation: Array<{ role: string; content: string }>; + } = body; + + if (!Array.isArray(conversation) || conversation.length === 0) { + throw createError({ statusCode: 400, message: "conversation fehlt" }); + } + + const config = useRuntimeConfig(); + const key = config.openrouterApiKey as string | undefined; + if (!key) { + throw createError({ statusCode: 503, message: "OpenRouter Key fehlt" }); + } + + // Nur User-Nachrichten extrahieren (Lyra-Antworten sind Kontext, kein User-Fakt) + const userMessages = conversation.filter((m) => m.role === "user"); + if (userMessages.length === 0) { + return { extracted: 0, skipped: 0 }; + } + + // Konversation als lesbaren Text aufbereiten (max 4000 chars für Haiku) + const conversationText = conversation + .slice(-20) // letzte 20 Messages reichen für Kontext + .map((m) => `${m.role === "user" ? "User" : "Lyra"}: ${m.content}`) + .join("\n") + .slice(0, 4000); + + let extracted = 0; + let skipped = 0; + + try { + const res = await $fetch<{ + choices: { message: { content: string } }[]; + }>("https://openrouter.ai/api/v1/chat/completions", { + method: "POST", + headers: { + Authorization: `Bearer ${key}`, + "Content-Type": "application/json", + "HTTP-Referer": "https://rebreak.org", + "X-Title": "ReBreak Memory Extraction", + }, + body: { + model: "anthropic/claude-haiku-4-5", + max_tokens: 800, + temperature: 0.1, + messages: [ + { role: "system", content: EXTRACTION_SYSTEM_PROMPT }, + { role: "user", content: conversationText }, + ], + }, + timeout: 20000, + }); + + const raw = res.choices?.[0]?.message?.content?.trim(); + if (!raw) { + console.log("[lyra-memory] extract: empty response from LLM"); + return { extracted: 0, skipped: 0 }; + } + + // JSON-Array parsen (Haiku gibt manchmal Markdown-Fences zurück) + const jsonStr = raw + .replace(/^```(?:json)?\s*/i, "") + .replace(/\s*```$/, "") + .trim(); + + let facts: Array<{ + type: string; + content: string; + confidence: number; + }> = []; + + try { + facts = JSON.parse(jsonStr); + } catch { + console.warn( + "[lyra-memory] extract: JSON parse failed:", + jsonStr.slice(0, 200), + ); + return { extracted: 0, skipped: 0 }; + } + + if (!Array.isArray(facts)) { + return { extracted: 0, skipped: 0 }; + } + + for (const fact of facts) { + // Confidence < 0.5 → skip + if (!fact.content || (fact.confidence ?? 0) < 0.5) { + skipped++; + continue; + } + + // Typ validieren + if (!VALID_TYPES.includes(fact.type as LyraMemoryType)) { + console.warn("[lyra-memory] extract: invalid type:", fact.type); + skipped++; + continue; + } + + try { + await upsertMemory( + user.id, + fact.type as LyraMemoryType, + fact.content, + sessionId ?? "sos-session", + Math.min(1, Math.max(0, fact.confidence ?? 0.7)), + ); + extracted++; + } catch (e) { + console.error("[lyra-memory] upsert error:", e); + skipped++; + } + } + + console.log( + `[lyra-memory] extract done for ${user.id}: ${extracted} extracted, ${skipped} skipped`, + ); + } catch (e) { + // Silent fail — User darf nichts merken + console.error("[lyra-memory] extract LLM error:", e); + return { extracted: 0, skipped: 0 }; + } + + return { success: true, data: { extracted, skipped } }; +}); diff --git a/backend/server/api/lyra/welcome-back.get.ts b/backend/server/api/lyra/welcome-back.get.ts new file mode 100644 index 0000000..dcd4284 --- /dev/null +++ b/backend/server/api/lyra/welcome-back.get.ts @@ -0,0 +1,98 @@ +/** + * GET /api/lyra/welcome-back + * + * Generiert einen kurzen, freundlichen Motivations-Text wenn der User nach + * einer Schutz-Deaktivierung den Schutz wieder aktiviert hat. + * + * Pattern: erst LLM-Call (OpenRouter falls Key da, sonst OpenAI), bei Fehler + * fällt's auf einen statischen Pool von vorgeschriebenen Sätzen zurück — + * UX-Fallback statt User-facing-Error. + * + * Response: { message: string, source: "ai" | "fallback" } + */ + +const WELCOME_BACK_PROMPT = `Du bist Lyra, der KI-Coach der Rebreak-App. +Der Nutzer hat seinen Glücksspiel-Schutz kurz deaktiviert und gerade wieder aktiviert. +Schreibe ihm GENAU EINE freundliche, warme Nachricht (max 3 kurze Sätze, Deutsch, du-Form). + +WICHTIG: +- Kein Urteil, keine Belehrung +- Kein Schuldgefühl, kein "endlich" +- Anerkennung dass Rückfälle/Ausrutscher zur Recovery gehören +- Stärke betonen dass er WIEDER aktiviert hat +- Kein Wort "Sucht", "süchtig", "Rückfall" — stattdessen: "Phase", "Moment", "Stärke" + +Antwort: nur die Nachricht, keine Anführungszeichen, kein Kontext.`; + +const FALLBACK_MESSAGES = [ + "Schön dass du wieder da bist. Diese eine Geste — den Schutz wieder einzuschalten — zeigt mehr Stärke als die meisten je sehen werden. Ich bin stolz auf dich.", + "Hey. Ausrutscher gehören zum Weg. Was zählt ist dass du jetzt hier bist, mit dem Schutz an. Das ist die wichtige Entscheidung.", + "Willkommen zurück. Du hast eine Pause genommen — und bist jetzt wieder hier. Genau so läuft Recovery: nicht linear, sondern echt. Lass uns weitermachen.", + "Gut dich wieder zu sehen. Manchmal braucht es einen kurzen Umweg um den eigenen Weg klarer zu sehen. Du gehst ihn weiter — das ist das Wichtige.", + "Hey, alles gut. Du hast den Schutz wieder aktiv — das war eine bewusste Entscheidung gegen den Impuls. Genau das ist der Muskel den wir hier trainieren.", +]; + +export default defineEventHandler(async (event) => { + await requireUser(event); + + const config = useRuntimeConfig(event); + const openrouterKey = config.openrouterApiKey as string | undefined; + const openaiKey = config.openaiApiKey as string | undefined; + const key = openrouterKey ?? openaiKey; + + // Fallback wenn keine Keys konfiguriert + if (!key) { + return { + message: pickRandom(FALLBACK_MESSAGES), + source: "fallback" as const, + }; + } + + const isOpenRouter = !!openrouterKey; + const url = isOpenRouter + ? "https://openrouter.ai/api/v1/chat/completions" + : "https://api.openai.com/v1/chat/completions"; + const model = isOpenRouter + ? "meta-llama/llama-3.1-8b-instruct" + : "gpt-4o-mini"; + + try { + const res = await $fetch<{ choices: { message: { content: string } }[] }>( + url, + { + method: "POST", + headers: { + Authorization: `Bearer ${key}`, + "Content-Type": "application/json", + ...(isOpenRouter && { + "HTTP-Referer": "https://rebreak.org", + "X-Title": "ReBreak Welcome-Back", + }), + }, + body: { + model, + max_tokens: 200, + temperature: 0.85, + messages: [ + { role: "system", content: WELCOME_BACK_PROMPT }, + { role: "user", content: "Schreibe mir die Welcome-Back-Nachricht." }, + ], + }, + timeout: 6000, + }, + ); + + const text = res.choices?.[0]?.message?.content?.trim(); + if (text && text.length > 10 && text.length < 600) { + return { message: text, source: "ai" as const }; + } + return { message: pickRandom(FALLBACK_MESSAGES), source: "fallback" as const }; + } catch (e: any) { + console.warn("[lyra.welcome-back] LLM-call failed:", e?.message ?? e); + return { message: pickRandom(FALLBACK_MESSAGES), source: "fallback" as const }; + } +}); + +function pickRandom(arr: readonly T[]): T { + return arr[Math.floor(Math.random() * arr.length)]; +} diff --git a/backend/server/api/mail/connect.post.ts b/backend/server/api/mail/connect.post.ts new file mode 100644 index 0000000..5f0a57f --- /dev/null +++ b/backend/server/api/mail/connect.post.ts @@ -0,0 +1,99 @@ +import { ImapFlow } from "imapflow"; +import { getProfile } from "../../db/profile"; +import { getPlanLimits } from "../../utils/plan-features"; +import { countMailConnections, upsertMailConnection } from "../../db/mail"; + +/** + * POST /api/mail/connect + * Body: { email, password } + * Testet IMAP-Verbindung und speichert Credentials verschlüsselt. + */ +export default defineEventHandler(async (event) => { + const user = await requireUser(event); + const { + email, + password, + // Custom-IMAP-Felder (optional, nur wenn User eigenen Server konfiguriert) + imapHost: customImapHost, + imapPort: customImapPort, + useTls, + rejectUnauthorized, + } = await readBody(event); + + if (!email || !password) { + throw createError({ + statusCode: 400, + message: "Email und Passwort erforderlich", + }); + } + + // Plan-Limit prüfen + const profile = await getProfile(user.id); + const limits = getPlanLimits(profile?.plan ?? "free"); + + if (limits.mailAgents !== Infinity) { + const count = await countMailConnections(user.id); + if (count >= limits.mailAgents) { + throw createError({ + statusCode: 403, + message: `Dein Plan erlaubt maximal ${limits.mailAgents} Mail-Agent${limits.mailAgents !== 1 ? "en" : ""}`, + }); + } + } + + // Custom-IMAP: wenn imapHost explizit gesetzt → Provider-Detection überspringen. + // Sonst: automatisch via Email-Domain erkennen. + const provider = detectImapProvider(email); + const resolvedHost = customImapHost?.trim() || provider.host; + const resolvedPort = customImapPort ?? provider.port; + + // TLS-Konfiguration ableiten + // useTls=false → STARTTLS (secure=false, requireTLS=true bei ImapFlow) + // useTls=true oder nicht gesetzt → implicit TLS (secure=true) + const useImplicitTls = useTls !== false; // default: true + const tlsRejectUnauthorized = rejectUnauthorized !== false; // default: true + // STARTTLS nur wenn explizit angefordert (useTls === false) + const useStarttls = useTls === false; + + // IMAP-Verbindung testen + const client = new ImapFlow({ + host: resolvedHost, + port: resolvedPort, + secure: useImplicitTls, + ...(useStarttls ? { requireTLS: true } : {}), + auth: { user: email, pass: password }, + logger: false, + tls: { rejectUnauthorized: tlsRejectUnauthorized }, + }); + + try { + await client.connect(); + await client.logout(); + } catch (err: any) { + throw createError({ + statusCode: 401, + message: `Verbindung fehlgeschlagen: ${err.message ?? "Ungültige Zugangsdaten"}`, + }); + } + + // Credentials verschlüsselt speichern + await upsertMailConnection({ + userId: user.id, + email, + provider: "imap", + // Bei Custom-Host: Host als providerName, sonst auto-erkannter Name + providerName: customImapHost ? resolvedHost : provider.name, + imapHost: resolvedHost, + imapPort: resolvedPort, + passwordEncrypted: encrypt(password), + rejectUnauthorized: tlsRejectUnauthorized, + useStarttls, + }); + + return { + connected: true, + email, + provider: customImapHost ? resolvedHost : provider.name, + custom: !!customImapHost, + }; +}); diff --git a/backend/server/api/mail/disconnect.delete.ts b/backend/server/api/mail/disconnect.delete.ts new file mode 100644 index 0000000..8dec6da --- /dev/null +++ b/backend/server/api/mail/disconnect.delete.ts @@ -0,0 +1,19 @@ +import { deleteMailConnection, deleteAllMailConnections } from "../../db/mail"; + +/** + * DELETE /api/mail/disconnect + * Trennt das Gmail-Konto (löscht Connection und alle Logs). + */ +export default defineEventHandler(async (event) => { + const user = await requireUser(event); + + const { connectionId } = await readBody(event).catch(() => ({})); + + if (connectionId) { + await deleteMailConnection(user.id, connectionId); + } else { + await deleteAllMailConnections(user.id); + } + + return { ok: true }; +}); diff --git a/backend/server/api/mail/interval.patch.ts b/backend/server/api/mail/interval.patch.ts new file mode 100644 index 0000000..752b495 --- /dev/null +++ b/backend/server/api/mail/interval.patch.ts @@ -0,0 +1,38 @@ +import { getProfile } from "../../db/profile"; +import { getPlanLimits } from "../../utils/plan-features"; +import { updateMailConnectionInterval } from "../../db/mail"; + +/** + * PATCH /api/mail/interval + * Body: { connectionId, interval: 1 | 8 | 24 } + * Setzt das Scan-Intervall für eine Mail-Verbindung. + */ +export default defineEventHandler(async (event) => { + const user = await requireUser(event); + const { connectionId, interval } = (await readBody(event)) as { + connectionId: string; + interval: number; + }; + + if (!connectionId || !Number.isInteger(interval) || interval < 1) { + throw createError({ + statusCode: 400, + message: "connectionId und interval erforderlich", + }); + } + + // Plan-Limit: erlaubte Intervalle prüfen + const profile = await getProfile(user.id); + const limits = getPlanLimits(profile?.plan ?? "free"); + + if (!limits.mailIntervalOptions.includes(interval)) { + throw createError({ + statusCode: 403, + message: `Dein Plan erlaubt nur folgende Intervalle (Stunden): ${limits.mailIntervalOptions.join(", ")}`, + }); + } + + await updateMailConnectionInterval(user.id, connectionId, interval); + + return { ok: true, interval }; +}); diff --git a/backend/server/api/mail/proxy-account.get.ts b/backend/server/api/mail/proxy-account.get.ts new file mode 100644 index 0000000..02ba926 --- /dev/null +++ b/backend/server/api/mail/proxy-account.get.ts @@ -0,0 +1,27 @@ +import { getImapProxyAccounts, getMailConnections } from "../../db/mail"; + +export default defineEventHandler(async (event) => { + const user = await requireUser(event); + + const [accounts, connections] = await Promise.all([ + getImapProxyAccounts(user.id), + getMailConnections(user.id), + ]); + + if (accounts.length === 0) { + return { configured: false, accounts: [] }; + } + + // Enrich with real email address from connection + const connMap = new Map(connections.map((c) => [c.id, c.email])); + + return { + configured: true, + host: "imap.rebreak.org", + port: 993, + accounts: accounts.map((a) => ({ + username: a.proxyUsername, + realEmail: connMap.get(a.connectionId) ?? null, + })), + }; +}); diff --git a/backend/server/api/mail/proxy-account.post.ts b/backend/server/api/mail/proxy-account.post.ts new file mode 100644 index 0000000..37bd547 --- /dev/null +++ b/backend/server/api/mail/proxy-account.post.ts @@ -0,0 +1,55 @@ +import { randomBytes } from "crypto"; +import { getMailConnections, upsertImapProxyAccount } from "../../db/mail"; +import { getProfile } from "../../db/profile"; +import { getPlanLimits } from "../../utils/plan-features"; +import { encrypt } from "../../utils/crypto"; +import { detectSmtpProvider } from "../../utils/imap-providers"; + +export default defineEventHandler(async (event) => { + const user = await requireUser(event); + + const profile = await getProfile(user.id); + const limits = getPlanLimits(profile?.plan ?? "free"); + if (limits.mailAgents !== Infinity) { + throw createError({ statusCode: 403, message: "Nur für Legend-User verfügbar" }); + } + + const connections = await getMailConnections(user.id); + if (connections.length === 0) { + throw createError({ + statusCode: 400, + message: "Erst eine E-Mail-Verbindung herstellen", + }); + } + + const results = await Promise.all( + connections.map(async (connection) => { + const plainPassword = randomBytes(12).toString("base64url"); + const localPart = connection.email.replace("@", ".").replace(/[^a-z0-9._-]/gi, "-"); + const proxyUsername = `${localPart}@imap.rebreak.org`; + + await upsertImapProxyAccount({ + userId: user.id, + proxyUsername, + proxyPassword: encrypt(plainPassword), + connectionId: connection.id, + }); + + const smtp = detectSmtpProvider(connection.imapHost); + return { + username: proxyUsername, + password: plainPassword, + realEmail: connection.email, + host: "imap.rebreak.org", + port: 993, + smtpHost: smtp.host, + smtpPort: smtp.port, + }; + }), + ); + + return { + note: "Passwörter werden nur einmal angezeigt – sofort in iOS Mail eintragen.", + accounts: results, + }; +}); diff --git a/backend/server/api/mail/proxy-config.get.ts b/backend/server/api/mail/proxy-config.get.ts new file mode 100644 index 0000000..02c5c4f --- /dev/null +++ b/backend/server/api/mail/proxy-config.get.ts @@ -0,0 +1,136 @@ +import { randomUUID } from "crypto"; +import { getImapProxyAccounts, getMailConnections } from "../../db/mail"; +import { getProfile } from "../../db/profile"; +import { getPlanLimits } from "../../utils/plan-features"; +import { detectSmtpProvider } from "../../utils/imap-providers"; +import { decrypt } from "../../utils/crypto"; + +function escapeXml(s: string): string { + return s + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +export default defineEventHandler(async (event) => { + const user = await requireUser(event); + + const profile = await getProfile(user.id); + const limits = getPlanLimits(profile?.plan ?? "free"); + if (limits.mailAgents !== Infinity) { + throw createError({ statusCode: 403, message: "Nur für Legend-User verfügbar" }); + } + + const [accounts, connections] = await Promise.all([ + getImapProxyAccounts(user.id), + getMailConnections(user.id), + ]); + + if (accounts.length === 0) { + throw createError({ statusCode: 404, message: "Noch keine Proxy-Konten eingerichtet" }); + } + + const connMap = new Map(connections.map((c) => [c.id, c])); + const payloads: string[] = []; + + for (const account of accounts) { + const conn = connMap.get(account.connectionId); + if (!conn) continue; + + let proxyPassword: string; + try { + proxyPassword = decrypt(account.proxyPassword); + } catch { + continue; // Legacy scrypt hash – skip, user needs to re-generate via proxy-account.post + } + + let realPassword = ""; + try { + realPassword = decrypt(conn.passwordEncrypted); + } catch { /* SMTP password omitted */ } + + const smtp = detectSmtpProvider(conn.imapHost); + + payloads.push(` + EmailAccountDescription + ReBreak Filter – ${escapeXml(conn.email)} + EmailAccountName + ${escapeXml(conn.email)} + EmailAccountType + EmailTypeIMAP + EmailAddress + ${escapeXml(conn.email)} + IncomingMailServerHostName + imap.rebreak.org + IncomingMailServerPortNumber + 993 + IncomingMailServerUseSSL + + IncomingMailServerUsername + ${escapeXml(account.proxyUsername)} + IncomingMailServerPassword + ${escapeXml(proxyPassword)} + OutgoingMailServerHostName + ${escapeXml(smtp.host)} + OutgoingMailServerPortNumber + ${smtp.port} + OutgoingMailServerUseSSL + + OutgoingMailServerUsername + ${escapeXml(conn.email)}${realPassword ? ` + OutgoingMailServerPassword + ${escapeXml(realPassword)}` : ""} + SMIMEEnabled + + PayloadDescription + Mail-Filter für ${escapeXml(conn.email)} + PayloadDisplayName + ReBreak – ${escapeXml(conn.email)} + PayloadIdentifier + org.rebreak.mail.${conn.id} + PayloadType + com.apple.mail.managed + PayloadUUID + ${randomUUID()} + PayloadVersion + 1 + `); + } + + if (payloads.length === 0) { + throw createError({ statusCode: 400, message: "Proxy-Konten müssen zuerst neu erstellt werden" }); + } + + const plist = ` + + + + PayloadContent + + ${payloads.join("\n ")} + + PayloadDescription + Blockiert Casino-Mails automatisch bevor sie in deinen Posteingang gelangen + PayloadDisplayName + ReBreak Mail Filter + PayloadIdentifier + org.rebreak.mail.profile.${user.id} + PayloadOrganization + ReBreak + PayloadRemovalDisallowed + + PayloadType + Configuration + PayloadUUID + ${randomUUID()} + PayloadVersion + 1 + +`; + + setHeader(event, "Content-Type", "application/x-apple-aspen-config"); + setHeader(event, "Content-Disposition", 'attachment; filename="rebreak-mail-filter.mobileconfig"'); + return plist; +}); diff --git a/backend/server/api/mail/results.get.ts b/backend/server/api/mail/results.get.ts new file mode 100644 index 0000000..2491662 --- /dev/null +++ b/backend/server/api/mail/results.get.ts @@ -0,0 +1,16 @@ +import { deleteOldMailBlocked, getMailBlockedPaginated } from "../../db/mail"; + +/** + * GET /api/mail/results + * Gibt die letzten blockierten Gambling-Mails zurück (paginiert). + */ +export default defineEventHandler(async (event) => { + const user = await requireUser(event); + + const query = getQuery(event); + const page = Math.max(1, parseInt((query.page as string) || "1")); + + await deleteOldMailBlocked(user.id); + + return getMailBlockedPaginated(user.id, page); +}); diff --git a/backend/server/api/mail/scan-internal.post.ts b/backend/server/api/mail/scan-internal.post.ts new file mode 100644 index 0000000..1006381 --- /dev/null +++ b/backend/server/api/mail/scan-internal.post.ts @@ -0,0 +1,205 @@ +import { ImapFlow } from "imapflow"; +import { + getMailConnections, + deleteOldMailBlocked, + getAlreadyBlockedUidSet, + insertMailBlocked, + updateMailConnectionScanStats, +} from "../../db/mail"; +import { getBlocklistedDomainsSet } from "../../db/domains"; +import { getProfile } from "../../db/profile"; +import { getPlanLimits } from "../../utils/plan-features"; +// Single-Source-of-Truth (Mo's Finding #4) +// @ts-expect-error — .mjs ohne types, GAMBLING_KEYWORDS ist string[] +import { GAMBLING_KEYWORDS } from "../../utils/gambling-keywords.mjs"; + + +/** + * POST /api/mail/scan-internal + * Called by cron or IMAP proxy. Scans ALL mailbox folders. + * Free: only custom domains + keywords. Pro/Legend: global blocklist + custom. + */ +export default defineEventHandler(async (event) => { + const secret = getHeader(event, "x-admin-secret"); + const adminSecret = process.env.NUXT_ADMIN_SECRET || process.env.ADMIN_SECRET; + if (!secret || !adminSecret || secret !== adminSecret) { + throw createError({ statusCode: 401, message: "Unauthorized" }); + } + + const body = (await readBody(event)) as { userId?: string }; + const userId = body?.userId; + if (!userId) + throw createError({ statusCode: 400, message: "userId missing" }); + + const connections = await getMailConnections(userId); + if (connections.length === 0) return { scanned: 0, blocked: 0 }; + + // Plan-aware blocklist + const profile = await getProfile(userId); + const limits = getPlanLimits(profile?.plan ?? "free"); + const includeGlobal = limits.globalBlocklist; + + await deleteOldMailBlocked(userId); + + let totalScanned = 0; + let totalBlocked = 0; + + for (const connection of connections) { + let password: string; + try { + password = decrypt(connection.passwordEncrypted); + } catch { + continue; + } + + // useStarttls=true → STARTTLS (secure=false + requireTLS=true) + // rejectUnauthorized=false → self-signed Certs zulassen (nur Custom-IMAP) + const useImplicitTls = !connection.useStarttls; + const imap = new ImapFlow({ + host: connection.imapHost, + port: connection.imapPort, + secure: useImplicitTls, + ...(connection.useStarttls ? { requireTLS: true } : {}), + auth: { user: connection.email, pass: password }, + logger: false, + tls: { rejectUnauthorized: connection.rejectUnauthorized ?? true }, + }); + + let scanned = 0; + let newlyBlocked = 0; + + try { + await imap.connect(); + + // Scan ALL mailbox folders (not just hardcoded list) + const mailboxes = await imap.list(); + const scannable = mailboxes.filter( + (mb: any) => !mb.flags?.has("\\Noselect"), + ); + console.log( + `[scan-internal] ${connection.email} scanning ${scannable.length} folders`, + ); + + for (const mb of scannable) { + let lock: any; + try { + lock = await imap.getMailboxLock(mb.path); + } catch { + continue; + } + try { + const SCAN_LIMIT = 200; + const status = await imap.status(mb.path, { messages: true }); + const msgCount = (status as any).messages ?? 0; + if (msgCount === 0) continue; + + const fetchRange = + msgCount > SCAN_LIMIT ? `${msgCount - SCAN_LIMIT + 1}:*` : "1:*"; + const allMessages = await imap.fetchAll(fetchRange, { + envelope: true, + }); + scanned += allMessages.length; + totalScanned += allMessages.length; + + const allUids = allMessages.map( + (m: any) => `${mb.path}:${String(m.uid ?? m.seq)}`, + ); + const [blockedDomainSet, alreadyBlockedSet] = await Promise.all([ + getBlocklistedDomainsSet( + allMessages + .map( + (m: any) => + (m.envelope?.from?.[0]?.address ?? "") + .toLowerCase() + .split("@")[1] ?? "", + ) + .filter(Boolean), + userId, + includeGlobal, + ), + getAlreadyBlockedUidSet(allUids, userId), + ]); + + const toInsert: Parameters[0] = []; + const uidsToDelete: string[] = []; + + for (const msg of allMessages) { + const from = msg.envelope?.from?.[0]; + const senderEmail = (from?.address ?? "").toLowerCase(); + const senderName = from?.name ?? null; + const subject = (msg.envelope?.subject ?? "").trim(); + const msgDate = msg.envelope?.date ?? new Date(); + const uid = `${mb.path}:${String(msg.uid ?? msg.seq)}`; + + const haystack = `${senderEmail} ${subject}`.toLowerCase(); + const isGamblingKeyword = GAMBLING_KEYWORDS.some((kw) => + haystack.includes(kw), + ); + const senderDomain = senderEmail.split("@")[1] ?? ""; + const isBlocklisted = senderDomain + ? blockedDomainSet.has(senderDomain) + : false; + + if (!isGamblingKeyword && !isBlocklisted) continue; + if (alreadyBlockedSet.has(uid)) continue; + + uidsToDelete.push(String(msg.uid)); + toInsert.push({ + userId, + connectionId: connection.id, + gmailMessageId: uid, + senderEmail: senderEmail || "unbekannt", + senderName, + subject: subject.slice(0, 200) || "(kein Betreff)", + receivedAt: msgDate, + action: "deleted", + }); + newlyBlocked++; + } + + if (uidsToDelete.length > 0) { + try { + await imap.messageDelete(uidsToDelete.join(","), { uid: true }); + } catch { + try { + for (const uid of uidsToDelete) { + await imap + .messageFlagsAdd(uid, ["\\Deleted"], { uid: true }) + .catch(() => {}); + } + await (imap as any).expunge().catch(() => {}); + } catch { + /* ignore */ + } + } + console.log( + `[scan-internal] ${connection.email} | ${mb.path} | deleted ${uidsToDelete.length} gambling mails`, + ); + } + + await insertMailBlocked(toInsert); + } finally { + lock.release(); + } + } + + await imap.logout(); + } catch { + try { + await imap.logout(); + } catch {} + } + + totalBlocked += newlyBlocked; + await updateMailConnectionScanStats( + connection.id, + scanned, + newlyBlocked, + connection.emailsBlocked, + connection.emailsScanned, + connection.scanInterval, + ); + } + + return { scanned: totalScanned, blocked: totalBlocked }; +}); diff --git a/backend/server/api/mail/scan.post.ts b/backend/server/api/mail/scan.post.ts new file mode 100644 index 0000000..f1defce --- /dev/null +++ b/backend/server/api/mail/scan.post.ts @@ -0,0 +1,196 @@ +import { ImapFlow } from "imapflow"; +import { + getMailConnections, + deleteOldMailBlocked, + getAlreadyBlockedUidSet, + insertMailBlocked, + updateMailConnectionScanStats, +} from "../../db/mail"; +import { getBlocklistedDomainsSet } from "../../db/domains"; +import { getProfile } from "../../db/profile"; +import { getPlanLimits } from "../../utils/plan-features"; +// Single-Source-of-Truth (Mo's Finding #4) +// @ts-expect-error — .mjs ohne types, GAMBLING_KEYWORDS ist string[] +import { GAMBLING_KEYWORDS } from "../../utils/gambling-keywords.mjs"; + + +/** + * POST /api/mail/scan + * Scannt ALLE Ordner (INBOX, Spam, Papierkorb, All Mail …) nach Gambling-Mails. + * Free-User: nur eigene Domains + Keywords. Pro/Legend: globale Blocklist + eigene. + */ +export default defineEventHandler(async (event) => { + const user = await requireUser(event); + + const connections = await getMailConnections(user.id); + if (connections.length === 0) { + throw createError({ + statusCode: 404, + message: "Kein Mail-Konto verbunden", + }); + } + + // Plan-aware: Free users get only custom domains, Pro/Legend get global blocklist + const profile = await getProfile(user.id); + const limits = getPlanLimits(profile?.plan ?? "free"); + const includeGlobal = limits.globalBlocklist; + + await deleteOldMailBlocked(user.id); + + let totalScanned = 0; + let totalBlocked = 0; + + for (const connection of connections) { + let password: string; + try { + password = decrypt(connection.passwordEncrypted); + } catch { + continue; + } + + // useStarttls=true → STARTTLS (secure=false + requireTLS=true) + // rejectUnauthorized=false → self-signed Certs zulassen (nur Custom-IMAP) + const useImplicitTls = !connection.useStarttls; + const imap = new ImapFlow({ + host: connection.imapHost, + port: connection.imapPort, + secure: useImplicitTls, + ...(connection.useStarttls ? { requireTLS: true } : {}), + auth: { user: connection.email, pass: password }, + logger: false, + tls: { rejectUnauthorized: connection.rejectUnauthorized ?? true }, + }); + + let scanned = 0; + let newlyBlocked = 0; + + try { + await imap.connect(); + + // Scan ALL mailbox folders (not just hardcoded list) + const mailboxes = await imap.list(); + const scannable = mailboxes.filter( + (mb: any) => !mb.flags?.has("\\Noselect"), + ); + + for (const mb of scannable) { + let lock: any; + try { + lock = await imap.getMailboxLock(mb.path); + } catch { + continue; + } + try { + const SCAN_LIMIT = 200; + const status = await imap.status(mb.path, { messages: true }); + const msgCount = (status as any).messages ?? 0; + if (msgCount === 0) continue; + + const fetchRange = + msgCount > SCAN_LIMIT ? `${msgCount - SCAN_LIMIT + 1}:*` : "1:*"; + const allMessages = await imap.fetchAll(fetchRange, { + envelope: true, + }); + scanned += allMessages.length; + totalScanned += allMessages.length; + + const allUids = allMessages.map( + (m: any) => `${mb.path}:${String(m.uid ?? m.seq)}`, + ); + const [blockedDomainSet, alreadyBlockedSet] = await Promise.all([ + getBlocklistedDomainsSet( + allMessages + .map( + (m: any) => + (m.envelope?.from?.[0]?.address ?? "") + .toLowerCase() + .split("@")[1] ?? "", + ) + .filter(Boolean), + user.id, + includeGlobal, + ), + getAlreadyBlockedUidSet(allUids, user.id), + ]); + + const toInsert: Parameters[0] = []; + const uidsToDelete: string[] = []; + + for (const msg of allMessages) { + const from = msg.envelope?.from?.[0]; + const senderEmail = (from?.address ?? "").toLowerCase(); + const senderName = from?.name ?? null; + const subject = (msg.envelope?.subject ?? "").trim(); + const msgDate = msg.envelope?.date ?? new Date(); + const uid = `${mb.path}:${String(msg.uid ?? msg.seq)}`; + + const haystack = `${senderEmail} ${subject}`.toLowerCase(); + const isGamblingKeyword = GAMBLING_KEYWORDS.some((kw) => + haystack.includes(kw), + ); + const senderDomain = senderEmail.split("@")[1] ?? ""; + const isBlocklisted = senderDomain + ? blockedDomainSet.has(senderDomain) + : false; + + if (!isGamblingKeyword && !isBlocklisted) continue; + if (alreadyBlockedSet.has(uid)) continue; + + uidsToDelete.push(String(msg.uid)); + toInsert.push({ + userId: user.id, + connectionId: connection.id, + gmailMessageId: uid, + senderEmail: senderEmail || "unbekannt", + senderName, + subject: subject.slice(0, 200) || "(kein Betreff)", + receivedAt: msgDate, + action: "deleted", + }); + newlyBlocked++; + } + + // Permanently delete gambling mails from this folder + if (uidsToDelete.length > 0) { + try { + await imap.messageDelete(uidsToDelete.join(","), { uid: true }); + } catch { + try { + for (const uid of uidsToDelete) { + await imap + .messageFlagsAdd(uid, ["\\Deleted"], { uid: true }) + .catch(() => {}); + } + await imap.expunge(); + } catch { + /* ignore */ + } + } + } + + await insertMailBlocked(toInsert); + } finally { + lock.release(); + } + } + + await imap.logout(); + } catch { + try { + await imap.logout(); + } catch {} + } + + totalBlocked += newlyBlocked; + await updateMailConnectionScanStats( + connection.id, + scanned, + newlyBlocked, + connection.emailsBlocked, + connection.emailsScanned, + connection.scanInterval, + ); + } + + return { scanned: totalScanned, blocked: totalBlocked }; +}); diff --git a/backend/server/api/mail/status.get.ts b/backend/server/api/mail/status.get.ts new file mode 100644 index 0000000..0a604d7 --- /dev/null +++ b/backend/server/api/mail/status.get.ts @@ -0,0 +1,52 @@ +import { getMailConnections, getMailBlockedStats } from "../../db/mail"; + +/** + * GET /api/mail/status + * Gibt den Verbindungsstatus + Statistiken zurück. + */ +export default defineEventHandler(async (event) => { + const user = await requireUser(event); + + const connections = await getMailConnections(user.id); + + const list = connections.map((c) => ({ + id: c.id, + email: c.email, + provider: c.providerName ?? "IMAP", + isActive: c.isActive, + lastScannedAt: c.lastScannedAt?.toISOString() ?? null, + nextScanAt: c.nextScanAt?.toISOString() ?? null, + totalBlocked: c.emailsBlocked, + totalScanned: c.emailsScanned, + scanInterval: c.scanInterval, + blockRate: + c.emailsScanned > 0 + ? Math.round((c.emailsBlocked / c.emailsScanned) * 100) + : 0, + })); + + const blocked7d = await getMailBlockedStats(user.id); + + const dailyMap: Record = {}; + for (const row of blocked7d) { + const day = row.createdAt.toISOString().slice(0, 10); + dailyMap[day] = (dailyMap[day] ?? 0) + 1; + } + const dailyStats = Array.from({ length: 7 }, (_, i) => { + const d = new Date(Date.now() - (6 - i) * 86_400_000); + const key = d.toISOString().slice(0, 10); + return { + date: key, + label: d.toLocaleDateString("de-DE", { weekday: "short" }), + count: dailyMap[key] ?? 0, + }; + }); + + return { + connected: list.length > 0, + accounts: list, + totalBlocked: list.reduce((s, c) => s + c.totalBlocked, 0), + totalScanned: list.reduce((s, c) => s + c.totalScanned, 0), + dailyStats, + }; +}); diff --git a/backend/server/api/notifications/[id].delete.ts b/backend/server/api/notifications/[id].delete.ts new file mode 100644 index 0000000..7a12a0b --- /dev/null +++ b/backend/server/api/notifications/[id].delete.ts @@ -0,0 +1,10 @@ +import { deleteNotification } from "../../db/notifications"; + +export default defineEventHandler(async (event) => { + const user = await requireUser(event); + const id = getRouterParam(event, "id"); + if (!id) throw createError({ statusCode: 400, message: "Missing id" }); + + await deleteNotification(id, user.id); + return { ok: true }; +}); diff --git a/backend/server/api/notifications/index.get.ts b/backend/server/api/notifications/index.get.ts new file mode 100644 index 0000000..941d47e --- /dev/null +++ b/backend/server/api/notifications/index.get.ts @@ -0,0 +1,10 @@ +import { getNotifications, countUnread } from "../../db/notifications"; + +export default defineEventHandler(async (event) => { + const user = await requireUser(event); + const [items, unread] = await Promise.all([ + getNotifications(user.id), + countUnread(user.id), + ]); + return { items, unread }; +}); diff --git a/backend/server/api/notifications/read.post.ts b/backend/server/api/notifications/read.post.ts new file mode 100644 index 0000000..e3ca004 --- /dev/null +++ b/backend/server/api/notifications/read.post.ts @@ -0,0 +1,7 @@ +import { markAllRead } from "../../db/notifications"; + +export default defineEventHandler(async (event) => { + const user = await requireUser(event); + await markAllRead(user.id); + return { ok: true }; +}); diff --git a/backend/server/api/protection/state.get.ts b/backend/server/api/protection/state.get.ts new file mode 100644 index 0000000..df947bb --- /dev/null +++ b/backend/server/api/protection/state.get.ts @@ -0,0 +1,51 @@ +import { requireUser } from "../../utils/auth"; +import { getActiveCooldown, resolveCooldown } from "../../db/cooldown"; +import { getProfile } from "../../db/profile"; + +/** + * GET /api/protection/state + * Combined protection + cooldown state polled every 30 s by the app. + */ +export default defineEventHandler(async (event) => { + const user = await requireUser(event); + + const [cooldown, profile] = await Promise.all([ + getActiveCooldown(user.id), + getProfile(user.id), + ]); + + const now = new Date(); + let active = false; + let remainingSeconds = 0; + let cooldownEndsAt: string | null = null; + + if (cooldown) { + const expired = now >= cooldown.cooldownEndsAt; + if (expired) { + await resolveCooldown(cooldown.id); + // After resolve: no active cooldown + } else { + active = true; + remainingSeconds = Math.max( + 0, + Math.floor((cooldown.cooldownEndsAt.getTime() - now.getTime()) / 1000), + ); + cooldownEndsAt = cooldown.cooldownEndsAt.toISOString(); + } + } + + const plan = (profile?.plan ?? "free") as "free" | "pro" | "legend"; + + return { + success: true, + data: { + protectionShouldBeActive: !active, + cooldown: { + active, + remainingSeconds, + cooldownEndsAt, + }, + plan, + }, + }; +}); diff --git a/backend/server/api/providers/index.get.ts b/backend/server/api/providers/index.get.ts new file mode 100644 index 0000000..1718f4a --- /dev/null +++ b/backend/server/api/providers/index.get.ts @@ -0,0 +1,284 @@ +import { usePrisma } from "../../utils/prisma"; + +/** + * GET /api/providers + * Liefert die Liste der in Deutschland lizenzierten Glücksspiel-Anbieter (GGL-Whitelist). + * Quelle: Gemeinsame Glücksspielbehörde der Länder (ggl-behoerde.de) + * Diese Daten werden statisch gepflegt + aus der DB ergänzt. + */ + +// Statische GGL-lizenzierte Anbieter (Stand: 2026) +// Kategorie: sportwetten | casino | poker | slots +const GGL_PROVIDERS = [ + // Sportwetten-Lizenzen + { + name: "bet365", + domain: "bet365.com", + category: "sportwetten", + license: "GGL-SW", + risk: "hoch", + }, + { + name: "Tipico", + domain: "tipico.de", + category: "sportwetten", + license: "GGL-SW", + risk: "hoch", + }, + { + name: "bwin", + domain: "bwin.de", + category: "sportwetten", + license: "GGL-SW", + risk: "hoch", + }, + { + name: "Betano", + domain: "betano.de", + category: "sportwetten", + license: "GGL-SW", + risk: "hoch", + }, + { + name: "Betway", + domain: "betway.de", + category: "sportwetten", + license: "GGL-SW", + risk: "hoch", + }, + { + name: "ODDSET", + domain: "oddset.de", + category: "sportwetten", + license: "GGL-SW", + risk: "mittel", + }, + { + name: "Interwetten", + domain: "interwetten.de", + category: "sportwetten", + license: "GGL-SW", + risk: "mittel", + }, + { + name: "Unibet", + domain: "unibet.de", + category: "sportwetten", + license: "GGL-SW", + risk: "hoch", + }, + { + name: "888sport", + domain: "888sport.de", + category: "sportwetten", + license: "GGL-SW", + risk: "hoch", + }, + { + name: "Betsson", + domain: "betsson.com", + category: "sportwetten", + license: "GGL-SW", + risk: "mittel", + }, + { + name: "NEObet", + domain: "neobet.de", + category: "sportwetten", + license: "GGL-SW", + risk: "mittel", + }, + { + name: "AdmiralBet", + domain: "admiralbet.de", + category: "sportwetten", + license: "GGL-SW", + risk: "mittel", + }, + { + name: "Winamax", + domain: "winamax.de", + category: "sportwetten", + license: "GGL-SW", + risk: "mittel", + }, + { + name: "Merkur Sports", + domain: "merkursports.de", + category: "sportwetten", + license: "GGL-SW", + risk: "mittel", + }, + // Virtuelle Automatenspiele + { + name: "Merkur Slots", + domain: "merkur-slots.de", + category: "slots", + license: "GGL-VA", + risk: "sehr hoch", + }, + { + name: "Jackpotpiraten", + domain: "jackpotpiraten.de", + category: "slots", + license: "GGL-VA", + risk: "sehr hoch", + }, + { + name: "Jokerstar", + domain: "jokerstar.de", + category: "slots", + license: "GGL-VA", + risk: "sehr hoch", + }, + { + name: "BingBong", + domain: "bingbong.de", + category: "slots", + license: "GGL-VA", + risk: "sehr hoch", + }, + { + name: "Slotmagie", + domain: "slotmagie.de", + category: "slots", + license: "GGL-VA", + risk: "sehr hoch", + }, + { + name: "Drückglück", + domain: "drueckglueck.de", + category: "slots", + license: "GGL-VA", + risk: "sehr hoch", + }, + { + name: "Hyperino", + domain: "hyperino.de", + category: "slots", + license: "GGL-VA", + risk: "sehr hoch", + }, + { + name: "Löwen Play", + domain: "loewenplay.de", + category: "slots", + license: "GGL-VA", + risk: "sehr hoch", + }, + { + name: "Sunmaker", + domain: "sunmaker.de", + category: "slots", + license: "GGL-VA", + risk: "sehr hoch", + }, + // Poker + { + name: "PokerStars", + domain: "pokerstars.de", + category: "poker", + license: "GGL-PK", + risk: "hoch", + }, + { + name: "GGPoker", + domain: "ggpoker.de", + category: "poker", + license: "GGL-PK", + risk: "hoch", + }, + { + name: "888poker", + domain: "888poker.de", + category: "poker", + license: "GGL-PK", + risk: "hoch", + }, + // Illegale / häufig genutzte ohne DE-Lizenz + { + name: "Stake", + domain: "stake.com", + category: "casino", + license: "keine (Curaçao)", + risk: "extrem", + }, + { + name: "Rollbit", + domain: "rollbit.com", + category: "casino", + license: "keine", + risk: "extrem", + }, + { + name: "Roobet", + domain: "roobet.com", + category: "casino", + license: "keine (Curaçao)", + risk: "extrem", + }, + { + name: "Gamdom", + domain: "gamdom.com", + category: "casino", + license: "keine (Curaçao)", + risk: "extrem", + }, + { + name: "CSGORoll", + domain: "csgoroll.com", + category: "casino", + license: "keine", + risk: "extrem", + }, + { + name: "Duelbits", + domain: "duelbits.com", + category: "casino", + license: "keine (Curaçao)", + risk: "extrem", + }, +]; + +export default defineEventHandler(async (event) => { + const query = getQuery(event); + const category = query.category as string | undefined; + + let providers = [...GGL_PROVIDERS]; + + // Optionale DB-Erweiterung: community-gemeldete Anbieter + try { + const db = usePrisma(); + const data = await db.blocklistDomain.findMany({ + where: { isActive: true, reportCount: { gt: 5 } }, + select: { domain: true, source: true }, + take: 50, + }); + + for (const d of data) { + if (!providers.find((p) => p.domain === d.domain)) { + providers.push({ + name: d.domain.replace(/\.\w+$/, ""), + domain: d.domain, + category: "casino", + license: "unbekannt", + risk: "hoch", + }); + } + } + } catch { + // DB nicht erreichbar → nur statische Daten + } + + // Filter nach Kategorie + if (category && category !== "all") { + providers = providers.filter((p) => p.category === category); + } + + return { + total: providers.length, + providers, + source: "GGL Whitelist + Community Reports", + lastUpdated: "2026-04-01", + }; +}); diff --git a/backend/server/api/scores/leaderboard.get.ts b/backend/server/api/scores/leaderboard.get.ts new file mode 100644 index 0000000..a6057f8 --- /dev/null +++ b/backend/server/api/scores/leaderboard.get.ts @@ -0,0 +1,28 @@ +import { getLeaderboard } from "../../db/scores"; +import { getProfile } from "../../db/profile"; +import { getUsersMeta } from "../../utils/getUsersMeta"; + +// GET /api/scores/leaderboard – Top 50 mit Username +export default defineEventHandler(async (event) => { + const entries = await getLeaderboard(50); + const userIds = entries.map((e) => e.userId); + + const [profiles, metaMap] = await Promise.all([ + Promise.all(userIds.map((id) => getProfile(id))), + getUsersMeta(userIds), + ]); + const profileMap = new Map(profiles.filter(Boolean).map((p) => [p!.id, p!])); + + return entries.map((row, i) => { + const p = profileMap.get(row.userId); + const meta = metaMap[row.userId] ?? { nickname: null, avatar: null }; + return { + rank: i + 1, + user_id: row.userId, + username: p?.username ?? "Anonym", + avatar: meta.avatar ?? null, + total_points: row.totalPoints, + tier: row.tier, + }; + }); +}); diff --git a/backend/server/api/scores/me.get.ts b/backend/server/api/scores/me.get.ts new file mode 100644 index 0000000..086df80 --- /dev/null +++ b/backend/server/api/scores/me.get.ts @@ -0,0 +1,22 @@ +import { getUserScore, getRecentScoreEvents } from "../../db/scores"; + +// GET /api/scores/me – eigener Score + Tier + letzte Events +export default defineEventHandler(async (event) => { + const user = await requireUser(event); + + const [score, events] = await Promise.all([ + getUserScore(user.id), + getRecentScoreEvents(user.id, 20), + ]); + + return { + total_points: score?.totalPoints ?? 0, + tier: score?.tier ?? "beginner", + recent_events: events.map((e) => ({ + event_type: e.eventType, + points: e.points, + created_at: e.createdAt, + meta: e.meta, + })), + }; +}); diff --git a/backend/server/api/social/follow.post.ts b/backend/server/api/social/follow.post.ts new file mode 100644 index 0000000..a877c75 --- /dev/null +++ b/backend/server/api/social/follow.post.ts @@ -0,0 +1,42 @@ +import { + getFollowRelation, + createFollow, + deleteFollow, + getProfileWithFollowers, +} from "../../db/social"; + +/** + * POST /api/social/follow + * Body: { userId } – Target-User, dem gefolgt werden soll + * Toggled Follow: follow wenn nicht gefolgt, unfollow wenn bereits gefolgt. + */ +export default defineEventHandler(async (event) => { + const user = await requireUser(event); + const { userId } = (await readBody(event)) as { userId: string }; + + if (!userId) + throw createError({ statusCode: 400, message: "userId erforderlich" }); + if (userId === user.id) + throw createError({ + statusCode: 400, + message: "Du kannst dir selbst nicht folgen", + }); + + const existing = await getFollowRelation(user.id, userId); + let following: boolean; + + if (existing) { + await deleteFollow(user.id, userId); + following = false; + } else { + await createFollow(user.id, userId); + following = true; + } + + const profile = await getProfileWithFollowers(userId); + + return { + following, + followersCount: profile?.followersCount ?? 0, + }; +}); diff --git a/backend/server/api/social/profile/[userId].get.ts b/backend/server/api/social/profile/[userId].get.ts new file mode 100644 index 0000000..63d66f0 --- /dev/null +++ b/backend/server/api/social/profile/[userId].get.ts @@ -0,0 +1,71 @@ +import { getProfile } from "../../../db/profile"; +import { getUserScore } from "../../../db/scores"; +import { getFollowRelation } from "../../../db/social"; +import { getUsersMeta } from "../../../utils/getUsersMeta"; +import { usePrisma } from "../../../utils/prisma"; + +/** GET /api/social/profile/[userId] */ +export default defineEventHandler(async (event) => { + const targetUserId = getRouterParam(event, "userId"); + if (!targetUserId) + throw createError({ statusCode: 400, message: "userId fehlt" }); + + // Auth-User optional + let currentUserId: string | null = null; + try { + const u = await requireUser(event); + currentUserId = u.id; + } catch {} + + const [profile, score, followRelation, recentPosts, metaMap] = + await Promise.all([ + getProfile(targetUserId), + getUserScore(targetUserId), + currentUserId && currentUserId !== targetUserId + ? getFollowRelation(currentUserId, targetUserId) + : Promise.resolve(null), + usePrisma().communityPost.findMany({ + where: { userId: targetUserId, isModerated: false }, + orderBy: { createdAt: "desc" }, + take: 5, + select: { + id: true, + category: true, + content: true, + likesCount: true, + commentsCount: true, + createdAt: true, + }, + }), + getUsersMeta([targetUserId]), + ]); + + if (!profile) + throw createError({ statusCode: 404, message: "Profil nicht gefunden" }); + + const meta = metaMap[targetUserId] ?? { nickname: null, avatar: null }; + + return { + id: profile.id, + username: profile.username, + nickname: meta.nickname ?? profile.username, + avatar: meta.avatar, + bio: (profile as any).bio ?? null, + followersCount: profile.followersCount ?? 0, + followingCount: (profile as any).followingCount ?? 0, + postsCount: (profile as any).postsCount ?? 0, + tier: score?.tier ?? "beginner", + totalPoints: score?.totalPoints ?? 0, + isFollowing: !!followRelation, + isSelf: currentUserId === targetUserId, + joinedAt: profile.createdAt, + recentPosts: recentPosts.map((p) => ({ + id: p.id, + category: p.category, + content: p.content.slice(0, 120), + likesCount: p.likesCount ?? 0, + commentsCount: p.commentsCount ?? 0, + createdAt: p.createdAt, + })), + }; +}); diff --git a/backend/server/api/sos/session.post.ts b/backend/server/api/sos/session.post.ts new file mode 100644 index 0000000..4cf0336 --- /dev/null +++ b/backend/server/api/sos/session.post.ts @@ -0,0 +1,42 @@ +import { createSosSession } from "../../db/sosSession"; + +/** POST /api/sos/session — speichert kompletten SOS-Verlauf für DiGA-Auswertung */ +export default defineEventHandler(async (event) => { + const user = await requireUser(event); + const body = await readBody(event); + + if (!body || !Array.isArray(body.messages)) { + throw createError({ statusCode: 400, message: "messages required" }); + } + + // Hard limit gegen Spam: max 200 messages, max 1MB body + const messages = body.messages.slice(0, 200); + + const rating = + typeof body.feedbackRating === "number" + ? Math.max(1, Math.min(5, Math.floor(body.feedbackRating))) + : null; + + const session = await createSosSession(user.id, { + startedAt: body.startedAt, + endedAt: body.endedAt ?? new Date(), + durationSec: typeof body.durationSec === "number" ? body.durationSec : null, + messages, + gamesPlayed: Array.isArray(body.gamesPlayed) + ? body.gamesPlayed.slice(0, 20) + : [], + breathingCount: + typeof body.breathingCount === "number" ? body.breathingCount : 0, + wasOvercome: !!body.wasOvercome, + feedbackBetter: + typeof body.feedbackBetter === "boolean" ? body.feedbackBetter : null, + feedbackRating: rating, + feedbackText: + typeof body.feedbackText === "string" + ? body.feedbackText.slice(0, 1000) + : null, + locale: typeof body.locale === "string" ? body.locale.slice(0, 10) : null, + }); + + return { id: session.id }; +}); diff --git a/backend/server/api/streak/events.get.ts b/backend/server/api/streak/events.get.ts new file mode 100644 index 0000000..6b07820 --- /dev/null +++ b/backend/server/api/streak/events.get.ts @@ -0,0 +1,7 @@ +import { getStreakEvents } from "../../db/streak"; + +/** GET /api/streak/events – Streak-Verlauf abrufen */ +export default defineEventHandler(async (event) => { + const user = await requireUser(event); + return getStreakEvents(user.id); +}); diff --git a/backend/server/api/streak/index.get.ts b/backend/server/api/streak/index.get.ts new file mode 100644 index 0000000..91a801d --- /dev/null +++ b/backend/server/api/streak/index.get.ts @@ -0,0 +1,7 @@ +import { getActiveStreak } from "../../db/streak"; + +/** GET /api/streak */ +export default defineEventHandler(async (event) => { + const user = await requireUser(event); + return getActiveStreak(user.id); +}); diff --git a/backend/server/api/streak/index.patch.ts b/backend/server/api/streak/index.patch.ts new file mode 100644 index 0000000..49bc1c9 --- /dev/null +++ b/backend/server/api/streak/index.patch.ts @@ -0,0 +1,27 @@ +import { + getActiveStreak, + resetStreak, + updateStreakSavings, +} from "../../db/streak"; + +/** PATCH /api/streak – reset oder avg_monthly_savings aktualisieren */ +export default defineEventHandler(async (event) => { + const user = await requireUser(event); + const body = await readBody(event); + + const current = await getActiveStreak(user.id); + if (!current) + throw createError({ statusCode: 404, message: "Kein aktiver Streak" }); + + if (body.reset === true) { + const longest = Math.max(current.longestDays, current.currentDays); + const reason = body.reason ?? "manual"; + return resetStreak(current.id, longest, user.id, reason); + } + + if (body.avgMonthlySavings !== undefined) { + return updateStreakSavings(current.id, body.avgMonthlySavings); + } + + return current; +}); diff --git a/backend/server/api/streak/index.post.ts b/backend/server/api/streak/index.post.ts new file mode 100644 index 0000000..f5a9f8f --- /dev/null +++ b/backend/server/api/streak/index.post.ts @@ -0,0 +1,11 @@ +import { upsertStreak } from "../../db/streak"; + +/** POST /api/streak – Streak starten oder reaktivieren */ +export default defineEventHandler(async (event) => { + const user = await requireUser(event); + const body = await readBody(event); + return upsertStreak(user.id, { + avgMonthlySavings: body?.avgMonthlySavings, + startDate: body?.startDate ?? null, + }); +}); diff --git a/backend/server/api/stripe/checkout.post.ts b/backend/server/api/stripe/checkout.post.ts new file mode 100644 index 0000000..fb21210 --- /dev/null +++ b/backend/server/api/stripe/checkout.post.ts @@ -0,0 +1,70 @@ +import Stripe from "stripe"; + +export default defineEventHandler(async (event) => { + const config = useRuntimeConfig(); + + if (!config.stripeSecretKey) { + throw createError({ + statusCode: 500, + message: "Stripe nicht konfiguriert – NUXT_STRIPE_SECRET_KEY fehlt", + }); + } + + const stripe = new Stripe(config.stripeSecretKey); + const user = await requireUser(event); + + const body = await readBody(event); + const plan = body?.plan as string; + const billing = (body?.billing as string) || "monthly"; + + // Aktive Pläne: free (kein Checkout), pro, legend (legend noch nicht aktiv – TODO: Stripe-Preise hinzufügen) + const activePlans = ["pro", "legend"]; + if (!plan || !activePlans.includes(plan)) { + throw createError({ statusCode: 400, message: "Ungültiger Plan" }); + } + if (!["monthly", "yearly"].includes(billing)) { + throw createError({ + statusCode: 400, + message: "Ungültiger Billing-Zyklus", + }); + } + + const priceEnvMap: Record> = { + pro: { + monthly: "STRIPE_PRICE_STANDARD_MONTHLY", + quarterly: "STRIPE_PRICE_STANDARD_QUARTERLY", + yearly: "STRIPE_PRICE_STANDARD_YEARLY", + }, + legend: { + monthly: "STRIPE_PRICE_PRO_MONTHLY", + quarterly: "STRIPE_PRICE_PRO_QUARTERLY", + yearly: "STRIPE_PRICE_PRO_YEARLY", + }, + }; + + const envKey = priceEnvMap[plan][billing]; + const priceId = process.env[envKey]; + + if (!priceId || !priceId.startsWith("price_")) { + throw createError({ + statusCode: 503, + message: `Dieser Plan ist noch nicht verfügbar. (${envKey} nicht gesetzt)`, + }); + } + + const appUrl = config.public.appUrl || "https://rebreak.org"; + + const session = await stripe.checkout.sessions.create({ + mode: "subscription", + line_items: [{ price: priceId, quantity: 1 }], + success_url: `${appUrl}/app/settings?upgraded=true`, + cancel_url: `${appUrl}/pricing`, + client_reference_id: user.id, + metadata: { + user_id: user.id, + plan, + }, + }); + + return { url: session.url }; +}); diff --git a/backend/server/api/stripe/portal.post.ts b/backend/server/api/stripe/portal.post.ts new file mode 100644 index 0000000..b7b90e9 --- /dev/null +++ b/backend/server/api/stripe/portal.post.ts @@ -0,0 +1,35 @@ +import Stripe from "stripe"; +import { usePrisma } from "../../utils/prisma"; + +/** + * POST /api/stripe/portal + * Erstellt eine Stripe Billing Portal Session (Abo verwalten/kündigen). + */ +export default defineEventHandler(async (event) => { + const config = useRuntimeConfig(); + const stripe = new Stripe(config.stripeSecretKey); + + const user = await requireUser(event); + + const db = usePrisma(); + const profile = await db.profile.findUnique({ + where: { id: user.id }, + select: { stripeCustomerId: true }, + }); + + if (!profile?.stripeCustomerId) { + throw createError({ + statusCode: 400, + message: "Kein aktives Abo gefunden", + }); + } + + const appUrl = config.public.appUrl || "https://rebreak.app"; + + const session = await stripe.billingPortal.sessions.create({ + customer: profile.stripeCustomerId, + return_url: `${appUrl}/app/settings`, + }); + + return { url: session.url }; +}); diff --git a/backend/server/api/stripe/webhook.post.ts b/backend/server/api/stripe/webhook.post.ts new file mode 100644 index 0000000..4dc932b --- /dev/null +++ b/backend/server/api/stripe/webhook.post.ts @@ -0,0 +1,103 @@ +import Stripe from "stripe"; +import { usePrisma } from "../../utils/prisma"; + +/** + * POST /api/stripe/webhook + * Stripe Webhook – verarbeitet Subscription-Events. + * Aktualisiert profiles.plan + stripe_* Felder. + */ +export default defineEventHandler(async (event) => { + const config = useRuntimeConfig(); + const stripe = new Stripe(config.stripeSecretKey); + + const body = await readRawBody(event); + const sig = getHeader(event, "stripe-signature"); + + if (!body || !sig) { + throw createError({ + statusCode: 400, + message: "Missing body or signature", + }); + } + + let stripeEvent: Stripe.Event; + try { + stripeEvent = stripe.webhooks.constructEvent( + body, + sig, + config.stripeWebhookSecret, + ); + } catch (err: any) { + throw createError({ + statusCode: 400, + message: `Webhook Error: ${err.message}`, + }); + } + + const db = usePrisma(); + + switch (stripeEvent.type) { + case "checkout.session.completed": { + const session = stripeEvent.data.object as Stripe.Checkout.Session; + const userId = session.metadata?.user_id || session.client_reference_id; + const plan = session.metadata?.plan || "legend"; + + if (userId) { + await db.profile.update({ + where: { id: userId }, + data: { + plan: + plan === "legend" ? "legend" : plan === "pro" ? "pro" : "free", + stripeCustomerId: session.customer as string, + stripeSubId: session.subscription as string, + }, + }); + } + break; + } + + case "customer.subscription.updated": { + const sub = stripeEvent.data.object as Stripe.Subscription; + const customerId = sub.customer as string; + + const profile = await db.profile.findFirst({ + where: { stripeCustomerId: customerId }, + select: { id: true, plan: true }, + }); + + if (profile) { + const isActive = ["active", "trialing"].includes(sub.status); + await db.profile.update({ + where: { id: profile.id }, + data: { + plan: isActive ? profile.plan : "free", + premiumUntil: sub.current_period_end + ? new Date(sub.current_period_end * 1000) + : null, + }, + }); + } + break; + } + + case "customer.subscription.deleted": { + const sub = stripeEvent.data.object as Stripe.Subscription; + const customerId = sub.customer as string; + + const profile = await db.profile.findFirst({ + where: { stripeCustomerId: customerId }, + select: { id: true }, + }); + + if (profile) { + await db.profile.update({ + where: { id: profile.id }, + data: { plan: "free", premiumUntil: null }, + }); + } + break; + } + } + + return { received: true }; +}); diff --git a/backend/server/api/urge/index.get.ts b/backend/server/api/urge/index.get.ts new file mode 100644 index 0000000..e7a6eea --- /dev/null +++ b/backend/server/api/urge/index.get.ts @@ -0,0 +1,9 @@ +import { getRecentUrgeLogs } from "../../db/urge"; + +/** GET /api/urge?limit=20 */ +export default defineEventHandler(async (event) => { + const user = await requireUser(event); + const query = getQuery(event); + const limit = Math.min(100, parseInt((query.limit as string) || "20")); + return getRecentUrgeLogs(user.id, limit); +}); diff --git a/backend/server/api/urge/index.post.ts b/backend/server/api/urge/index.post.ts new file mode 100644 index 0000000..9cacfae --- /dev/null +++ b/backend/server/api/urge/index.post.ts @@ -0,0 +1,42 @@ +import { createUrgeLog } from "../../db/urge"; +import { addStreakEvent } from "../../db/streak"; + +type Emotion = "stress" | "sadness" | "anger" | "empty" | "boredom" | "other"; +const VALID_EMOTIONS: Emotion[] = [ + "stress", + "sadness", + "anger", + "empty", + "boredom", + "other", +]; + +/** POST /api/urge */ +export default defineEventHandler(async (event) => { + const user = await requireUser(event); + const body = await readBody(event); + const { emotion, wasOvercome, breathingDone } = body ?? {}; + + if (!VALID_EMOTIONS.includes(emotion)) { + throw createError({ statusCode: 400, message: "Ungültige Emotion" }); + } + + const log = await createUrgeLog( + user.id, + emotion, + !!wasOvercome, + !!breathingDone, + ); + + // StreakEvent loggen + if (wasOvercome) { + await addStreakEvent(user.id, "milestone", { + type: "urge_overcome", + emotion, + }); + } else { + await addStreakEvent(user.id, "relapse", { emotion }); + } + + return log; +}); diff --git a/backend/server/api/url-filter/blocklist.bin.get.ts b/backend/server/api/url-filter/blocklist.bin.get.ts new file mode 100644 index 0000000..9348b64 --- /dev/null +++ b/backend/server/api/url-filter/blocklist.bin.get.ts @@ -0,0 +1,109 @@ +import { + getActiveBlocklistDomains, + getUserCustomDomains, +} from "../../db/domains"; +import { getProfile } from "../../db/profile"; +import { getPlanLimits } from "../../utils/plan-features"; +import { buildHashListBinary, etagFor } from "../../utils/domainHash"; + +/** + * GET /api/url-filter/blocklist.bin + * + * Liefert die Blocklist als sortierte Binary-Hash-Liste (8 Bytes pro Hash, + * big-endian, sorted ascending). Die iOS-Extension memory-mapped diese Datei + * und macht binary-search bei jedem Browser-Flow. + * + * Privacy: + * - Server schickt NUR Hashes, keine Klartext-Domains an die App + * - On-Disk in App-Group: nur Hash-Bytes, kein Klartext (forensik-resistent) + * + * Plan-aware: + * - free: KEIN global-blocklist, NUR Custom-Domains gehasht + * - pro/legend: global HaGeZi-List + Custom-Domains beide gehasht + * + * Caching: + * - ETag = sha256 des Binary-Inhalts (16 hex chars) + * - Cache-Control: private, max-age=300 + * - Bei If-None-Match: 304 Not Modified + */ +export default defineEventHandler(async (event) => { + const user = await requireUser(event); + + const [profile, customDomains, globalDomains] = await Promise.all([ + getProfile(user.id), + getUserCustomDomains(user.id), + // Global nur lazy laden wenn Plan es erlaubt — spart RAM bei Free-Usern + Promise.resolve(null), + ]); + + const limits = getPlanLimits(profile?.plan ?? "free"); + + // Global Domains nur für Pro/Legend + const global = limits.globalBlocklist ? await getActiveBlocklistDomains() : []; + + // Beide Listen ohne Salt hashen — vereinfachte Architektur: + // Server kennt die Klartext-Domains eh (via DB), darum bringt User-Salt + // praktisch keinen Privacy-Vorteil. Vorteil: Extension kann mit einer + // Hash-Funktion alle Hosts prüfen. + const customBuf = buildHashListBinary(customDomains.map((d) => d.domain)); + const globalBuf = buildHashListBinary(global.map((d) => d.domain)); + + // Merged sorted binary (Extension macht eine binary-search über alles). + // Wichtig: doppelt sortieren weil Custom + Global zwei separate sorted + // arrays sind — wir mergen sie zu einem sorted array. + const combined = mergeSortedHashBuffers(customBuf, globalBuf); + + const etag = etagFor(combined); + + // Conditional GET — bei If-None-Match returns 304 + const ifNoneMatch = getHeader(event, "if-none-match"); + if (ifNoneMatch === etag) { + setResponseStatus(event, 304); + setResponseHeader(event, "etag", etag); + return null; + } + + setResponseHeader(event, "content-type", "application/octet-stream"); + setResponseHeader(event, "etag", etag); + setResponseHeader(event, "cache-control", "private, max-age=300"); + setResponseHeader(event, "x-rebreak-count", String(combined.length / 8)); + setResponseHeader(event, "x-rebreak-plan", profile?.plan ?? "free"); + + return combined; +}); + +/** + * Merged zwei sortierte 8-byte-hash-Buffer in ein sortiertes Output. + * In-place merge sort. + */ +function mergeSortedHashBuffers(a: Buffer, b: Buffer): Buffer { + const result = Buffer.alloc(a.length + b.length); + let ai = 0; + let bi = 0; + let ri = 0; + + while (ai < a.length && bi < b.length) { + const av = a.readBigUInt64BE(ai); + const bv = b.readBigUInt64BE(bi); + if (av <= bv) { + result.writeBigUInt64BE(av, ri); + ai += 8; + } else { + result.writeBigUInt64BE(bv, ri); + bi += 8; + } + ri += 8; + } + while (ai < a.length) { + result.writeBigUInt64BE(a.readBigUInt64BE(ai), ri); + ai += 8; + ri += 8; + } + while (bi < b.length) { + result.writeBigUInt64BE(b.readBigUInt64BE(bi), ri); + bi += 8; + ri += 8; + } + + return result.subarray(0, ri); +} diff --git a/backend/server/api/user/delete.delete.ts b/backend/server/api/user/delete.delete.ts new file mode 100644 index 0000000..8bcfe4b --- /dev/null +++ b/backend/server/api/user/delete.delete.ts @@ -0,0 +1,36 @@ +import { serverSupabaseServiceRole } from "../../utils/useSupabase"; +import { deleteUserUrgeLogs } from "../../db/urge"; +import { deleteUserSosSessions } from "../../db/sosSession"; +import { deleteUserStreaks } from "../../db/streak"; +import { deleteUserPosts } from "../../db/community"; +import { deleteAllUserCustomDomains } from "../../db/domains"; +import { + deleteUserTrustedContacts, + deleteUserCoachSessions, +} from "../../db/user"; +import { deleteProfile } from "../../db/profile"; + +export default defineEventHandler(async (event) => { + const user = await requireUser(event); + const supabase = serverSupabaseServiceRole(event); + const userId = user.id; + + // Delete all user data (DSGVO Art. 17) + await Promise.all([ + deleteUserUrgeLogs(userId), + deleteUserSosSessions(userId), + deleteUserStreaks(userId), + deleteUserPosts(userId), + deleteAllUserCustomDomains(userId), + deleteUserTrustedContacts(userId), + deleteUserCoachSessions(userId), + ]); + + // Profil zuletzt löschen (FK-Abhängigkeiten sind bereits entfernt) + await deleteProfile(userId).catch(() => {}); + + // Auth-User löschen (bleibt Supabase) + await supabase.auth.admin.deleteUser(userId); + + return { success: true }; +}); diff --git a/backend/server/db/chat-rooms.ts b/backend/server/db/chat-rooms.ts new file mode 100644 index 0000000..b4a4586 --- /dev/null +++ b/backend/server/db/chat-rooms.ts @@ -0,0 +1,366 @@ +import { usePrisma } from "../utils/prisma"; +import { randomBytes } from "crypto"; + +// ─── Rooms ──────────────────────────────────────────────────────────────────── + +export async function listRooms(userId: string) { + const db = usePrisma(); + // Public rooms + private rooms user is member of + return db.chatRoom.findMany({ + where: { + OR: [ + { isPublic: true }, + { members: { some: { userId, status: "active" } } }, + ], + }, + orderBy: { updatedAt: "desc" }, + include: { + // Only include current user's membership to correctly determine isMember/myRole + members: { + where: { userId, status: "active" }, + select: { userId: true, role: true }, + }, + messages: { + orderBy: { createdAt: "desc" }, + take: 1, + select: { content: true, createdAt: true, userId: true }, + }, + }, + }); +} + +export async function getRoom(roomId: string) { + const db = usePrisma(); + return db.chatRoom.findUnique({ + where: { id: roomId }, + include: { + members: { + where: { status: "active" }, + select: { userId: true, role: true, joinedAt: true }, + }, + }, + }); +} + +export async function createRoom(data: { + name: string; + description?: string; + isPublic: boolean; + joinMode: string; + createdBy: string; + avatarUrl?: string; +}) { + const db = usePrisma(); + const inviteCode = randomBytes(4).toString("hex"); + return db.chatRoom.create({ + data: { + name: data.name, + description: data.description, + isPublic: data.isPublic, + joinMode: data.joinMode, + inviteCode, + createdBy: data.createdBy, + avatarUrl: data.avatarUrl, + memberCount: 1, + members: { + create: { userId: data.createdBy, role: "owner", status: "active" }, + }, + }, + }); +} + +export async function updateRoom( + roomId: string, + data: { + name?: string; + description?: string; + joinMode?: string; + avatarUrl?: string | null; + }, +) { + const db = usePrisma(); + return db.chatRoom.update({ where: { id: roomId }, data }); +} + +export async function deleteRoom(roomId: string) { + const db = usePrisma(); + return db.chatRoom.delete({ where: { id: roomId } }); +} + +// ─── Membership ─────────────────────────────────────────────────────────────── + +export async function getMember(roomId: string, userId: string) { + const db = usePrisma(); + return db.chatRoomMember.findUnique({ + where: { roomId_userId: { roomId, userId } }, + }); +} + +export async function getRoomMembers(roomId: string) { + const db = usePrisma(); + return db.chatRoomMember.findMany({ + where: { roomId, status: "active" }, + orderBy: { joinedAt: "asc" }, + select: { userId: true, role: true, joinedAt: true }, + }); +} + +export async function joinRoom( + roomId: string, + userId: string, + status: "active" | "pending" = "active", +) { + const db = usePrisma(); + const member = await db.chatRoomMember.upsert({ + where: { roomId_userId: { roomId, userId } }, + create: { roomId, userId, role: "member", status }, + update: { status }, + }); + if (status === "active") { + await db.chatRoom.update({ + where: { id: roomId }, + data: { memberCount: { increment: 1 } }, + }); + } + return member; +} + +export async function leaveRoom(roomId: string, userId: string) { + const db = usePrisma(); + await db.chatRoomMember.delete({ + where: { roomId_userId: { roomId, userId } }, + }); + await db.chatRoom.update({ + where: { id: roomId }, + data: { memberCount: { decrement: 1 } }, + }); +} + +export async function approveRequest(roomId: string, userId: string) { + const db = usePrisma(); + await db.chatRoomMember.update({ + where: { roomId_userId: { roomId, userId } }, + data: { status: "active" }, + }); + await db.chatRoom.update({ + where: { id: roomId }, + data: { memberCount: { increment: 1 } }, + }); +} + +export async function rejectRequest(roomId: string, userId: string) { + const db = usePrisma(); + await db.chatRoomMember.delete({ + where: { roomId_userId: { roomId, userId } }, + }); +} + +export async function getPendingRequests(roomId: string) { + const db = usePrisma(); + return db.chatRoomMember.findMany({ + where: { roomId, status: "pending" }, + orderBy: { joinedAt: "asc" }, + select: { userId: true, joinedAt: true }, + }); +} + +export async function findRoomByInviteCode(code: string) { + const db = usePrisma(); + return db.chatRoom.findUnique({ where: { inviteCode: code } }); +} + +export async function banMember(roomId: string, userId: string) { + const db = usePrisma(); + const member = await db.chatRoomMember.findUnique({ + where: { roomId_userId: { roomId, userId } }, + }); + if (!member) return; + await db.chatRoomMember.update({ + where: { roomId_userId: { roomId, userId } }, + data: { status: "banned" }, + }); + if (member.status === "active") { + await db.chatRoom.update({ + where: { id: roomId }, + data: { memberCount: { decrement: 1 } }, + }); + } +} + +export async function setMemberRole( + roomId: string, + userId: string, + role: "admin" | "member", +) { + const db = usePrisma(); + return db.chatRoomMember.update({ + where: { roomId_userId: { roomId, userId } }, + data: { role }, + }); +} + +// ─── Room Messages ──────────────────────────────────────────────────────────── + +export async function getRoomMessages( + roomId: string, + cursor?: string, + limit = 50, +) { + const db = usePrisma(); + return db.chatMessage.findMany({ + where: { roomId }, + orderBy: { createdAt: "desc" }, + take: limit, + ...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}), + select: { + id: true, + userId: true, + content: true, + replyToId: true, + attachmentUrl: true, + attachmentType: true, + attachmentName: true, + likesCount: true, + createdAt: true, + replyTo: { + select: { id: true, userId: true, content: true }, + }, + }, + }); +} + +export async function createRoomMessage(data: { + userId: string; + roomId: string; + content: string; + replyToId?: string; + attachmentUrl?: string; + attachmentType?: string; + attachmentName?: string; +}) { + const db = usePrisma(); + const msg = await db.chatMessage.create({ + data: { + userId: data.userId, + content: data.content, + roomId: data.roomId, + replyToId: data.replyToId || null, + attachmentUrl: data.attachmentUrl || null, + attachmentType: data.attachmentType || null, + attachmentName: data.attachmentName || null, + }, + select: { + id: true, + userId: true, + content: true, + replyToId: true, + attachmentUrl: true, + attachmentType: true, + attachmentName: true, + likesCount: true, + createdAt: true, + replyTo: { + select: { id: true, userId: true, content: true }, + }, + }, + }); + // Bump room updatedAt + await db.chatRoom.update({ + where: { id: data.roomId }, + data: { updatedAt: new Date() }, + }); + return msg; +} + +// ─── Likes ──────────────────────────────────────────────────────────────────── + +export async function toggleChatMessageLike(userId: string, messageId: string) { + const db = usePrisma(); + const existing = await db.chatMessageLike.findUnique({ + where: { userId_messageId: { userId, messageId } }, + }); + if (existing) { + await db.chatMessageLike.delete({ + where: { userId_messageId: { userId, messageId } }, + }); + await db.chatMessage.update({ + where: { id: messageId }, + data: { likesCount: { decrement: 1 } }, + }); + return false; + } + await db.chatMessageLike.create({ data: { userId, messageId } }); + await db.chatMessage.update({ + where: { id: messageId }, + data: { likesCount: { increment: 1 } }, + }); + return true; +} + +export async function toggleDmLike(userId: string, messageId: string) { + const db = usePrisma(); + const existing = await db.directMessageLike.findUnique({ + where: { userId_messageId: { userId, messageId } }, + }); + if (existing) { + await db.directMessageLike.delete({ + where: { userId_messageId: { userId, messageId } }, + }); + await db.directMessage.update({ + where: { id: messageId }, + data: { likesCount: { decrement: 1 } }, + }); + return false; + } + await db.directMessageLike.create({ data: { userId, messageId } }); + await db.directMessage.update({ + where: { id: messageId }, + data: { likesCount: { increment: 1 } }, + }); + return true; +} + +// ─── Seed Default Groups ────────────────────────────────────────────────────── + +const SYSTEM_USER = "00000000-0000-0000-0000-000000000000"; + +const DEFAULT_ROOMS = [ + { + name: "Erfolge & Meilensteine", + description: "Teile deine Fortschritte und feiere mit der Community.", + isPublic: true, + }, + { + name: "Gemeinsam stark", + description: "Der offene Raum – Austausch, Motivation und Zusammenhalt.", + isPublic: true, + }, +]; + +export async function seedDefaultRooms() { + const db = usePrisma(); + const existing = await db.chatRoom.findMany({ + where: { isDefault: true }, + select: { id: true }, + }); + if (existing.length >= DEFAULT_ROOMS.length) return; + + for (const room of DEFAULT_ROOMS) { + const exists = await db.chatRoom.findFirst({ + where: { name: room.name, isDefault: true }, + }); + if (exists) continue; + await db.chatRoom.create({ + data: { + name: room.name, + description: room.description, + isPublic: true, + isDefault: true, + joinMode: "open", + createdBy: SYSTEM_USER, + inviteCode: randomBytes(4).toString("hex"), + memberCount: 0, + }, + }); + } +} diff --git a/backend/server/db/chat.ts b/backend/server/db/chat.ts new file mode 100644 index 0000000..eedf993 --- /dev/null +++ b/backend/server/db/chat.ts @@ -0,0 +1,139 @@ +import { usePrisma } from "../utils/prisma"; + +// ─── Gruppen-Chat ───────────────────────────────────────────────────────────── + +export async function getChatMessages(limit = 100) { + const db = usePrisma(); + return db.chatMessage.findMany({ + where: { roomId: null }, + orderBy: { createdAt: "asc" }, + take: limit, + select: { id: true, content: true, createdAt: true, userId: true }, + }); +} + +export async function createChatMessage(userId: string, content: string) { + const db = usePrisma(); + return db.chatMessage.create({ + data: { userId, content, roomId: null }, + select: { id: true, content: true, createdAt: true, userId: true }, + }); +} + +// ─── Direktnachrichten ─────────────────────────────────────────────────────── + +export async function sendDirectMessage( + senderId: string, + receiverId: string, + content: string, + opts?: { + replyToId?: string; + attachmentUrl?: string; + attachmentType?: string; + attachmentName?: string; + }, +) { + const db = usePrisma(); + return db.directMessage.create({ + data: { + senderId, + receiverId, + content, + replyToId: opts?.replyToId || null, + attachmentUrl: opts?.attachmentUrl || null, + attachmentType: opts?.attachmentType || null, + attachmentName: opts?.attachmentName || null, + }, + select: { + id: true, + content: true, + createdAt: true, + replyToId: true, + attachmentUrl: true, + attachmentType: true, + attachmentName: true, + likesCount: true, + replyTo: { + select: { id: true, senderId: true, content: true }, + }, + }, + }); +} + +export async function getDmHistory( + userId: string, + partnerId: string, + page = 1, + limit = 50, +) { + const db = usePrisma(); + const offset = (page - 1) * limit; + return db.directMessage.findMany({ + where: { + OR: [ + { senderId: userId, receiverId: partnerId }, + { senderId: partnerId, receiverId: userId }, + ], + }, + orderBy: { createdAt: "desc" }, + skip: offset, + take: limit, + select: { + id: true, + senderId: true, + receiverId: true, + content: true, + createdAt: true, + readAt: true, + replyToId: true, + attachmentUrl: true, + attachmentType: true, + attachmentName: true, + likesCount: true, + replyTo: { + select: { id: true, senderId: true, content: true }, + }, + }, + }); +} + +export async function markDmsAsRead(senderId: string, receiverId: string) { + const db = usePrisma(); + return db.directMessage.updateMany({ + where: { senderId, receiverId, readAt: null }, + data: { readAt: new Date() }, + }); +} + +export async function getDmConversations(userId: string) { + const db = usePrisma(); + // Alle DMs als Sender oder Empfänger, neueste zuerst + return db.directMessage.findMany({ + where: { + OR: [{ senderId: userId }, { receiverId: userId }], + }, + orderBy: { createdAt: "desc" }, + take: 500, + select: { + id: true, + senderId: true, + receiverId: true, + content: true, + createdAt: true, + readAt: true, + }, + }); +} + +export async function countUnreadDms(receiverId: string) { + const db = usePrisma(); + const rows = await db.directMessage.findMany({ + where: { receiverId, readAt: null }, + select: { senderId: true }, + }); + const byPartner: Record = {}; + for (const r of rows) { + byPartner[r.senderId] = (byPartner[r.senderId] ?? 0) + 1; + } + return byPartner; +} diff --git a/backend/server/db/community.ts b/backend/server/db/community.ts new file mode 100644 index 0000000..84dee20 --- /dev/null +++ b/backend/server/db/community.ts @@ -0,0 +1,434 @@ +import { usePrisma } from "../utils/prisma"; + +// ─── Posts ──────────────────────────────────────────────────────────────────── + +export async function getPosts( + category: string, + page: number, + limit: number, + currentUserId: string | null, + filterUserId?: string | null, +) { + const db = usePrisma(); + const offset = (page - 1) * limit; + + const where: any = { isModerated: false }; + if (category !== "all") { + if (category === "games") { + where.category = { in: ["game_share", "challenge"] }; + } else { + where.category = category; + } + } + if (filterUserId) { + where.userId = filterUserId; + } + + const posts = await db.communityPost.findMany({ + where, + orderBy: { createdAt: "desc" }, + skip: offset, + take: limit, + include: { + author: { + select: { + id: true, + username: true, + nickname: true, + avatar: true, + plan: true, + }, + }, + repostOf: { + include: { + author: { + select: { + id: true, + username: true, + nickname: true, + avatar: true, + plan: true, + }, + }, + }, + }, + }, + }); + + // Batch: UserScore (tier) für alle Autoren laden + const authorUserIds = [ + ...new Set( + posts + .flatMap((p) => [p.userId, p.repostOf?.userId]) + .filter((id): id is string => !!id), + ), + ]; + let userScores: Record = {}; + if (authorUserIds.length > 0) { + const scores = await db.userScore.findMany({ + where: { userId: { in: authorUserIds } }, + select: { userId: true, tier: true }, + }); + for (const s of scores) userScores[s.userId] = s.tier; + } + + // Eigene Likes laden wenn eingeloggt + let userLikes: Record = {}; + if (currentUserId && posts.length > 0) { + const postIds = posts.map((p) => p.id); + const likes = await db.postLike.findMany({ + where: { userId: currentUserId, postId: { in: postIds } }, + select: { postId: true, type: true }, + }); + for (const l of likes) { + userLikes[l.postId] = l.type as "like" | "dislike"; + } + } + + // Challenge-Status für Challenge-Posts laden + const challengeIds = posts + .map((p) => (p as any).challengeId) + .filter((id): id is string => !!id); + let challengeStatuses: Record< + string, + { + status: string; + opponentId: string | null; + opponentName: string | null; + gameType: string | null; + isLive: boolean; + } + > = {}; + if (challengeIds.length > 0) { + const challenges = await db.gameChallenge.findMany({ + where: { id: { in: challengeIds } }, + select: { + id: true, + status: true, + opponentId: true, + opponentName: true, + gameType: true, + isLive: true, + }, + }); + for (const c of challenges) { + challengeStatuses[c.id] = { + status: c.status, + opponentId: c.opponentId, + opponentName: (c as any).opponentName ?? null, + gameType: (c as any).gameType ?? null, + isLive: (c as any).isLive ?? false, + }; + } + } + + // Domain-Submission-Daten für domain_vote Posts laden + const domainVotePostIds = posts + .filter((p) => p.category === "domain_vote") + .map((p) => p.id); + type SubEntry = { + id: string; + domain: string; + yesVotes: number; + noVotes: number; + status: string; + reviewedAt: Date | null; + }; + let domainSubmissions: Record = {}; + let userDomainVotes: Record = {}; + let submissionVoters: Record< + string, + { + yes: { id: string; nickname: string; avatar: string | null }[]; + no: { id: string; nickname: string; avatar: string | null }[]; + } + > = {}; + + if (domainVotePostIds.length > 0) { + const subs = await db.domainSubmission.findMany({ + where: { postId: { in: domainVotePostIds } }, + select: { + id: true, + postId: true, + domain: true, + yesVotes: true, + noVotes: true, + status: true, + reviewedAt: true, + }, + }); + for (const s of subs) { + if (s.postId) + domainSubmissions[s.postId] = { + id: s.id, + domain: s.domain, + yesVotes: s.yesVotes, + noVotes: s.noVotes, + status: s.status, + reviewedAt: s.reviewedAt ?? null, + }; + } + if (currentUserId && subs.length > 0) { + const votes = await db.domainVote.findMany({ + where: { + userId: currentUserId, + submissionId: { in: subs.map((s) => s.id) }, + }, + select: { submissionId: true, vote: true }, + }); + const subIdToPostId = Object.fromEntries( + Object.entries(domainSubmissions).map(([pid, s]) => [s.id, pid]), + ); + for (const v of votes) { + const pid = subIdToPostId[v.submissionId]; + if (pid) userDomainVotes[pid] = v.vote as "yes" | "no"; + } + } + + // Batch: DomainVote voters for domain submissions + const submissionIds = Object.values(domainSubmissions) + .map((s) => s.id) + .filter(Boolean); + if (submissionIds.length > 0) { + const votes = await db.domainVote.findMany({ + where: { submissionId: { in: submissionIds } }, + select: { submissionId: true, vote: true, userId: true }, + }); + const voterIds = [...new Set(votes.map((v) => v.userId))]; + let voterProfiles: Record< + string, + { nickname: string | null; avatar: string | null } + > = {}; + if (voterIds.length > 0) { + const profiles = await db.profile.findMany({ + where: { id: { in: voterIds } }, + select: { id: true, nickname: true, avatar: true }, + }); + for (const p of profiles) + voterProfiles[p.id] = { nickname: p.nickname, avatar: p.avatar }; + } + for (const v of votes) { + if (!submissionVoters[v.submissionId]) + submissionVoters[v.submissionId] = { yes: [], no: [] }; + const profile = voterProfiles[v.userId]; + const voter = { + id: v.userId, + nickname: profile?.nickname ?? "Nutzer", + avatar: profile?.avatar ?? null, + }; + if (v.vote === "yes") submissionVoters[v.submissionId].yes.push(voter); + else submissionVoters[v.submissionId].no.push(voter); + } + } + } + + return { + posts, + userLikes, + challengeStatuses, + domainSubmissions, + userDomainVotes, + userScores, + submissionVoters, + }; +} + +export async function getPostById(postId: string) { + const db = usePrisma(); + return db.communityPost.findUnique({ + where: { id: postId }, + include: { + author: { + select: { + id: true, + username: true, + nickname: true, + avatar: true, + plan: true, + }, + }, + }, + }); +} + +export async function createPost( + userId: string, + category: string, + content: string, + imageUrl?: string, +) { + const db = usePrisma(); + return db.communityPost.create({ + data: { + userId, + category, + content, + imageUrl: imageUrl || null, + isAnonymous: false, + isModerated: false, + }, + include: { + author: { + select: { + id: true, + username: true, + nickname: true, + avatar: true, + plan: true, + }, + }, + }, + }); +} + +export async function deleteUserPosts(userId: string) { + const db = usePrisma(); + return db.communityPost.deleteMany({ where: { userId } }); +} + +// ─── Likes ─────────────────────────────────────────────────────────────────── + +export async function getPostLike(userId: string, postId: string) { + const db = usePrisma(); + return db.postLike.findUnique({ + where: { userId_postId: { userId, postId } }, + select: { type: true }, + }); +} + +export async function setPostLike( + userId: string, + postId: string, + type: "like" | "dislike", +) { + const db = usePrisma(); + return db.postLike.upsert({ + where: { userId_postId: { userId, postId } }, + create: { userId, postId, type }, + update: { type }, + }); +} + +export async function deletePostLike(userId: string, postId: string) { + const db = usePrisma(); + return db.postLike.delete({ + where: { userId_postId: { userId, postId } }, + }); +} + +export async function countPostLikes(postId: string) { + const db = usePrisma(); + const counts = await db.postLike.groupBy({ + by: ["type"], + where: { postId }, + _count: { type: true }, + }); + const likes = counts.find((c) => c.type === "like")?._count.type ?? 0; + const dislikes = counts.find((c) => c.type === "dislike")?._count.type ?? 0; + return { likes, dislikes }; +} + +export async function syncPostLikeCounts( + postId: string, + likes: number, + dislikes: number, +) { + const db = usePrisma(); + return db.communityPost.update({ + where: { id: postId }, + data: { likesCount: likes, dislikesCount: dislikes }, + }); +} + +// ─── Comments (replies) ────────────────────────────────────────────────────── + +export async function getCommentsByPost( + postId: string, + currentUserId: string | null, +) { + const db = usePrisma(); + const comments = await db.communityReply.findMany({ + where: { postId }, + orderBy: { createdAt: "asc" }, + take: 200, + include: { + author: { + select: { id: true, username: true, nickname: true, avatar: true }, + }, + }, + }); + + let userLikes = new Set(); + if (currentUserId && comments.length > 0) { + const commentIds = comments.map((c) => c.id); + const likes = await db.commentLike.findMany({ + where: { userId: currentUserId, commentId: { in: commentIds } }, + select: { commentId: true }, + }); + for (const l of likes) { + userLikes.add(l.commentId); + } + } + + return { comments, userLikes }; +} + +export async function createComment( + userId: string, + postId: string, + content: string, + parentReplyId: string | null, +) { + const db = usePrisma(); + const [reply] = await Promise.all([ + db.communityReply.create({ + data: { userId, postId, content, parentReplyId, isAnonymous: false }, + select: { + id: true, + content: true, + createdAt: true, + likesCount: true, + parentReplyId: true, + }, + }), + db.communityPost.update({ + where: { id: postId }, + data: { commentsCount: { increment: 1 } }, + }), + ]); + return reply; +} + +// ─── Comment Likes ──────────────────────────────────────────────────────────── + +export async function getCommentLike(userId: string, commentId: string) { + const db = usePrisma(); + return db.commentLike.findUnique({ + where: { userId_commentId: { userId, commentId } }, + }); +} + +export async function createCommentLike(userId: string, commentId: string) { + const db = usePrisma(); + return db.commentLike.create({ data: { userId, commentId } }); +} + +export async function deleteCommentLike(userId: string, commentId: string) { + const db = usePrisma(); + return db.commentLike.delete({ + where: { userId_commentId: { userId, commentId } }, + }); +} + +export async function getCommentLikeCount(commentId: string) { + const db = usePrisma(); + return db.commentLike.count({ where: { commentId } }); +} + +export async function syncCommentLikeCount(commentId: string, count: number) { + const db = usePrisma(); + return db.communityReply.update({ + where: { id: commentId }, + data: { likesCount: count }, + }); +} diff --git a/backend/server/db/cooldown.ts b/backend/server/db/cooldown.ts new file mode 100644 index 0000000..35de545 --- /dev/null +++ b/backend/server/db/cooldown.ts @@ -0,0 +1,51 @@ +import { usePrisma } from "../utils/prisma"; + +/** + * Returns the active CooldownRequest for a user: + * not resolved, not cancelled, cooldownEndsAt in the future. + * (Expired but not yet resolved entries are also returned so the caller can resolve them.) + */ +export async function getActiveCooldown(userId: string) { + const db = usePrisma(); + return db.cooldownRequest.findFirst({ + where: { + userId, + resolvedAt: null, + cancelledAt: null, + }, + orderBy: { cooldownStartedAt: "desc" }, + }); +} + +export async function createCooldown( + userId: string, + jti: string, + cooldownEndsAt: Date, + reason?: string, +) { + const db = usePrisma(); + return db.cooldownRequest.create({ + data: { + userId, + reason: reason ?? null, + cooldownEndsAt, + tokenJti: jti, + }, + }); +} + +export async function resolveCooldown(id: string) { + const db = usePrisma(); + return db.cooldownRequest.update({ + where: { id }, + data: { resolvedAt: new Date() }, + }); +} + +export async function cancelCooldown(id: string) { + const db = usePrisma(); + return db.cooldownRequest.update({ + where: { id }, + data: { cancelledAt: new Date() }, + }); +} diff --git a/backend/server/db/devices.ts b/backend/server/db/devices.ts new file mode 100644 index 0000000..60474df --- /dev/null +++ b/backend/server/db/devices.ts @@ -0,0 +1,146 @@ +import { usePrisma } from "../utils/prisma"; + +/** + * Device-Binding pro User. Free=1, Pro=1, Legend=3 (siehe plan-features.maxDevices). + * deviceId kommt vom Frontend via Capacitor Device.getId() (persistent UUID). + */ + +export interface DeviceRecord { + id: string; + deviceId: string; + platform: string; + model: string | null; + name: string | null; + lastSeenAt: Date; + createdAt: Date; +} + +/** Liste aller Devices eines Users, aktuellstes zuerst. */ +export async function listUserDevices(userId: string): Promise { + const db = usePrisma(); + return db.userDevice.findMany({ + where: { userId }, + orderBy: { lastSeenAt: "desc" }, + select: { + id: true, + deviceId: true, + platform: true, + model: true, + name: true, + lastSeenAt: true, + createdAt: true, + }, + }); +} + +/** Gibt das Device zurück wenn registriert; sonst null. */ +export async function findUserDevice( + userId: string, + deviceId: string, +): Promise { + const db = usePrisma(); + return db.userDevice.findUnique({ + where: { userId_deviceId: { userId, deviceId } }, + select: { + id: true, + deviceId: true, + platform: true, + model: true, + name: true, + lastSeenAt: true, + createdAt: true, + }, + }); +} + +/** + * Idempotente Registrierung. Wenn Device bereits existiert: Touch lastSeenAt. + * Wenn nicht existiert UND Limit erreicht: throw mit Liste der existierenden Devices. + */ +export async function registerDevice(opts: { + userId: string; + deviceId: string; + platform: string; + model?: string | null; + name?: string | null; + maxDevices: number; +}): Promise<{ + device: DeviceRecord; + created: boolean; +}> { + const db = usePrisma(); + + // Idempotent: existiert das Device schon? + const existing = await findUserDevice(opts.userId, opts.deviceId); + if (existing) { + // model/name beim Re-Register aktualisieren — User-Agent oder OS-Version + // kann sich geändert haben (App-Update, OS-Upgrade, iPad-Detection-Fix). + const updated = await db.userDevice.update({ + where: { id: existing.id }, + data: { + lastSeenAt: new Date(), + ...(opts.model !== undefined && { model: opts.model }), + ...(opts.name !== undefined && { name: opts.name }), + }, + select: { + id: true, + deviceId: true, + platform: true, + model: true, + name: true, + lastSeenAt: true, + createdAt: true, + }, + }); + return { device: updated, created: false }; + } + + // Neues Device — Limit prüfen + const count = await db.userDevice.count({ where: { userId: opts.userId } }); + if (count >= opts.maxDevices) { + throw Object.assign(new Error("device_limit_reached"), { + code: "DEVICE_LIMIT_REACHED", + currentCount: count, + max: opts.maxDevices, + }); + } + + const created = await db.userDevice.create({ + data: { + userId: opts.userId, + deviceId: opts.deviceId, + platform: opts.platform, + model: opts.model ?? null, + name: opts.name ?? null, + }, + select: { + id: true, + deviceId: true, + platform: true, + model: true, + name: true, + lastSeenAt: true, + createdAt: true, + }, + }); + return { device: created, created: true }; +} + +/** Touch lastSeenAt — wird in der Auth-Middleware bei jedem Request aufgerufen. */ +export async function touchDevice(userId: string, deviceId: string): Promise { + const db = usePrisma(); + await db.userDevice + .updateMany({ + where: { userId, deviceId }, + data: { lastSeenAt: new Date() }, + }) + .catch(() => { + /* race-safe: wenn Device gerade gelöscht wurde */ + }); +} + +/** User entfernt ein eigenes Device — gibt Slot frei. */ +export async function deleteUserDevice(userId: string, id: string): Promise { + const db = usePrisma(); + await db.userDevice.deleteMany({ where: { id, userId } }); +} diff --git a/backend/server/db/domains.ts b/backend/server/db/domains.ts new file mode 100644 index 0000000..84ee2f1 --- /dev/null +++ b/backend/server/db/domains.ts @@ -0,0 +1,430 @@ +import { usePrisma } from "../utils/prisma"; +import { createNotification } from "./notifications"; + +// ─── Custom Domains ─────────────────────────────────────────────────────────── + +export async function getUserCustomDomains(userId: string) { + const db = usePrisma(); + const rows = await db.userCustomDomain.findMany({ + where: { userId }, + orderBy: { addedAt: "desc" }, + select: { + id: true, + domain: true, + status: true, + postId: true, + addedAt: true, + submission: { + select: { id: true, yesVotes: true, noVotes: true, status: true }, + }, + }, + }); + return rows; +} + +/** + * Counts domains that occupy a slot (active + submitted). + * approved → slot freed (domain joined global list) + * rejected → slot freed (user can re-submit or delete) + */ +export async function countActiveCustomDomains(userId: string) { + const db = usePrisma(); + return db.userCustomDomain.count({ + where: { userId, status: { notIn: ["approved", "rejected"] } }, + }); +} + +export async function addUserCustomDomain( + userId: string, + domain: string, + source = "manual", +) { + const db = usePrisma(); + return db.userCustomDomain.create({ + data: { userId, domain, source }, + select: { id: true, domain: true }, + }); +} + +export async function deleteUserCustomDomain(id: string, userId: string) { + const db = usePrisma(); + // Cannot delete submitted/approved domains (protect integrity) + const existing = await db.userCustomDomain.findFirst({ + where: { id, userId }, + select: { status: true }, + }); + if (!existing) throw Object.assign(new Error("Not found"), { code: "P2025" }); + if (existing.status === "submitted" || existing.status === "approved") { + throw Object.assign( + new Error( + "Eingereichte oder genehmigte Domains können nicht gelöscht werden", + ), + { code: "DOMAIN_LOCKED" }, + ); + } + return db.userCustomDomain.delete({ where: { id, userId } }); +} + +export async function deleteAllUserCustomDomains(userId: string) { + const db = usePrisma(); + return db.userCustomDomain.deleteMany({ where: { userId } }); +} + +// ─── Domain Submissions ─────────────────────────────────────────────────────── + +// Net-Vote-Schwelle (yesVotes - noVotes) damit eine Submission von der +// Community-Vote-Phase in die Admin-Review-Phase wandert. +export const NET_VOTE_THRESHOLD = 10; + +// Submission-Status: +// "pending" → Community-Voting-Phase (nur Pro) +// "in_review" → wartet auf Admin (Legend direkt, Pro nach 10 Netto-Yes-Votes) +// "approved" → in globaler Blocklist +// "rejected" → vom Admin abgelehnt +export type SubmissionPlan = "free" | "pro" | "legend"; + +export async function submitDomainForReview( + userId: string, + customDomainId: string, + plan: SubmissionPlan, + postId?: string, +) { + if (plan === "free") { + throw Object.assign(new Error("Free-Plan kann keine Domains einreichen"), { + code: "PLAN_NO_SUBMIT", + }); + } + const submissionStatus = plan === "legend" ? "in_review" : "pending"; + const db = usePrisma(); + return db.$transaction(async (tx) => { + // Mark custom domain as submitted + const domain = await tx.userCustomDomain.update({ + where: { id: customDomainId, userId }, + data: { status: "submitted", postId: postId ?? null }, + select: { id: true, domain: true }, + }); + // Create submission record + const submission = await tx.domainSubmission.create({ + data: { + userId, + domain: domain.domain, + customDomainId, + postId: postId ?? null, + status: submissionStatus, + }, + }); + return { domain, submission }; + }); +} + +export async function castDomainVote( + userId: string, + submissionId: string, + vote: "yes" | "no", +) { + const db = usePrisma(); + return db.$transaction(async (tx) => { + // Upsert vote (change allowed) + const existing = await tx.domainVote.findUnique({ + where: { userId_submissionId: { userId, submissionId } }, + }); + if (existing) { + if (existing.vote === vote) return { changed: false }; + await tx.domainVote.update({ + where: { userId_submissionId: { userId, submissionId } }, + data: { vote }, + }); + } else { + await tx.domainVote.create({ data: { userId, submissionId, vote } }); + } + + // Recount + const [yes, no] = await Promise.all([ + tx.domainVote.count({ where: { submissionId, vote: "yes" } }), + tx.domainVote.count({ where: { submissionId, vote: "no" } }), + ]); + const updated = await tx.domainSubmission.update({ + where: { id: submissionId }, + data: { yesVotes: yes, noVotes: no }, + }); + + // Netto-Yes (yes - no) muss die Schwelle erreichen, dann wandert die + // Submission in die Admin-Review-Phase. Approval erfolgt erst durch Admin. + let movedToReview = false; + if (updated.status === "pending" && yes - no >= NET_VOTE_THRESHOLD) { + await tx.domainSubmission.update({ + where: { id: submissionId }, + data: { status: "in_review" }, + }); + movedToReview = true; + } + + return { yesVotes: yes, noVotes: no, movedToReview }; + }); +} + +async function approveDomainSubmissionTx( + tx: any, + submissionId: string, + customDomainId: string, + domain: string, +) { + await tx.domainSubmission.update({ + where: { id: submissionId }, + data: { status: "approved", reviewedAt: new Date() }, + }); + await tx.userCustomDomain.update({ + where: { id: customDomainId }, + data: { status: "approved" }, + }); + // Add to global blocklist + await tx.blocklistDomain.upsert({ + where: { domain }, + create: { domain, source: "community", isActive: true }, + update: { isActive: true }, + }); +} + +export async function adminApproveSubmission( + submissionId: string, + reviewNote?: string, +) { + const db = usePrisma(); + const sub = await db.domainSubmission.findUniqueOrThrow({ + where: { id: submissionId }, + select: { + customDomainId: true, + domain: true, + status: true, + userId: true, + postId: true, + }, + }); + // Admin darf in beiden offenen Phasen approven (Vote oder Review) + if (sub.status !== "pending" && sub.status !== "in_review") { + throw new Error("Submission already resolved"); + } + + await db.$transaction((tx) => + approveDomainSubmissionTx(tx, submissionId, sub.customDomainId, sub.domain), + ); + + // Alle anderen User die diese Domain als Custom Domain haben → Slot freigeben + benachrichtigen + const affectedDomains = await db.userCustomDomain.findMany({ + where: { + domain: sub.domain, + // Submitter's domain already handled in approveDomainSubmissionTx + id: { not: sub.customDomainId }, + status: { notIn: ["approved", "rejected"] }, + }, + select: { id: true, userId: true }, + }); + + if (affectedDomains.length > 0) { + // Batch-Update: alle auf "approved" setzen (Slot freigeben) + await db.userCustomDomain.updateMany({ + where: { id: { in: affectedDomains.map((d) => d.id) } }, + data: { status: "approved" }, + }); + + // Notification an jeden betroffenen User + await Promise.allSettled( + affectedDomains.map((d) => + createNotification({ + recipientId: d.userId, + type: "domain_accepted", + actorName: "ReBreak", + postId: sub.postId ?? undefined, + preview: sub.domain, + }), + ), + ); + } + + // Auch Submitter benachrichtigen (Admin hat seine Domain genehmigt) + await createNotification({ + recipientId: sub.userId, + type: "domain_accepted", + actorName: "ReBreak Admin", + postId: sub.postId ?? undefined, + preview: sub.domain, + }).catch(() => {}); + + return db.domainSubmission.findUnique({ where: { id: submissionId } }); +} + +export async function adminRejectSubmission( + submissionId: string, + reviewNote?: string, +) { + const db = usePrisma(); + // Submission + zugehörige Custom-Domain laden (für Notification + Cleanup) + const sub = await db.domainSubmission.findUniqueOrThrow({ + where: { id: submissionId }, + select: { + userId: true, + domain: true, + postId: true, + customDomainId: true, + }, + }); + + await db.$transaction(async (tx) => { + await tx.domainSubmission.update({ + where: { id: submissionId }, + data: { + status: "rejected", + reviewedAt: new Date(), + reviewNote: reviewNote ?? null, + }, + }); + // Custom-Domain des Submitters komplett aus seiner Liste entfernen + // (DomainSubmission via onDelete: Cascade automatisch mitgelöscht? NEIN — + // Submission referenziert customDomain. Wir haben Submission gerade upgedatet, + // also sicher löschen via Cascade von customDomain → submission.) + await tx.userCustomDomain.delete({ + where: { id: sub.customDomainId }, + }); + }); + + // Submitter benachrichtigen — Notification von ReBreak (System) + await createNotification({ + recipientId: sub.userId, + type: "domain_rejected", + actorName: "ReBreak", + postId: sub.postId ?? undefined, + preview: sub.domain, + }).catch(() => {}); + + return { customDomainId: sub.customDomainId }; +} + +export async function getPendingSubmissions() { + const db = usePrisma(); + return db.domainSubmission.findMany({ + where: { status: { in: ["pending", "in_review"] } }, + orderBy: [{ status: "asc" }, { yesVotes: "desc" }], + select: { + id: true, + domain: true, + yesVotes: true, + noVotes: true, + status: true, + createdAt: true, + userId: true, + postId: true, + customDomain: { select: { id: true } }, + }, + }); +} + +// ─── Global Blocklist ───────────────────────────────────────────────────────── + +export async function getActiveBlocklistCount() { + const db = usePrisma(); + return db.blocklistDomain.count({ where: { isActive: true } }); +} + +export async function getActiveBlocklistDomains() { + const db = usePrisma(); + return db.blocklistDomain.findMany({ + where: { isActive: true }, + select: { domain: true }, + }); +} + +export async function isBlocklistedDomain( + domain: string, + userId: string, +): Promise { + const db = usePrisma(); + const [global, custom] = await Promise.all([ + db.blocklistDomain.findFirst({ + where: { domain, isActive: true }, + select: { domain: true }, + }), + db.userCustomDomain.findFirst({ + where: { domain, userId }, + select: { domain: true }, + }), + ]); + return !!(global || custom); +} + +export async function getBlocklistedDomainsSet( + domains: string[], + userId: string, + includeGlobal = true, +): Promise> { + if (domains.length === 0) return new Set(); + const db = usePrisma(); + const unique = [...new Set(domains)]; + + // Alle möglichen Parent-Domains erzeugen: "mail.casino.de" → ["mail.casino.de", "casino.de"] + const allVariants = [ + ...new Set( + unique.flatMap((d) => { + const parts = d.split("."); + return parts + .map((_, i) => parts.slice(i).join(".")) + .filter((v) => v.includes(".")); + }), + ), + ]; + + const queries: Promise<{ domain: string }[]>[] = []; + + if (includeGlobal) { + queries.push( + db.blocklistDomain.findMany({ + where: { domain: { in: allVariants }, isActive: true }, + select: { domain: true }, + }), + ); + } + + queries.push( + db.userCustomDomain.findMany({ + where: { domain: { in: allVariants }, userId }, + select: { domain: true }, + }), + ); + + const results = await Promise.all(queries); + const blockedSet = new Set(results.flatMap((r) => r.map((d) => d.domain))); + + // Zurück auf Original-Domains mappen: wenn irgendein Variant geblockt ist → Original blocken + const result = new Set(); + for (const orig of unique) { + const parts = orig.split("."); + const variants = parts + .map((_, i) => parts.slice(i).join(".")) + .filter((v) => v.includes(".")); + if (variants.some((v) => blockedSet.has(v))) result.add(orig); + } + return result; +} + +export async function upsertBlocklistDomains( + domains: { domain: string; source: string }[], +) { + const db = usePrisma(); + // Batch upsert in chunks + const CHUNK = 5000; + let total = 0; + for (let i = 0; i < domains.length; i += CHUNK) { + const chunk = domains.slice(i, i + CHUNK).map((d) => ({ + ...d, + isActive: true, + })); + for (const d of chunk) { + await db.blocklistDomain.upsert({ + where: { domain: d.domain }, + create: d, + update: { isActive: true }, + }); + total++; + } + } + return total; +} diff --git a/backend/server/db/lyraMemory.ts b/backend/server/db/lyraMemory.ts new file mode 100644 index 0000000..34e5b4b --- /dev/null +++ b/backend/server/db/lyraMemory.ts @@ -0,0 +1,193 @@ +/** + * DB-Layer: LyraMemory + * + * Strukturierte persistente User-Erinnerungen für den Lyra-Coach. + * Enthält Art-9-Gesundheitsdaten — kein direkter Zugriff außer über diese Funktionen. + * + * Constraints: + * - Max MAX_MEMORIES_PER_USER pro User (Datenminimierung + System-Prompt-Budget) + * - upsertMemory: ähnlicher Content (Substring) → Update statt Insert + */ +import { usePrisma } from "../utils/prisma"; +import type { LyraMemoryType } from "../generated/prisma"; + +export type { LyraMemoryType }; + +export interface LyraMemoryRow { + id: string; + userId: string; + type: LyraMemoryType; + content: string; + confidence: number; + source: string | null; + createdAt: Date; + updatedAt: Date; + lastReferencedAt: Date | null; +} + +const MAX_MEMORIES_PER_USER = 30; +const LOG = "[lyra-memory]"; + +/** + * Alle Memories eines Users, sortiert nach Relevanz (zuletzt referenziert → neueste). + */ +export async function getMemoriesForUser( + userId: string, +): Promise { + const db = usePrisma(); + return db.lyraMemory.findMany({ + where: { userId }, + orderBy: [ + { lastReferencedAt: { sort: "desc", nulls: "last" } }, + { createdAt: "desc" }, + ], + }) as Promise; +} + +/** + * Upsert: wenn Content-Substring eines bestehenden Eintrags gleichen Typs + * bereits vorhanden ist → update confidence + source. Sonst insert. + * Hält Max-Constraint ein: bei > MAX_MEMORIES_PER_USER werden älteste + * mit niedrigster confidence gelöscht. + */ +export async function upsertMemory( + userId: string, + type: LyraMemoryType, + content: string, + source?: string, + confidence = 0.7, +): Promise { + const db = usePrisma(); + const trimmedContent = content.slice(0, 500).trim(); + + // Similarity-Check: existierende Memories gleichen Typs laden + const existing = await db.lyraMemory.findMany({ + where: { userId, type }, + select: { id: true, content: true, confidence: true }, + }); + + // Substring-Match (case-insensitive) + const contentLower = trimmedContent.toLowerCase(); + const match = existing.find((m) => { + const mLower = m.content.toLowerCase(); + return ( + mLower.includes(contentLower) || + contentLower.includes(mLower) || + // 70%-Overlap-Heuristik für kurze Strings + (contentLower.length > 20 && + mLower.length > 20 && + levenshteinSimilarity(contentLower, mLower) > 0.7) + ); + }); + + let result: LyraMemoryRow; + + if (match) { + // Update: nimm höhere confidence + neuen Source + const newConfidence = Math.max(match.confidence, confidence); + result = (await db.lyraMemory.update({ + where: { id: match.id }, + data: { + content: trimmedContent, + confidence: newConfidence, + source: source ?? undefined, + updatedAt: new Date(), + }, + })) as LyraMemoryRow; + console.log( + `${LOG} updated memory ${match.id} (type=${type}, conf=${newConfidence})`, + ); + } else { + result = (await db.lyraMemory.create({ + data: { + userId, + type, + content: trimmedContent, + confidence, + source: source ?? null, + }, + })) as LyraMemoryRow; + console.log( + `${LOG} created memory ${result.id} (type=${type}, conf=${confidence})`, + ); + + // Max-Constraint enforzen + await enforceMaxMemories(userId); + } + + return result; +} + +/** + * Markiert Memories als zuletzt referenziert (in System-Prompt injiziert). + * Fire-and-forget geeignet — wirft nicht. + */ +export async function markReferenced(memoryIds: string[]): Promise { + if (!memoryIds.length) return; + const db = usePrisma(); + try { + await db.lyraMemory.updateMany({ + where: { id: { in: memoryIds } }, + data: { lastReferencedAt: new Date() }, + }); + console.log(`${LOG} markReferenced: ${memoryIds.length} memories`); + } catch (e) { + console.error(`${LOG} markReferenced error:`, e); + } +} + +/** + * User-seitiges Delete (V2.5-ready — Endpoint kommt später). + */ +export async function deleteMemoryById( + userId: string, + memoryId: string, +): Promise { + const db = usePrisma(); + await db.lyraMemory.deleteMany({ + where: { id: memoryId, userId }, + }); + console.log(`${LOG} deleted memory ${memoryId} for user ${userId}`); +} + +// ── Interne Helpers ────────────────────────────────────────────────────────── + +async function enforceMaxMemories(userId: string): Promise { + const db = usePrisma(); + const total = await db.lyraMemory.count({ where: { userId } }); + if (total <= MAX_MEMORIES_PER_USER) return; + + const overflow = total - MAX_MEMORIES_PER_USER; + // Älteste mit niedrigster confidence zuerst löschen + const candidates = await db.lyraMemory.findMany({ + where: { userId }, + orderBy: [{ confidence: "asc" }, { createdAt: "asc" }], + take: overflow, + select: { id: true }, + }); + const ids = candidates.map((c) => c.id); + await db.lyraMemory.deleteMany({ where: { id: { in: ids } } }); + console.log(`${LOG} enforceMax: deleted ${ids.length} old memories`); +} + +/** + * Einfache Levenshtein-basierte Ähnlichkeit (0.0-1.0). + * Nur für kurze Strings — O(n*m) ist ok für max 500 chars. + */ +function levenshteinSimilarity(a: string, b: string): number { + if (a === b) return 1; + const la = a.length; + const lb = b.length; + const dp: number[][] = Array.from({ length: la + 1 }, (_, i) => + Array.from({ length: lb + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0)), + ); + for (let i = 1; i <= la; i++) { + for (let j = 1; j <= lb; j++) { + dp[i][j] = + a[i - 1] === b[j - 1] + ? dp[i - 1][j - 1] + : 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]); + } + } + return 1 - dp[la][lb] / Math.max(la, lb); +} diff --git a/backend/server/db/mail.ts b/backend/server/db/mail.ts new file mode 100644 index 0000000..4db90ea --- /dev/null +++ b/backend/server/db/mail.ts @@ -0,0 +1,200 @@ +import { usePrisma } from "../utils/prisma"; + +export async function getMailConnections(userId: string) { + const db = usePrisma(); + return db.mailConnection.findMany({ + where: { userId, isActive: true }, + orderBy: { createdAt: "asc" }, + }); +} + +export async function getAllActiveMailUserIds() { + const db = usePrisma(); + const rows = await db.mailConnection.findMany({ + where: { isActive: true, nextScanAt: { lte: new Date() } }, + select: { userId: true }, + distinct: ["userId"], + }); + return rows.map((r) => r.userId); +} + +export async function countMailConnections(userId: string) { + const db = usePrisma(); + return db.mailConnection.count({ where: { userId, isActive: true } }); +} + +export async function upsertMailConnection(data: { + userId: string; + email: string; + provider: string; + providerName: string; + imapHost: string; + imapPort: number; + passwordEncrypted: string; + rejectUnauthorized?: boolean; + useStarttls?: boolean; +}) { + const db = usePrisma(); + return db.mailConnection.upsert({ + where: { userId_email: { userId: data.userId, email: data.email } }, + create: { + ...data, + isActive: true, + rejectUnauthorized: data.rejectUnauthorized ?? true, + useStarttls: data.useStarttls ?? false, + }, + update: { + providerName: data.providerName, + imapHost: data.imapHost, + imapPort: data.imapPort, + passwordEncrypted: data.passwordEncrypted, + rejectUnauthorized: data.rejectUnauthorized ?? true, + useStarttls: data.useStarttls ?? false, + isActive: true, + }, + }); +} + +export async function deleteMailConnection( + userId: string, + connectionId: string, +) { + const db = usePrisma(); + return db.mailConnection.deleteMany({ + where: { id: connectionId, userId }, + }); +} + +export async function deleteAllMailConnections(userId: string) { + const db = usePrisma(); + return db.mailConnection.deleteMany({ where: { userId } }); +} + +export async function updateMailConnectionInterval( + userId: string, + connectionId: string, + interval: number, +) { + const db = usePrisma(); + return db.mailConnection.updateMany({ + where: { id: connectionId, userId }, + data: { scanInterval: interval }, + }); +} + +export async function updateMailConnectionScanStats( + connectionId: string, + scanned: number, + blocked: number, + currentBlocked: number, + currentScanned: number, + scanIntervalHours: number, +) { + const db = usePrisma(); + return db.mailConnection.update({ + where: { id: connectionId }, + data: { + lastScannedAt: new Date(), + emailsBlocked: currentBlocked + blocked, + emailsScanned: currentScanned + scanned, + nextScanAt: new Date(Date.now() + scanIntervalHours * 3_600_000), + }, + }); +} + +export async function getMailBlockedStats(userId: string) { + const db = usePrisma(); + const since7d = new Date(Date.now() - 7 * 86_400_000); + return db.mailBlocked.findMany({ + where: { userId, createdAt: { gte: since7d } }, + select: { createdAt: true }, + }); +} + +export async function isMailAlreadyBlocked( + gmailMessageId: string, + userId: string, +) { + const db = usePrisma(); + const existing = await db.mailBlocked.findFirst({ + where: { gmailMessageId, userId }, + select: { id: true }, + }); + return !!existing; +} + +export async function getAlreadyBlockedUidSet( + uids: string[], + userId: string, +): Promise> { + if (uids.length === 0) return new Set(); + const db = usePrisma(); + const existing = await db.mailBlocked.findMany({ + where: { gmailMessageId: { in: uids }, userId }, + select: { gmailMessageId: true }, + }); + return new Set(existing.map((e) => e.gmailMessageId)); +} + +export async function insertMailBlocked( + entries: { + userId: string; + connectionId: string; + gmailMessageId: string; + senderEmail: string; + senderName: string | null; + subject: string; + receivedAt: Date; + action: string; + }[], +) { + if (entries.length === 0) return; + const db = usePrisma(); + await db.mailBlocked.createMany({ data: entries, skipDuplicates: true }); +} + +export async function getImapProxyAccounts(userId: string) { + const db = usePrisma(); + return db.imapProxyAccount.findMany({ where: { userId } }); +} + +export async function upsertImapProxyAccount(data: { + userId: string; + proxyUsername: string; + proxyPassword: string; + connectionId: string; +}) { + const db = usePrisma(); + return db.imapProxyAccount.upsert({ + where: { connectionId: data.connectionId }, + create: data, + update: { proxyPassword: data.proxyPassword }, + }); +} + +export async function deleteOldMailBlocked(userId: string) { + const db = usePrisma(); + const cutoff = new Date(Date.now() - 24 * 3_600_000); + return db.mailBlocked.deleteMany({ + where: { userId, createdAt: { lt: cutoff } }, + }); +} + +export async function getMailBlockedPaginated( + userId: string, + page: number, + limit = 20, +) { + const db = usePrisma(); + const offset = (page - 1) * limit; + const [results, total] = await Promise.all([ + db.mailBlocked.findMany({ + where: { userId }, + orderBy: { createdAt: "desc" }, + skip: offset, + take: limit, + }), + db.mailBlocked.count({ where: { userId } }), + ]); + return { results, total, page, pages: Math.ceil(total / limit) }; +} diff --git a/backend/server/db/notifications.ts b/backend/server/db/notifications.ts new file mode 100644 index 0000000..d3882ae --- /dev/null +++ b/backend/server/db/notifications.ts @@ -0,0 +1,52 @@ +import { usePrisma } from "../utils/prisma"; + +export async function createNotification(data: { + recipientId: string; + type: string; + actorName: string; + actorAvatar?: string; + postId?: string; + preview?: string; +}) { + const db = usePrisma(); + return db.notification.create({ data }); +} + +export async function getNotifications(userId: string) { + const db = usePrisma(); + return db.notification.findMany({ + where: { recipientId: userId }, + orderBy: { createdAt: "desc" }, + take: 30, + }); +} + +export async function countUnread(userId: string) { + const db = usePrisma(); + return db.notification.count({ + where: { recipientId: userId, readAt: null }, + }); +} + +export async function markAllRead(userId: string) { + const db = usePrisma(); + return db.notification.updateMany({ + where: { recipientId: userId, readAt: null }, + data: { readAt: new Date() }, + }); +} + +export async function deleteNotification(notifId: string, userId: string) { + const db = usePrisma(); + return db.notification.deleteMany({ + where: { id: notifId, recipientId: userId }, + }); +} + +export async function deleteOldNotifications() { + const db = usePrisma(); + const threeDaysAgo = new Date(Date.now() - 3 * 24 * 60 * 60 * 1000); + return db.notification.deleteMany({ + where: { createdAt: { lt: threeDaysAgo } }, + }); +} diff --git a/backend/server/db/profile.ts b/backend/server/db/profile.ts new file mode 100644 index 0000000..ea38317 --- /dev/null +++ b/backend/server/db/profile.ts @@ -0,0 +1,23 @@ +import { usePrisma } from "../utils/prisma"; + +export async function getProfile(userId: string) { + const db = usePrisma(); + return db.profile.findUnique({ where: { id: userId } }); +} + +export async function updateProfile( + userId: string, + data: Partial<{ + username: string | null; + nickname: string | null; + avatar: string | null; + }>, +) { + const db = usePrisma(); + return db.profile.update({ where: { id: userId }, data }); +} + +export async function deleteProfile(userId: string) { + const db = usePrisma(); + return db.profile.delete({ where: { id: userId } }); +} diff --git a/backend/server/db/scores.ts b/backend/server/db/scores.ts new file mode 100644 index 0000000..f6557df --- /dev/null +++ b/backend/server/db/scores.ts @@ -0,0 +1,61 @@ +import { usePrisma } from "../utils/prisma"; + +export type ScoreEventType = + | "post_created" + | "upvote_received" + | "custom_domain_submitted" + | "daily_checkin" + | "chat_message" + | "streak_milestone" + | "helped_user"; + +const POINTS: Record = { + post_created: 50, + upvote_received: 10, + custom_domain_submitted: 30, + daily_checkin: 5, + chat_message: 2, + streak_milestone: 100, + helped_user: 20, +}; + +export async function awardPoints( + userId: string, + type: ScoreEventType, + meta?: Record, +) { + const db = usePrisma(); + const points = POINTS[type] ?? 0; + if (points === 0) return; + + await db.scoreEvent.create({ + data: { userId, eventType: type, points, meta: meta ?? null }, + }); +} + +export async function getUserScore(userId: string) { + const db = usePrisma(); + return db.userScore.findUnique({ + where: { userId }, + select: { totalPoints: true, tier: true, updatedAt: true }, + }); +} + +export async function getRecentScoreEvents(userId: string, limit = 20) { + const db = usePrisma(); + return db.scoreEvent.findMany({ + where: { userId }, + orderBy: { createdAt: "desc" }, + take: limit, + select: { eventType: true, points: true, createdAt: true, meta: true }, + }); +} + +export async function getLeaderboard(limit = 50) { + const db = usePrisma(); + return db.userScore.findMany({ + orderBy: { totalPoints: "desc" }, + take: limit, + select: { userId: true, totalPoints: true, tier: true }, + }); +} diff --git a/backend/server/db/social.ts b/backend/server/db/social.ts new file mode 100644 index 0000000..1c5b09c --- /dev/null +++ b/backend/server/db/social.ts @@ -0,0 +1,73 @@ +import { usePrisma } from "../utils/prisma"; + +export async function getFollowRelation( + followerId: string, + followingId: string, +) { + const db = usePrisma(); + return db.userFollow.findUnique({ + where: { followerId_followingId: { followerId, followingId } }, + }); +} + +export async function createFollow(followerId: string, followingId: string) { + const db = usePrisma(); + const exists = await db.userFollow.findUnique({ + where: { followerId_followingId: { followerId, followingId } }, + }); + if (exists) return; + await db.userFollow.create({ data: { followerId, followingId } }); + await db.profile.update({ + where: { id: followingId }, + data: { followersCount: { increment: 1 } }, + }); +} + +export async function deleteFollow(followerId: string, followingId: string) { + const db = usePrisma(); + const exists = await db.userFollow.findUnique({ + where: { followerId_followingId: { followerId, followingId } }, + }); + if (!exists) return; + await db.userFollow.delete({ + where: { followerId_followingId: { followerId, followingId } }, + }); + // Nie unter 0 + const profile = await db.profile.findUnique({ + where: { id: followingId }, + select: { followersCount: true }, + }); + if (profile && profile.followersCount > 0) { + await db.profile.update({ + where: { id: followingId }, + data: { followersCount: { decrement: 1 } }, + }); + } +} + +/** Gibt ein Set der userIds zurück, denen followerId bereits folgt (Batch-Variante). */ +export async function getFollowingSet( + followerId: string, + followingIds: string[], +): Promise> { + if (followingIds.length === 0) return new Set(); + const db = usePrisma(); + const rows = await db.userFollow.findMany({ + where: { followerId, followingId: { in: followingIds } }, + select: { followingId: true }, + }); + return new Set(rows.map((r) => r.followingId)); +} + +export async function getProfileWithFollowers(userId: string) { + const db = usePrisma(); + return db.profile.findUnique({ + where: { id: userId }, + select: { + followersCount: true, + username: true, + avatar: true, + nickname: true, + }, + }); +} diff --git a/backend/server/db/sosSession.ts b/backend/server/db/sosSession.ts new file mode 100644 index 0000000..1ecd885 --- /dev/null +++ b/backend/server/db/sosSession.ts @@ -0,0 +1,41 @@ +import { usePrisma } from "../utils/prisma"; + +export interface SosSessionInput { + startedAt?: string | Date; + endedAt?: string | Date | null; + durationSec?: number | null; + messages: Array<{ role: string; content: string; timestamp?: string }>; + gamesPlayed?: Array<{ game: string; score?: number; durationSec?: number }>; + breathingCount?: number; + wasOvercome?: boolean; + feedbackBetter?: boolean | null; + feedbackRating?: number | null; + feedbackText?: string | null; + locale?: string | null; +} + +export async function createSosSession(userId: string, input: SosSessionInput) { + const db = usePrisma(); + return db.sosSession.create({ + data: { + userId, + startedAt: input.startedAt ? new Date(input.startedAt) : new Date(), + endedAt: input.endedAt ? new Date(input.endedAt) : null, + durationSec: input.durationSec ?? null, + messages: input.messages as any, + gamesPlayed: (input.gamesPlayed ?? []) as any, + breathingCount: input.breathingCount ?? 0, + wasOvercome: input.wasOvercome ?? false, + feedbackBetter: input.feedbackBetter ?? null, + feedbackRating: input.feedbackRating ?? null, + feedbackText: input.feedbackText ?? null, + locale: input.locale ?? null, + }, + select: { id: true, startedAt: true, endedAt: true }, + }); +} + +export async function deleteUserSosSessions(userId: string) { + const db = usePrisma(); + return db.sosSession.deleteMany({ where: { userId } }); +} diff --git a/backend/server/db/streak.ts b/backend/server/db/streak.ts new file mode 100644 index 0000000..9998cb8 --- /dev/null +++ b/backend/server/db/streak.ts @@ -0,0 +1,104 @@ +import { usePrisma } from "../utils/prisma"; + +export async function getActiveStreak(userId: string) { + const db = usePrisma(); + return db.streak.findFirst({ + where: { userId, isActive: true }, + }); +} + +export async function upsertStreak( + userId: string, + data: { avgMonthlySavings?: number | null; startDate?: string | null }, +) { + const db = usePrisma(); + const today = new Date(); + today.setUTCHours(0, 0, 0, 0); + + // Allow setting a past start date (e.g. from onboarding "last played" selection) + const startDate = data.startDate ? new Date(data.startDate) : today; + startDate.setUTCHours(0, 0, 0, 0); + + const existing = await db.streak.findFirst({ where: { userId } }); + + if (existing) { + const streak = await db.streak.update({ + where: { id: existing.id }, + data: { + startDate, + currentDays: 0, + isActive: true, + ...(data.avgMonthlySavings !== undefined + ? { avgMonthlySavings: data.avgMonthlySavings } + : {}), + }, + }); + await addStreakEvent(userId, "started"); + return streak; + } + + const streak = await db.streak.create({ + data: { + userId, + startDate, + currentDays: 0, + longestDays: 0, + avgMonthlySavings: data.avgMonthlySavings ?? null, + isActive: true, + }, + }); + await addStreakEvent(userId, "started"); + return streak; +} + +export async function resetStreak( + streakId: string, + longestDays: number, + userId: string, + reason: "blocker_off" | "relapse" | "manual" = "manual", +) { + const db = usePrisma(); + const today = new Date(); + today.setUTCHours(0, 0, 0, 0); + const streak = await db.streak.update({ + where: { id: streakId }, + data: { currentDays: 0, startDate: today, longestDays }, + }); + await addStreakEvent(userId, "reset", { reason, previousDays: longestDays }); + return streak; +} + +export async function updateStreakSavings(streakId: string, amount: number) { + const db = usePrisma(); + return db.streak.update({ + where: { id: streakId }, + data: { avgMonthlySavings: amount }, + }); +} + +export async function deleteUserStreaks(userId: string) { + const db = usePrisma(); + return db.streak.deleteMany({ where: { userId } }); +} + +// --- Streak Events --- + +export async function addStreakEvent( + userId: string, + type: "started" | "reset" | "milestone" | "relapse", + meta?: Record, +) { + const db = usePrisma(); + return db.streakEvent.create({ + data: { userId, type, meta: meta ?? undefined }, + }); +} + +export async function getStreakEvents(userId: string, limit = 50) { + const db = usePrisma(); + return db.streakEvent.findMany({ + where: { userId }, + orderBy: { createdAt: "desc" }, + take: limit, + }); +} diff --git a/backend/server/db/urge.ts b/backend/server/db/urge.ts new file mode 100644 index 0000000..209f02b --- /dev/null +++ b/backend/server/db/urge.ts @@ -0,0 +1,43 @@ +import { usePrisma } from "../utils/prisma"; + +type Emotion = "stress" | "sadness" | "anger" | "empty" | "boredom" | "other"; + +export async function getRecentUrgeLogs(userId: string, limit = 20) { + const db = usePrisma(); + return db.urgeLog.findMany({ + where: { userId }, + orderBy: { timestamp: "desc" }, + take: limit, + select: { + id: true, + timestamp: true, + emotion: true, + wasOvercome: true, + breathingDone: true, + }, + }); +} + +export async function createUrgeLog( + userId: string, + emotion: Emotion, + wasOvercome: boolean, + breathingDone: boolean, +) { + const db = usePrisma(); + return db.urgeLog.create({ + data: { userId, emotion, wasOvercome, breathingDone }, + select: { + id: true, + timestamp: true, + emotion: true, + wasOvercome: true, + breathingDone: true, + }, + }); +} + +export async function deleteUserUrgeLogs(userId: string) { + const db = usePrisma(); + return db.urgeLog.deleteMany({ where: { userId } }); +} diff --git a/backend/server/db/user.ts b/backend/server/db/user.ts new file mode 100644 index 0000000..143790f --- /dev/null +++ b/backend/server/db/user.ts @@ -0,0 +1,11 @@ +import { usePrisma } from "../utils/prisma"; + +export async function deleteUserTrustedContacts(userId: string) { + const db = usePrisma(); + return db.trustedContact.deleteMany({ where: { userId } }); +} + +export async function deleteUserCoachSessions(userId: string) { + const db = usePrisma(); + return db.coachSession.deleteMany({ where: { userId } }); +} diff --git a/backend/server/middleware/cors.ts b/backend/server/middleware/cors.ts new file mode 100644 index 0000000..30ddb5b --- /dev/null +++ b/backend/server/middleware/cors.ts @@ -0,0 +1,38 @@ +/** + * CORS Middleware – läuft vor jedem API-Request. + * + * Warum Middleware statt routeRules: + * - routeRules mit cors:true + headers:{} setzt den Header doppelt → Browser blockiert + * - Nur hier kann OPTIONS-Preflight korrekt mit 204 beantwortet werden + * + * Origin-Strategie: echo zurück statt *, damit Capacitor-Webview (capacitor://localhost), + * iOS-Scheme (rebreakapp://), Android (http://localhost) und alle Staging/Prod-Domains + * funktionieren – ohne explizite Whitelist pflegen zu müssen. + * Das API verwendet Bearer-Token-Auth (kein Cookie/credentials), daher ist das sicher. + */ +export default defineEventHandler((event) => { + if (!event.path.startsWith("/api/")) return; + + const origin = getHeader(event, "origin") ?? "*"; + + setHeader(event, "Access-Control-Allow-Origin", origin); + setHeader(event, "Access-Control-Allow-Credentials", "true"); + setHeader( + event, + "Access-Control-Allow-Methods", + "GET, POST, PUT, PATCH, DELETE, OPTIONS" + ); + setHeader( + event, + "Access-Control-Allow-Headers", + "Content-Type, Authorization, apikey, x-client-info, x-device-id, x-platform" + ); + setHeader(event, "Access-Control-Max-Age", "3600"); + setHeader(event, "Vary", "Origin"); + + // OPTIONS Preflight → sofort 204 zurück, kein Handler nötig + if (getMethod(event) === "OPTIONS") { + event.node.res.statusCode = 204; + event.node.res.end(); + } +}); diff --git a/backend/server/plugins/blocklist-cron.ts b/backend/server/plugins/blocklist-cron.ts new file mode 100644 index 0000000..731b371 --- /dev/null +++ b/backend/server/plugins/blocklist-cron.ts @@ -0,0 +1,40 @@ +import { consola } from "consola"; + +const SYNC_INTERVAL = 60 * 60 * 1000; // 1 hour + +export default defineNitroPlugin((nitro) => { + if (import.meta.dev) { + consola.info("[blocklist-cron] Skipping cron in dev mode"); + return; + } + + consola.info("[blocklist-cron] Starting hourly HaGeZi sync"); + + // Run initial sync after 30 seconds (let server boot) + const initialTimer = setTimeout(async () => { + await runSync(); + }, 30_000); + + // Then run every hour + const interval = setInterval(async () => { + await runSync(); + }, SYNC_INTERVAL); + + // Cleanup on close + nitro.hooks.hook("close", () => { + clearTimeout(initialTimer); + clearInterval(interval); + }); +}); + +async function runSync() { + try { + consola.info("[blocklist-cron] Syncing HaGeZi gambling domains..."); + const result = await $fetch("/api/blocklist/sync", { method: "POST" }); + consola.success( + `[blocklist-cron] Synced ${(result as any).total_fetched} domains`, + ); + } catch (err: any) { + consola.error("[blocklist-cron] Sync failed:", err.message ?? err); + } +} diff --git a/backend/server/plugins/mail-scan-cron.ts b/backend/server/plugins/mail-scan-cron.ts new file mode 100644 index 0000000..13ab574 --- /dev/null +++ b/backend/server/plugins/mail-scan-cron.ts @@ -0,0 +1,37 @@ +import { consola } from "consola"; +import { getAllActiveMailUserIds } from "../db/mail"; + +const SCAN_INTERVAL = 30 * 60 * 1000; // every 30 minutes (proxy EXISTS handles real-time) + +export default defineNitroPlugin((nitro) => { + if (import.meta.dev) return; + + consola.info("[mail-scan-cron] Starting – scanning due accounts every 30 min"); + + const interval = setInterval(runScan, SCAN_INTERVAL); + nitro.hooks.hook("close", () => clearInterval(interval)); +}); + +async function runScan() { + let userIds: string[]; + try { + userIds = await getAllActiveMailUserIds(); + } catch { + return; + } + + if (userIds.length === 0) return; + + const adminSecret = process.env.NUXT_ADMIN_SECRET || process.env.ADMIN_SECRET; + if (!adminSecret) return; + + await Promise.allSettled( + userIds.map((userId) => + $fetch("/api/mail/scan-internal", { + method: "POST", + headers: { "x-admin-secret": adminSecret }, + body: { userId }, + }).catch(() => {}) + ) + ); +} diff --git a/backend/server/utils/auth.ts b/backend/server/utils/auth.ts new file mode 100644 index 0000000..96a1cde --- /dev/null +++ b/backend/server/utils/auth.ts @@ -0,0 +1,98 @@ +import { createClient } from '@supabase/supabase-js'; +import type { H3Event } from 'h3'; +import { findUserDevice, registerDevice, touchDevice } from '../db/devices'; +import { getProfile } from '../db/profile'; +import { getPlanLimits } from './plan-features'; + +const TOUCH_THROTTLE_MS = 60_000; // touch lastSeenAt höchstens 1×/min pro Device + +export interface RequireUserOptions { + /** Bootstrap-Endpoints (z.B. /api/devices/register) brauchen kein Device-Binding */ + skipDeviceCheck?: boolean; +} + +export async function requireUser( + event: H3Event, + opts: RequireUserOptions = {}, +) { + const authHeader = getHeader(event, 'authorization'); + let token = authHeader?.replace('Bearer ', ''); + + if (!token) { + const query = getQuery(event); + token = query.token as string; + } + + if (!token) { + throw createError({ statusCode: 401, message: 'Nicht eingeloggt' }); + } + + const config = useRuntimeConfig(event); + const supabaseCfg = + (config as any).public?.supabase ?? (config as any).supabase; + const client = createClient( + supabaseCfg.url as string, + supabaseCfg.key as string, + { global: { headers: { Authorization: `Bearer ${token}` } } }, + ); + + const { + data: { user }, + error, + } = await client.auth.getUser(); + + if (error || !user) { + throw createError({ statusCode: 401, message: 'Nicht eingeloggt' }); + } + + if (opts.skipDeviceCheck) return user; + + // Device-Binding: nur enforced wenn Client einen x-device-id Header schickt. + // Web-Clients ohne Header laufen weiter wie bisher. + const deviceId = getHeader(event, 'x-device-id'); + if (!deviceId) return user; + + const existing = await findUserDevice(user.id, deviceId); + if (existing) { + // Touch lastSeenAt, throttled auf 1×/min — fire-and-forget + if (Date.now() - existing.lastSeenAt.getTime() > TOUCH_THROTTLE_MS) { + touchDevice(user.id, deviceId).catch(() => {}); + } + return user; + } + + // Device unbekannt — Auto-Register (ohne Frontend-explicit-call) + // Plan-Limit holen + const profile = await getProfile(user.id); + const limits = getPlanLimits(profile?.plan ?? 'free'); + const platform = getHeader(event, 'x-platform') ?? 'unknown'; + + try { + await registerDevice({ + userId: user.id, + deviceId, + platform, + maxDevices: limits.maxDevices, + }); + return user; + } catch (err: any) { + if (err.code === 'DEVICE_LIMIT_REACHED') { + // Devices-Liste mitschicken damit das Frontend-Modal die Geräte + // anzeigen + Freigeben-Buttons rendern kann (auch wenn der 403 + // nicht vom register-Endpoint sondern vom auth-Middleware kommt). + const { listUserDevices } = await import('../db/devices'); + const devices = await listUserDevices(user.id); + throw createError({ + statusCode: 403, + statusMessage: 'device_limit_reached', + data: { + error: 'device_limit_reached', + max: limits.maxDevices, + plan: profile?.plan ?? 'free', + devices, + }, + }); + } + throw err; + } +} \ No newline at end of file diff --git a/backend/server/utils/cooldownToken.ts b/backend/server/utils/cooldownToken.ts new file mode 100644 index 0000000..2033e1e --- /dev/null +++ b/backend/server/utils/cooldownToken.ts @@ -0,0 +1,83 @@ +import { SignJWT, jwtVerify, type JWTPayload } from "jose"; +import { randomUUID } from "crypto"; + +const ALGORITHM = "HS256"; +const PURPOSE = "rebreak.cooldown"; + +function getSecret(): Uint8Array { + const raw = + process.env.REBREAK_COOLDOWN_SECRET || + process.env.NUXT_AUTH_SECRET || + // Last-resort fallback — deterministic within a single process lifetime. + // A proper secret MUST be set via Infisical in production. + "rebreak-cooldown-insecure-fallback-replace-me"; + return new TextEncoder().encode(raw); +} + +export interface CooldownTokenPayload { + userId: string; + jti: string; + cooldownEndsAt: string; // ISO-8601 +} + +/** + * Signs a short-lived JWT that the iOS app can present to prove it has + * permission to disable the DNS protection (cooldown expired on the server). + * + * Lifetime: 5 minutes — short enough to prevent replay attacks. + * The `jti` ties the token to the exact CooldownRequest row. + */ +export async function signCooldownToken( + userId: string, + jti: string, + cooldownEndsAt: Date, +): Promise { + const secret = getSecret(); + const now = Math.floor(Date.now() / 1000); + + return new SignJWT({ + sub: userId, + jti, + purpose: PURPOSE, + cooldown_ends_at: cooldownEndsAt.toISOString(), + } satisfies JWTPayload & { purpose: string; cooldown_ends_at: string }) + .setProtectedHeader({ alg: ALGORITHM }) + .setIssuedAt(now) + .setExpirationTime(now + 5 * 60) // 5 minutes + .sign(secret); +} + +/** + * Verifies the token and returns its payload or null if invalid/expired. + */ +export async function verifyCooldownToken( + token: string, +): Promise { + try { + const { payload } = await jwtVerify(token, getSecret(), { + algorithms: [ALGORITHM], + }); + + if ( + typeof payload.sub !== "string" || + typeof payload.jti !== "string" || + payload.purpose !== PURPOSE || + typeof payload.cooldown_ends_at !== "string" + ) { + return null; + } + + return { + userId: payload.sub, + jti: payload.jti, + cooldownEndsAt: payload.cooldown_ends_at, + }; + } catch { + return null; + } +} + +/** Convenience: generate a new JTI (UUID v4). */ +export function generateJti(): string { + return randomUUID(); +} diff --git a/backend/server/utils/crypto.ts b/backend/server/utils/crypto.ts new file mode 100644 index 0000000..152c644 --- /dev/null +++ b/backend/server/utils/crypto.ts @@ -0,0 +1,43 @@ +import { createCipheriv, createDecipheriv, randomBytes } from "crypto"; + +const ALGORITHM = "aes-256-gcm"; +const KEY_LENGTH = 32; + +function getKey(): Buffer { + const raw = + process.env.NUXT_ENCRYPTION_KEY || process.env.ENCRYPTION_KEY || ""; + if (!raw || raw.length < 32) { + // Fallback: pad with zeros (only for dev without key) + return Buffer.alloc( + KEY_LENGTH, + raw.padEnd(KEY_LENGTH, "0").slice(0, KEY_LENGTH), + ); + } + return Buffer.from(raw.slice(0, KEY_LENGTH), "utf8"); +} + +export function encrypt(text: string): string { + const key = getKey(); + const iv = randomBytes(12); + const cipher = createCipheriv(ALGORITHM, key, iv); + const encrypted = Buffer.concat([ + cipher.update(text, "utf8"), + cipher.final(), + ]); + const tag = cipher.getAuthTag(); + // Format: iv(24hex):tag(32hex):encrypted(hex) + return `${iv.toString("hex")}:${tag.toString("hex")}:${encrypted.toString("hex")}`; +} + +export function decrypt(stored: string): string { + const key = getKey(); + const parts = stored.split(":"); + if (parts.length !== 3) throw new Error("Invalid encrypted format"); + const [ivHex, tagHex, dataHex] = parts; + const iv = Buffer.from(ivHex, "hex"); + const tag = Buffer.from(tagHex, "hex"); + const data = Buffer.from(dataHex, "hex"); + const decipher = createDecipheriv(ALGORITHM, key, iv); + decipher.setAuthTag(tag); + return decipher.update(data) + decipher.final("utf8"); +} diff --git a/backend/server/utils/domainHash.ts b/backend/server/utils/domainHash.ts new file mode 100644 index 0000000..bf6e343 --- /dev/null +++ b/backend/server/utils/domainHash.ts @@ -0,0 +1,69 @@ +import { createHash } from "node:crypto"; + +/** + * Normalisiert eine Domain für Hashing — muss zwischen Server und iOS-Extension + * IDENTISCH sein, sonst stimmen die Hashes nicht überein. + * + * Schritte: + * 1. trim, lowercase + * 2. http:// und https:// entfernen + * 3. Pfad / Query nach erstem `/` abschneiden + * 4. Optional `www.` Prefix entfernen (so dass `www.bet365.com` und `bet365.com` + * den gleichen Hash haben) + */ +export function normalizeDomain(input: string): string { + return input + .trim() + .toLowerCase() + .replace(/^https?:\/\//, "") + .replace(/\/.*$/, "") + .replace(/^www\./, ""); +} + +/** + * SHA-256 → erste 8 Bytes als big-endian UInt64. + * Wenn salt gesetzt ist, wird `:` gehasht (für user-spezifische + * Custom-Domains, damit gleiche Domain bei zwei Usern unterschiedliche + * Hashes ergibt → keine Cross-User-Korrelation möglich). + */ +export function hashDomain(domain: string, salt = ""): bigint { + const normalized = normalizeDomain(domain); + const input = salt ? `${salt}:${normalized}` : normalized; + const digest = createHash("sha256").update(input, "utf8").digest(); + return digest.readBigUInt64BE(0); +} + +/** + * Hasht eine Liste von Domains, sortiert die Hashes aufsteigend, und gibt + * sie als Binary-Buffer zurück (8 Bytes pro Hash, big-endian). + * + * Format der Binary-Datei für die iOS-Extension: + * ┌────────────────┬────────────────┬─────────────────┐ + * │ Hash 0 (8 B) │ Hash 1 (8 B) │ Hash 2 (8 B) │ ... + * └────────────────┴────────────────┴─────────────────┘ + * sorted ascending → binary-search möglich, O(log n). + */ +export function buildHashListBinary(domains: string[], salt = ""): Buffer { + const hashes = new BigUint64Array(domains.length); + for (let i = 0; i < domains.length; i++) { + hashes[i] = hashDomain(domains[i], salt); + } + hashes.sort(); + + // BigUint64Array ist platform-endian — wir brauchen explicit big-endian + // damit Server und iOS-Extension dasselbe Format lesen. + const buf = Buffer.alloc(hashes.length * 8); + for (let i = 0; i < hashes.length; i++) { + buf.writeBigUInt64BE(hashes[i], i * 8); + } + return buf; +} + +/** + * ETag aus dem Binary-Content (sha256 der Bytes, hex-encoded). + * Client cached darauf basierend → wenn Server-DB sich nicht ändert, + * spart Bandbreite + Battery. + */ +export function etagFor(buf: Buffer): string { + return `"${createHash("sha256").update(buf).digest("hex").slice(0, 16)}"`; +} diff --git a/backend/server/utils/gambling-keywords.mjs b/backend/server/utils/gambling-keywords.mjs new file mode 100644 index 0000000..02a6c1a --- /dev/null +++ b/backend/server/utils/gambling-keywords.mjs @@ -0,0 +1,64 @@ +/** + * Single-Source-of-Truth für Gambling-Keyword-Detection. + * + * Importiert von: + * - server/api/mail/scan.post.ts + * - server/api/mail/scan-internal.post.ts + * - imap-proxy/session.mjs + * - imap-idle/index.mjs + * + * Mo's DSGVO-Finding #4: vorher in 4 Files dupliziert → Drift-Risk. + */ + +export const GAMBLING_KEYWORDS = [ + // Major Anbieter + "casino", "bet365", "bwin", "tipico", "unibet", "betway", "888casino", + "pokerstars", "interwetten", "netbet", "leovegas", "mrgreen", "mr green", + "betsson", "neobet", "mybet", "lottoland", "betano", "william hill", + "paddypower", "betfair", "stake", "rolletto", "vbet", "1xbet", "melbet", + "mostbet", "luckyvibe", "lucky vibe", "spinz", "casinoly", "rabona", + "justcasino", "getslots", "rocketplay", "fresh casino", "freshcasino", + "nom nom", + + // Generic Begriffe + "sportwetten", "jackpot", "freispiel", "free spin", "bonus code", + "auszahlung", "glücksspiel", "slots", "roulette", + + // ⚠️ Risk: matcht auch unschuldige Wörter (Mo's Finding #5) + // TODO Whitelist: "wette" matcht "wettervorhersage" → False-Positive + // siehe gambling-whitelist.mjs (TODO) + "wette", +]; + +/** + * Whitelist — Begriffe die NICHT als Gambling gelten dürfen. + * Bei Match in GAMBLING_KEYWORDS, vor Block prüfen ob in Whitelist. + * + * TODO Mo's Finding #5: ausführen Mail-Whitelist-Check vor Auto-Delete. + */ +export const GAMBLING_WHITELIST = [ + "wettervorhersage", + "wetter", + "wetterbericht", + "wettkampf", // kein Glücksspiel + "wettbewerb", // dito +]; + +/** + * Helper: prüft ob ein Text Gambling-Keywords enthält, mit Whitelist-Check. + */ +export function isGamblingText(text) { + if (!text) return false; + const lower = text.toLowerCase(); + + // Erst Whitelist — wenn matched, kein Gambling + for (const w of GAMBLING_WHITELIST) { + if (lower.includes(w)) return false; + } + + // Dann Gambling-Keywords + for (const kw of GAMBLING_KEYWORDS) { + if (lower.includes(kw)) return true; + } + return false; +} diff --git a/backend/server/utils/getUsersMeta.ts b/backend/server/utils/getUsersMeta.ts new file mode 100644 index 0000000..d7f7964 --- /dev/null +++ b/backend/server/utils/getUsersMeta.ts @@ -0,0 +1,22 @@ +import { usePrisma } from "./prisma"; + +/** + * Lädt nickname + avatar aus der profiles-Tabelle für mehrere User-IDs. + * Vollständig über Prisma – kein Supabase Service Role benötigt. + */ +export async function getUsersMeta( + userIds: string[], +): Promise> { + if (userIds.length === 0) return {}; + const db = usePrisma(); + const profiles = await db.profile.findMany({ + where: { id: { in: userIds } }, + select: { id: true, nickname: true, username: true, avatar: true }, + }); + return Object.fromEntries( + profiles.map((p) => [ + p.id, + { nickname: p.nickname ?? p.username ?? null, avatar: p.avatar }, + ]), + ); +} diff --git a/backend/server/utils/imap-providers.ts b/backend/server/utils/imap-providers.ts new file mode 100644 index 0000000..82ed6db --- /dev/null +++ b/backend/server/utils/imap-providers.ts @@ -0,0 +1,63 @@ +/** + * IMAP-Provider Konfigurationen + * Automatisch erkennen anhand der Email-Domain + */ +export interface ImapConfig { + host: string; + port: number; + name: string; +} + +const PROVIDER_MAP: Record = { + "gmail.com": { host: "imap.gmail.com", port: 993, name: "Gmail" }, + "googlemail.com": { host: "imap.gmail.com", port: 993, name: "Gmail" }, + "icloud.com": { host: "imap.mail.me.com", port: 993, name: "iCloud" }, + "me.com": { host: "imap.mail.me.com", port: 993, name: "iCloud" }, + "mac.com": { host: "imap.mail.me.com", port: 993, name: "iCloud" }, + "outlook.com": { host: "outlook.office365.com", port: 993, name: "Outlook" }, + "hotmail.com": { host: "outlook.office365.com", port: 993, name: "Hotmail" }, + "hotmail.de": { host: "outlook.office365.com", port: 993, name: "Hotmail" }, + "live.com": { host: "outlook.office365.com", port: 993, name: "Live" }, + "live.de": { host: "outlook.office365.com", port: 993, name: "Live" }, + "yahoo.com": { host: "imap.mail.yahoo.com", port: 993, name: "Yahoo" }, + "yahoo.de": { host: "imap.mail.yahoo.com", port: 993, name: "Yahoo" }, + "gmx.de": { host: "imap.gmx.net", port: 993, name: "GMX" }, + "gmx.net": { host: "imap.gmx.net", port: 993, name: "GMX" }, + "gmx.at": { host: "imap.gmx.net", port: 993, name: "GMX" }, + "gmx.ch": { host: "imap.gmx.net", port: 993, name: "GMX" }, + "web.de": { host: "imap.web.de", port: 993, name: "Web.de" }, + "t-online.de": { + host: "secureimap.t-online.de", + port: 993, + name: "T-Online", + }, + "freenet.de": { host: "mx.freenet.de", port: 993, name: "Freenet" }, + "posteo.de": { host: "posteo.de", port: 993, name: "Posteo" }, +}; + +export function detectImapProvider(email: string): ImapConfig { + const domain = email.split("@")[1]?.toLowerCase() ?? ""; + return ( + PROVIDER_MAP[domain] ?? { + host: `imap.${domain}`, + port: 993, + name: domain, + } + ); +} + +const SMTP_MAP: Record = { + "imap.gmail.com": { host: "smtp.gmail.com", port: 587 }, + "imap.mail.me.com": { host: "smtp.mail.me.com", port: 587 }, + "imap.gmx.net": { host: "mail.gmx.net", port: 587 }, + "imap.web.de": { host: "smtp.web.de", port: 587 }, + "outlook.office365.com": { host: "smtp-mail.outlook.com", port: 587 }, + "imap.mail.yahoo.com": { host: "smtp.mail.yahoo.com", port: 587 }, + "secureimap.t-online.de": { host: "securesmtp.t-online.de", port: 587 }, + "mx.freenet.de": { host: "mx.freenet.de", port: 587 }, + "posteo.de": { host: "posteo.de", port: 587 }, +}; + +export function detectSmtpProvider(imapHost: string): { host: string; port: number } { + return SMTP_MAP[imapHost] ?? { host: imapHost.replace("imap.", "smtp."), port: 587 }; +} diff --git a/backend/server/utils/lyraMemoryExtract.ts b/backend/server/utils/lyraMemoryExtract.ts new file mode 100644 index 0000000..b7497b1 --- /dev/null +++ b/backend/server/utils/lyraMemoryExtract.ts @@ -0,0 +1,122 @@ +/** + * Lyra Memory Extraction — interne Funktion (ohne HTTP-Roundtrip). + * + * Wird von sos-stream.get.ts fire-and-forget nach Stream-Ende aufgerufen. + * Fehler sind immer silent — darf NIE die User-Experience beeinflussen. + */ +import { upsertMemory } from "../db/lyraMemory"; +import type { LyraMemoryType } from "../db/lyraMemory"; + +const VALID_TYPES: LyraMemoryType[] = [ + "trigger", + "habit", + "strength", + "relationship", + "milestone", + "pain_point", + "goal", + "preference", +]; + +const EXTRACTION_SYSTEM_PROMPT = `Du extrahierst aus einem Gespräch zwischen User und Lyra-Coach strukturierte Fakten über den User. Output strikt als JSON-Array: +[{"type":"trigger|habit|strength|relationship|milestone|pain_point|goal|preference", "content":"", "confidence":0.0-1.0}] +Regeln: +- Nur Fakten die der USER explizit oder implizit über sich geteilt hat. Nichts erfinden. +- Keine Vermutungen über Diagnosen oder Pathologisierungen ("süchtig", "krank" etc). +- Nur Wesentliches. Wenn nichts Neues drin ist → leeres Array []. +- confidence: 0.9+ wenn User explizit gesagt, 0.5-0.7 wenn implizit, <0.5 garnicht extrahieren. +- content in der Sprache des Gesprächs (DE). +- Maximal 8 Einträge pro Extraktion.`; + +export async function extractAndStoreMemories( + userId: string, + conversation: Array<{ role: string; content: string }>, + sessionId?: string, + openrouterKey?: string, +): Promise { + if (!openrouterKey) { + console.warn("[lyra-memory] extract: no OpenRouter key"); + return; + } + + const userMessages = conversation.filter((m) => m.role === "user"); + if (userMessages.length === 0) return; + + const conversationText = conversation + .slice(-20) + .map((m) => `${m.role === "user" ? "User" : "Lyra"}: ${m.content}`) + .join("\n") + .slice(0, 4000); + + try { + const res = await $fetch<{ + choices: { message: { content: string } }[]; + }>("https://openrouter.ai/api/v1/chat/completions", { + method: "POST", + headers: { + Authorization: `Bearer ${openrouterKey}`, + "Content-Type": "application/json", + "HTTP-Referer": "https://rebreak.org", + "X-Title": "ReBreak Memory Extraction", + }, + body: { + model: "anthropic/claude-haiku-4-5", + max_tokens: 800, + temperature: 0.1, + messages: [ + { role: "system", content: EXTRACTION_SYSTEM_PROMPT }, + { role: "user", content: conversationText }, + ], + }, + timeout: 20000, + }); + + const raw = res.choices?.[0]?.message?.content?.trim(); + if (!raw) return; + + const jsonStr = raw + .replace(/^```(?:json)?\s*/i, "") + .replace(/\s*```$/, "") + .trim(); + + let facts: Array<{ type: string; content: string; confidence: number }> = + []; + try { + facts = JSON.parse(jsonStr); + } catch { + console.warn( + "[lyra-memory] extract: JSON parse failed:", + jsonStr.slice(0, 200), + ); + return; + } + + if (!Array.isArray(facts)) return; + + let extracted = 0; + for (const fact of facts) { + if (!fact.content || (fact.confidence ?? 0) < 0.5) continue; + if (!VALID_TYPES.includes(fact.type as LyraMemoryType)) continue; + + try { + await upsertMemory( + userId, + fact.type as LyraMemoryType, + fact.content, + sessionId ?? "sos-session", + Math.min(1, Math.max(0, fact.confidence ?? 0.7)), + ); + extracted++; + } catch (e) { + console.error("[lyra-memory] upsert error:", e); + } + } + + console.log( + `[lyra-memory] async extract done for ${userId}: ${extracted} stored`, + ); + } catch (e) { + // Silent — darf User nie beeinflussen + console.error("[lyra-memory] extract error (silent):", e); + } +} diff --git a/backend/server/utils/plan-features.ts b/backend/server/utils/plan-features.ts new file mode 100644 index 0000000..bd7198d --- /dev/null +++ b/backend/server/utils/plan-features.ts @@ -0,0 +1,90 @@ +export type Plan = "free" | "pro" | "legend"; + +export interface PlanLimits { + /** Max. eigene Domains (Infinity = unbegrenzt) */ + customDomains: number; + /** Freigeschaltete Domain-Slots füllen sich wieder auf (Community-Promotion) */ + domainRefill: boolean; + /** Max. aktive Mail-Agenten (Infinity = unbegrenzt) */ + mailAgents: number; + /** Erlaubte Scan-Intervalle in Stunden */ + mailIntervalOptions: number[]; + /** Zugang zur globalen HaGeZi-Blocklist (200k+) */ + globalBlocklist: boolean; + /** Darf in der Community posten */ + canPost: boolean; + /** Darf Gruppen gründen */ + canCreateGroup: boolean; + /** Darf Domains direkt zur ReBreak Blocklist hinzufügen */ + canAddToBlocklist: boolean; + /** Max. parallel registrierte Devices pro Account (Anti-Account-Sharing) */ + maxDevices: number; + /** Primäres OpenRouter/Groq-Modell für KI-Coach */ + aiModel: string; + /** Fallback-Modelle (werden der Reihe nach versucht wenn primary fehlschlägt) */ + aiModelFallbacks: Array<{ provider: "groq" | "openrouter"; model: string }>; + /** AI-Provider: groq (Free/Pro) oder openrouter (Legend/Claude) */ + aiProvider: "groq" | "openrouter"; +} + +export const PLAN_LIMITS: Record = { + free: { + customDomains: 5, + domainRefill: false, + mailAgents: 1, + mailIntervalOptions: [4], + globalBlocklist: false, + canPost: true, + canCreateGroup: false, + canAddToBlocklist: false, + maxDevices: 1, + aiModel: "llama-3.1-8b-instant", + aiModelFallbacks: [ + { provider: "groq", model: "llama-3.3-70b-versatile" }, + { provider: "groq", model: "gemma2-9b-it" }, + { provider: "openrouter", model: "meta-llama/llama-3.1-8b-instruct" }, + ], + aiProvider: "groq", + }, + pro: { + customDomains: 5, + domainRefill: true, + mailAgents: 3, + mailIntervalOptions: [1, 4, 8], + globalBlocklist: true, + canPost: true, + canCreateGroup: false, + canAddToBlocklist: false, + maxDevices: 1, + aiModel: "llama-3.3-70b-versatile", + aiModelFallbacks: [ + { provider: "groq", model: "llama-3.1-8b-instant" }, + { provider: "openrouter", model: "meta-llama/llama-3.3-70b-instruct" }, + ], + aiProvider: "groq", + }, + legend: { + customDomains: 10, + domainRefill: true, + mailAgents: Infinity, + mailIntervalOptions: [1, 4, 8], + globalBlocklist: true, + canPost: true, + canCreateGroup: true, + canAddToBlocklist: true, + maxDevices: 3, + aiModel: "anthropic/claude-3.5-haiku", + aiModelFallbacks: [ + { provider: "openrouter", model: "anthropic/claude-3-haiku" }, + { provider: "groq", model: "llama-3.3-70b-versatile" }, + ], + aiProvider: "openrouter", + }, +}; + +export function getPlanLimits(plan: string): PlanLimits { + // Legacy-Pläne auf neue Namen mappen + if (plan === "premium") return PLAN_LIMITS.legend; + if (plan === "standard") return PLAN_LIMITS.pro; + return PLAN_LIMITS[(plan as Plan) ?? "free"] ?? PLAN_LIMITS.free; +} diff --git a/backend/server/utils/prisma.ts b/backend/server/utils/prisma.ts new file mode 100644 index 0000000..8f17fdc --- /dev/null +++ b/backend/server/utils/prisma.ts @@ -0,0 +1,23 @@ +import { PrismaClient } from "../generated/prisma"; +import { PrismaPg } from "@prisma/adapter-pg"; + +// Rebreak Supabase Postgres – Prod: 5434 / Staging: 5435 +const PROD_URL = + "postgresql://postgres:iPva_XtETZMSJfTod1lt4Z8GYz4wkN7O@127.0.0.1:5434/postgres"; +const STAGING_URL = + "postgresql://postgres:iPva_XtETZMSJfTod1lt4Z8GYz4wkN7O@127.0.0.1:5435/postgres"; + +let _prisma: PrismaClient | null = null; + +export function usePrisma(): PrismaClient { + if (_prisma) return _prisma; + + const config = useRuntimeConfig(); + const isProduction = process.env.NODE_ENV === "production"; + const url = + (config as any).databaseUrl || (isProduction ? PROD_URL : STAGING_URL); + + const adapter = new PrismaPg({ connectionString: url }); + _prisma = new PrismaClient({ adapter, log: ["error"] }); + return _prisma; +} diff --git a/backend/server/utils/scoring.ts b/backend/server/utils/scoring.ts new file mode 100644 index 0000000..8a67ebf --- /dev/null +++ b/backend/server/utils/scoring.ts @@ -0,0 +1,2 @@ +// Re-export aus dem DB-Layer – kein H3Event mehr nötig +export { awardPoints, type ScoreEventType } from "../db/scores"; diff --git a/backend/server/utils/sosSessions.ts b/backend/server/utils/sosSessions.ts new file mode 100644 index 0000000..4cbdd2b --- /dev/null +++ b/backend/server/utils/sosSessions.ts @@ -0,0 +1,49 @@ +/** + * In-Memory Session Store für SOS-Streaming + * + * POST /api/coach/sos-session speichert messages/locale hier, + * GET /api/coach/sos-stream lädt sie per sessionId. + * + * TTL: 5 Minuten (Auto-Cleanup) + */ +type SosSessionData = { + userId: string; + messages: Array<{ role: "user" | "assistant"; content: string }>; + locale: string; + createdAt: number; +}; + +const sessions = new Map(); +const SESSION_TTL = 5 * 60 * 1000; // 5min + +// Cleanup-Intervall: alle 2min alte Sessions löschen +setInterval( + () => { + const now = Date.now(); + for (const [id, data] of sessions.entries()) { + if (now - data.createdAt > SESSION_TTL) { + sessions.delete(id); + } + } + }, + 2 * 60 * 1000, +); + +export function setSosSession(sessionId: string, data: SosSessionData) { + sessions.set(sessionId, data); +} + +export function getSosSession(sessionId: string): SosSessionData | undefined { + const data = sessions.get(sessionId); + if (!data) return undefined; + // TTL-Check + if (Date.now() - data.createdAt > SESSION_TTL) { + sessions.delete(sessionId); + return undefined; + } + return data; +} + +export function deleteSosSession(sessionId: string) { + sessions.delete(sessionId); +} diff --git a/backend/server/utils/useSupabase.ts b/backend/server/utils/useSupabase.ts new file mode 100644 index 0000000..b06253d --- /dev/null +++ b/backend/server/utils/useSupabase.ts @@ -0,0 +1,75 @@ +// Drop-in-Replacement für `#supabase/server` aus dem Nuxt-Modul. +// Standalone-Nitro hat den Alias nicht — wir bauen die zwei Helper hier nach. +// +// `serverSupabaseClient(event)` → Anon-Key-Client (RLS aktiv, im Auftrag des Users) +// `serverSupabaseServiceRole(event)` → Service-Role-Client (RLS umgangen, admin) +// +// Auth-Cookies bleiben kompatibel mit dem ursprünglichen Nuxt-Modul-Verhalten: +// Cookies `sb-access-token` und `sb-refresh-token` werden gelesen und an den +// Supabase-Client als initial session weitergegeben. +import type { H3Event } from "h3"; +import { createClient, type SupabaseClient } from "@supabase/supabase-js"; +import { getCookie } from "h3"; + +function getSupabaseUrl(): string { + const url = + process.env.SUPABASE_URL ?? + process.env.NUXT_PUBLIC_SUPABASE_URL ?? + ""; + if (!url) throw new Error("SUPABASE_URL nicht gesetzt"); + return url; +} + +function getAnonKey(): string { + const key = + process.env.SUPABASE_KEY ?? + process.env.SUPABASE_ANON_KEY ?? + process.env.NUXT_PUBLIC_SUPABASE_KEY ?? + ""; + if (!key) throw new Error("SUPABASE_KEY (anon) nicht gesetzt"); + return key; +} + +function getServiceRoleKey(): string { + const key = + process.env.SUPABASE_SERVICE_KEY ?? + process.env.SUPABASE_SERVICE_ROLE_KEY ?? + ""; + if (!key) throw new Error("SUPABASE_SERVICE_KEY nicht gesetzt"); + return key; +} + +export async function serverSupabaseClient( + event: H3Event, +): Promise> { + const accessToken = getCookie(event, "sb-access-token"); + const refreshToken = getCookie(event, "sb-refresh-token"); + const client = createClient(getSupabaseUrl(), getAnonKey(), { + auth: { + autoRefreshToken: false, + persistSession: false, + detectSessionInUrl: false, + }, + global: accessToken + ? { headers: { Authorization: `Bearer ${accessToken}` } } + : undefined, + }); + if (accessToken && refreshToken) { + await client.auth + .setSession({ access_token: accessToken, refresh_token: refreshToken }) + .catch(() => {}); + } + return client; +} + +export function serverSupabaseServiceRole( + _event: H3Event, +): SupabaseClient { + return createClient(getSupabaseUrl(), getServiceRoleKey(), { + auth: { + autoRefreshToken: false, + persistSession: false, + detectSessionInUrl: false, + }, + }); +} diff --git a/backend/start-staging.sh b/backend/start-staging.sh new file mode 100644 index 0000000..84e51d5 --- /dev/null +++ b/backend/start-staging.sh @@ -0,0 +1,31 @@ +#!/bin/bash +# rebreak-backend Staging — startet Nitro mit Infisical-Secrets. +# Pattern analog trucko-backend/start-prod.sh, aber env=staging. +source /etc/environment + +if [[ -z "$INFISICAL_CLIENT_ID" || -z "$INFISICAL_CLIENT_SECRET" ]]; then + echo "❌ INFISICAL_CLIENT_ID / INFISICAL_CLIENT_SECRET nicht gesetzt in /etc/environment" >&2 + exit 1 +fi + +INFISICAL_TOKEN=$(infisical login \ + --method=universal-auth \ + --client-id="${INFISICAL_CLIENT_ID}" \ + --client-secret="${INFISICAL_CLIENT_SECRET}" \ + --silent --plain 2>/dev/null) + +if [[ -z "$INFISICAL_TOKEN" ]]; then + echo "❌ Infisical login fehlgeschlagen" >&2 + exit 1 +fi + +export NODE_ENV=production +export NITRO_PORT=3016 +export NITRO_HOST=127.0.0.1 +export PORT=3016 + +exec infisical run \ + --projectId="${INFISICAL_PROJECT_ID}" \ + --env=staging \ + --token="$INFISICAL_TOKEN" \ + -- /root/.nvm/versions/node/v24.11.1/bin/node /srv/rebreak-monorepo/backend/.output/server/index.mjs diff --git a/backend/tsconfig.json b/backend/tsconfig.json new file mode 100644 index 0000000..3c21032 --- /dev/null +++ b/backend/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "./.nitro/types/tsconfig.json", + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "useUnknownInCatchVariables": true, + "lib": ["ESNext", "DOM"], + "baseUrl": "." + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..3fac15d --- /dev/null +++ b/package.json @@ -0,0 +1,17 @@ +{ + "name": "rebreak-monorepo", + "private": true, + "version": "0.1.0", + "scripts": { + "dev:backend": "pnpm --filter rebreak-backend dev", + "dev:native": "pnpm --filter rebreak-native start", + "build:backend": "pnpm --filter rebreak-backend build", + "android": "pnpm --filter rebreak-native android", + "ios": "pnpm --filter rebreak-native ios" + }, + "engines": { + "node": ">=20", + "pnpm": ">=9" + }, + "packageManager": "pnpm@10.23.0" +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..d232a0f --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,11232 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: {} + + apps/rebreak-native: + dependencies: + '@expo-google-fonts/nunito': + specifier: ^0.2.3 + version: 0.2.3 + '@expo/vector-icons': + specifier: ^14.0.0 + version: 14.1.0(expo-font@13.0.4(expo@53.0.27(@babel/core@7.29.0)(@expo/metro-runtime@5.0.5(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0)))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react@19.0.0))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0) + '@react-native-async-storage/async-storage': + specifier: ^2.1.2 + version: 2.2.0(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0)) + '@react-native-community/slider': + specifier: ^5.2.0 + version: 5.2.0 + '@react-navigation/native': + specifier: ^7.0.0 + version: 7.2.2(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0) + '@supabase/supabase-js': + specifier: ^2.46.0 + version: 2.105.3 + '@tanstack/react-query': + specifier: ^5.59.0 + version: 5.100.9(react@19.0.0) + expo: + specifier: ^53.0.0 + version: 53.0.27(@babel/core@7.29.0)(@expo/metro-runtime@5.0.5(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0)))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0) + expo-apple-authentication: + specifier: ~7.2.4 + version: 7.2.4(expo@53.0.27(@babel/core@7.29.0)(@expo/metro-runtime@5.0.5(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0)))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0)) + expo-application: + specifier: ~6.1.5 + version: 6.1.5(expo@53.0.27(@babel/core@7.29.0)(@expo/metro-runtime@5.0.5(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0)))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)) + expo-av: + specifier: ~15.1.7 + version: 15.1.7(expo@53.0.27(@babel/core@7.29.0)(@expo/metro-runtime@5.0.5(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0)))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0) + expo-build-properties: + specifier: ~0.14.8 + version: 0.14.8(expo@53.0.27(@babel/core@7.29.0)(@expo/metro-runtime@5.0.5(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0)))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)) + expo-clipboard: + specifier: ^55.0.13 + version: 55.0.13(expo@53.0.27(@babel/core@7.29.0)(@expo/metro-runtime@5.0.5(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0)))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0) + expo-constants: + specifier: ~17.1.8 + version: 17.1.8(expo@53.0.27(@babel/core@7.29.0)(@expo/metro-runtime@5.0.5(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0)))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0)) + expo-dev-client: + specifier: ~5.2.4 + version: 5.2.4(expo@53.0.27(@babel/core@7.29.0)(@expo/metro-runtime@5.0.5(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0)))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)) + expo-file-system: + specifier: ~18.1.11 + version: 18.1.11(expo@53.0.27(@babel/core@7.29.0)(@expo/metro-runtime@5.0.5(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0)))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0)) + expo-font: + specifier: ~13.0.0 + version: 13.0.4(expo@53.0.27(@babel/core@7.29.0)(@expo/metro-runtime@5.0.5(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0)))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react@19.0.0) + expo-haptics: + specifier: ^55.0.14 + version: 55.0.14(expo@53.0.27(@babel/core@7.29.0)(@expo/metro-runtime@5.0.5(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0)))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)) + expo-image-picker: + specifier: ~16.1.4 + version: 16.1.4(expo@53.0.27(@babel/core@7.29.0)(@expo/metro-runtime@5.0.5(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0)))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)) + expo-linking: + specifier: ~7.1.7 + version: 7.1.7(expo@53.0.27(@babel/core@7.29.0)(@expo/metro-runtime@5.0.5(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0)))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0) + expo-localization: + specifier: ~16.1.6 + version: 16.1.6(expo@53.0.27(@babel/core@7.29.0)(@expo/metro-runtime@5.0.5(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0)))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react@19.0.0) + expo-modules-core: + specifier: ^2.0.0 + version: 2.5.0 + expo-notifications: + specifier: ~0.31.5 + version: 0.31.5(expo@53.0.27(@babel/core@7.29.0)(@expo/metro-runtime@5.0.5(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0)))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0) + expo-router: + specifier: ~5.1.11 + version: 5.1.11(15de8a0d2b6e197a248b53123aa45ca3) + expo-speech: + specifier: ~13.1.7 + version: 13.1.7(expo@53.0.27(@babel/core@7.29.0)(@expo/metro-runtime@5.0.5(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0)))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)) + expo-splash-screen: + specifier: ~0.30.10 + version: 0.30.10(expo@53.0.27(@babel/core@7.29.0)(@expo/metro-runtime@5.0.5(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0)))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)) + expo-status-bar: + specifier: ~2.2.3 + version: 2.2.3(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0) + expo-web-browser: + specifier: ~14.2.0 + version: 14.2.0(expo@53.0.27(@babel/core@7.29.0)(@expo/metro-runtime@5.0.5(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0)))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0)) + i18next: + specifier: ^23.16.0 + version: 23.16.8 + lottie-react-native: + specifier: 7.2.2 + version: 7.2.2(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0) + nativewind: + specifier: ^4.1.0 + version: 4.2.3(react-native-reanimated@4.0.3(@babel/core@7.29.0)(react-native-worklets@0.4.2(@babel/core@7.29.0)(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native-safe-area-context@5.4.0(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native-svg@15.11.2(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)(tailwindcss@3.4.19) + react: + specifier: 19.0.0 + version: 19.0.0 + react-dom: + specifier: 19.0.0 + version: 19.0.0(react@19.0.0) + react-hook-form: + specifier: ^7.53.0 + version: 7.75.0(react@19.0.0) + react-i18next: + specifier: ^15.1.0 + version: 15.7.4(i18next@23.16.8)(react-dom@19.0.0(react@19.0.0))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)(typescript@5.8.3) + react-native: + specifier: 0.79.6 + version: 0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0) + react-native-bottom-tabs: + specifier: ^1.2.0 + version: 1.2.0(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0) + react-native-gesture-handler: + specifier: ~2.24.0 + version: 2.24.0(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0) + react-native-mmkv: + specifier: ^3.1.0 + version: 3.3.3(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0) + react-native-reanimated: + specifier: ~4.0.0 + version: 4.0.3(@babel/core@7.29.0)(react-native-worklets@0.4.2(@babel/core@7.29.0)(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0) + react-native-safe-area-context: + specifier: 5.4.0 + version: 5.4.0(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0) + react-native-screens: + specifier: ~4.11.1 + version: 4.11.1(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0) + react-native-sse: + specifier: ^1.2.1 + version: 1.2.1 + react-native-svg: + specifier: 15.11.2 + version: 15.11.2(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0) + react-native-url-polyfill: + specifier: ^2.0.0 + version: 2.0.0(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0)) + react-native-worklets: + specifier: ~0.4.0 + version: 0.4.2(@babel/core@7.29.0)(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0) + rive-react-native: + specifier: ^9.0.1 + version: 9.8.3(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0) + tailwindcss: + specifier: ^3.4.14 + version: 3.4.19 + valibot: + specifier: ^1.2.0 + version: 1.4.0(typescript@5.8.3) + zustand: + specifier: ^5.0.0 + version: 5.0.13(@types/react@19.0.14)(react@19.0.0)(use-sync-external-store@1.6.0(react@19.0.0)) + devDependencies: + '@babel/core': + specifier: ^7.25.0 + version: 7.29.0 + '@types/react': + specifier: ~19.0.14 + version: 19.0.14 + typescript: + specifier: ~5.8.3 + version: 5.8.3 + + backend: + dependencies: + '@prisma/adapter-pg': + specifier: ^7.2.0 + version: 7.8.0 + '@prisma/client': + specifier: ^7.2.0 + version: 7.8.0(prisma@7.8.0(@types/react@19.0.14)(magicast@0.5.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.9.3))(typescript@5.9.3) + '@supabase/supabase-js': + specifier: ^2.39.7 + version: 2.105.3 + groq-sdk: + specifier: ^0.7.0 + version: 0.7.0 + imapflow: + specifier: ^1.2.18 + version: 1.3.3 + jose: + specifier: ^6.0.0 + version: 6.2.3 + openai: + specifier: ^4.65.0 + version: 4.104.0(ws@8.20.0)(zod@3.25.76) + pg: + specifier: ^8.16.3 + version: 8.20.0 + resend: + specifier: ^4.0.0 + version: 4.8.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + stripe: + specifier: ^17.0.0 + version: 17.7.0 + zod: + specifier: ^3.23.8 + version: 3.25.76 + devDependencies: + '@types/node': + specifier: ^22.0.0 + version: 22.19.17 + '@types/pg': + specifier: ^8.11.10 + version: 8.20.0 + h3: + specifier: ^1.15.4 + version: 1.15.11 + nitropack: + specifier: ^2.12.4 + version: 2.13.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3) + prisma: + specifier: ^7.2.0 + version: 7.8.0(@types/react@19.0.14)(magicast@0.5.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.9.3) + typescript: + specifier: ^5.9.3 + version: 5.9.3 + +packages: + + '@0no-co/graphql.web@1.2.0': + resolution: {integrity: sha512-/1iHy9TTr63gE1YcR5idjx8UREz1s0kFhydf3bBLCXyqjhkIc6igAzTOx3zPifCwFR87tsh/4Pa9cNts6d2otw==} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 + peerDependenciesMeta: + graphql: + optional: true + + '@alloc/quick-lru@5.2.0': + resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} + engines: {node: '>=10'} + + '@babel/code-frame@7.10.4': + resolution: {integrity: sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg==} + + '@babel/code-frame@7.29.0': + resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.3': + resolution: {integrity: sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.0': + resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.1': + resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-annotate-as-pure@7.27.3': + resolution: {integrity: sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.28.6': + resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-create-class-features-plugin@7.29.3': + resolution: {integrity: sha512-RpLYy2sb51oNLjuu1iD3bwBqCBWUzjO0ocp+iaCP/lJtb2CPLcnC2Fftw+4sAzaMELGeWTgExSKADbdo0GFVzA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-create-regexp-features-plugin@7.28.5': + resolution: {integrity: sha512-N1EhvLtHzOvj7QQOUCCS3NrPJP8c5W6ZXCHDn7Yialuy1iu4r5EmIYkXlKNqT99Ciw+W0mDqWoR6HWMZlFP3hw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-define-polyfill-provider@0.6.8': + resolution: {integrity: sha512-47UwBLPpQi1NoWzLuHNjRoHlYXMwIJoBf7MFou6viC/sIHWYygpvr0B6IAyh5sBdA2nr2LPIRww8lfaUVQINBA==} + peerDependencies: + '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + + '@babel/helper-globals@7.28.0': + resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-member-expression-to-functions@7.28.5': + resolution: {integrity: sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.28.6': + resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.28.6': + resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-optimise-call-expression@7.27.1': + resolution: {integrity: sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-plugin-utils@7.28.6': + resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==} + engines: {node: '>=6.9.0'} + + '@babel/helper-remap-async-to-generator@7.27.1': + resolution: {integrity: sha512-7fiA521aVw8lSPeI4ZOD3vRFkoqkJcS+z4hFo82bFSH/2tNd6eJ5qCVMS5OzDmZh/kaHQeBaeyxK6wljcPtveA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-replace-supers@7.28.6': + resolution: {integrity: sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-skip-transparent-expression-wrappers@7.27.1': + resolution: {integrity: sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.27.1': + resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-wrap-function@7.28.6': + resolution: {integrity: sha512-z+PwLziMNBeSQJonizz2AGnndLsP2DeGHIxDAn+wdHOGuo4Fo1x1HBPPXeE9TAOPHNNWQKCSlA2VZyYyyibDnQ==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.2': + resolution: {integrity: sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==} + engines: {node: '>=6.9.0'} + + '@babel/highlight@7.25.9': + resolution: {integrity: sha512-llL88JShoCsth8fF8R4SJnIn+WLvR6ccFxu1H3FlMhDontdcmZWf2HgIZ7AIqV3Xcck1idlohrN4EUBQz6klbw==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.3': + resolution: {integrity: sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-proposal-decorators@7.29.0': + resolution: {integrity: sha512-CVBVv3VY/XRMxRYq5dwr2DS7/MvqPm23cOCjbwNnVrfOqcWlnefua1uUs0sjdKOGjvPUG633o07uWzJq4oI6dA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-proposal-export-default-from@7.27.1': + resolution: {integrity: sha512-hjlsMBl1aJc5lp8MoCDEZCiYzlgdRAShOjAfRw6X+GlpLpUPU7c3XNLsKFZbQk/1cRzBlJ7CXg3xJAJMrFa1Uw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-async-generators@7.8.4': + resolution: {integrity: sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-bigint@7.8.3': + resolution: {integrity: sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-class-properties@7.12.13': + resolution: {integrity: sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-class-static-block@7.14.5': + resolution: {integrity: sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-decorators@7.28.6': + resolution: {integrity: sha512-71EYI0ONURHJBL4rSFXnITXqXrrY8q4P0q006DPfN+Rk+ASM+++IBXem/ruokgBZR8YNEWZ8R6B+rCb8VcUTqA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-dynamic-import@7.8.3': + resolution: {integrity: sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-export-default-from@7.28.6': + resolution: {integrity: sha512-Svlx1fjJFnNz0LZeUaybRukSxZI3KkpApUmIRzEdXC5k8ErTOz0OD0kNrICi5Vc3GlpP5ZCeRyRO+mfWTSz+iQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-flow@7.28.6': + resolution: {integrity: sha512-D+OrJumc9McXNEBI/JmFnc/0uCM2/Y3PEBG3gfV3QIYkKv5pvnpzFrl1kYCrcHJP8nOeFB/SHi1IHz29pNGuew==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-import-attributes@7.28.6': + resolution: {integrity: sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-import-meta@7.10.4': + resolution: {integrity: sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-json-strings@7.8.3': + resolution: {integrity: sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-jsx@7.28.6': + resolution: {integrity: sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-logical-assignment-operators@7.10.4': + resolution: {integrity: sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3': + resolution: {integrity: sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-numeric-separator@7.10.4': + resolution: {integrity: sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-object-rest-spread@7.8.3': + resolution: {integrity: sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-optional-catch-binding@7.8.3': + resolution: {integrity: sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-optional-chaining@7.8.3': + resolution: {integrity: sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-private-property-in-object@7.14.5': + resolution: {integrity: sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-top-level-await@7.14.5': + resolution: {integrity: sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-typescript@7.28.6': + resolution: {integrity: sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-arrow-functions@7.27.1': + resolution: {integrity: sha512-8Z4TGic6xW70FKThA5HYEKKyBpOOsucTOD1DjU3fZxDg+K3zBJcXMFnt/4yQiZnf5+MiOMSXQ9PaEK/Ilh1DeA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-async-generator-functions@7.29.0': + resolution: {integrity: sha512-va0VdWro4zlBr2JsXC+ofCPB2iG12wPtVGTWFx2WLDOM3nYQZZIGP82qku2eW/JR83sD+k2k+CsNtyEbUqhU6w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-async-to-generator@7.28.6': + resolution: {integrity: sha512-ilTRcmbuXjsMmcZ3HASTe4caH5Tpo93PkTxF9oG2VZsSWsahydmcEHhix9Ik122RcTnZnUzPbmux4wh1swfv7g==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-block-scoping@7.28.6': + resolution: {integrity: sha512-tt/7wOtBmwHPNMPu7ax4pdPz6shjFrmHDghvNC+FG9Qvj7D6mJcoRQIF5dy4njmxR941l6rgtvfSB2zX3VlUIw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-class-properties@7.28.6': + resolution: {integrity: sha512-dY2wS3I2G7D697VHndN91TJr8/AAfXQNt5ynCTI/MpxMsSzHp+52uNivYT5wCPax3whc47DR8Ba7cmlQMg24bw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-classes@7.28.6': + resolution: {integrity: sha512-EF5KONAqC5zAqT783iMGuM2ZtmEBy+mJMOKl2BCvPZ2lVrwvXnB6o+OBWCS+CoeCCpVRF2sA2RBKUxvT8tQT5Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-computed-properties@7.28.6': + resolution: {integrity: sha512-bcc3k0ijhHbc2lEfpFHgx7eYw9KNXqOerKWfzbxEHUGKnS3sz9C4CNL9OiFN1297bDNfUiSO7DaLzbvHQQQ1BQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-destructuring@7.28.5': + resolution: {integrity: sha512-Kl9Bc6D0zTUcFUvkNuQh4eGXPKKNDOJQXVyyM4ZAQPMveniJdxi8XMJwLo+xSoW3MIq81bD33lcUe9kZpl0MCw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-export-namespace-from@7.27.1': + resolution: {integrity: sha512-tQvHWSZ3/jH2xuq/vZDy0jNn+ZdXJeM8gHvX4lnJmsc3+50yPlWdZXIc5ay+umX+2/tJIqHqiEqcJvxlmIvRvQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-flow-strip-types@7.27.1': + resolution: {integrity: sha512-G5eDKsu50udECw7DL2AcsysXiQyB7Nfg521t2OAJ4tbfTJ27doHLeF/vlI1NZGlLdbb/v+ibvtL1YBQqYOwJGg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-for-of@7.27.1': + resolution: {integrity: sha512-BfbWFFEJFQzLCQ5N8VocnCtA8J1CLkNTe2Ms2wocj75dd6VpiqS5Z5quTYcUoo4Yq+DN0rtikODccuv7RU81sw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-function-name@7.27.1': + resolution: {integrity: sha512-1bQeydJF9Nr1eBCMMbC+hdwmRlsv5XYOMu03YSWFwNs0HsAmtSxxF1fyuYPqemVldVyFmlCU7w8UE14LupUSZQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-literals@7.27.1': + resolution: {integrity: sha512-0HCFSepIpLTkLcsi86GG3mTUzxV5jpmbv97hTETW3yzrAij8aqlD36toB1D0daVFJM8NK6GvKO0gslVQmm+zZA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-logical-assignment-operators@7.28.6': + resolution: {integrity: sha512-+anKKair6gpi8VsM/95kmomGNMD0eLz1NQ8+Pfw5sAwWH9fGYXT50E55ZpV0pHUHWf6IUTWPM+f/7AAff+wr9A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-modules-commonjs@7.28.6': + resolution: {integrity: sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-named-capturing-groups-regex@7.29.0': + resolution: {integrity: sha512-1CZQA5KNAD6ZYQLPw7oi5ewtDNxH/2vuCh+6SmvgDfhumForvs8a1o9n0UrEoBD8HU4djO2yWngTQlXl1NDVEQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-transform-nullish-coalescing-operator@7.28.6': + resolution: {integrity: sha512-3wKbRgmzYbw24mDJXT7N+ADXw8BC/imU9yo9c9X9NKaLF1fW+e5H1U5QjMUBe4Qo4Ox/o++IyUkl1sVCLgevKg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-numeric-separator@7.28.6': + resolution: {integrity: sha512-SJR8hPynj8outz+SlStQSwvziMN4+Bq99it4tMIf5/Caq+3iOc0JtKyse8puvyXkk3eFRIA5ID/XfunGgO5i6w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-object-rest-spread@7.28.6': + resolution: {integrity: sha512-5rh+JR4JBC4pGkXLAcYdLHZjXudVxWMXbB6u6+E9lRL5TrGVbHt1TjxGbZ8CkmYw9zjkB7jutzOROArsqtncEA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-optional-catch-binding@7.28.6': + resolution: {integrity: sha512-R8ja/Pyrv0OGAvAXQhSTmWyPJPml+0TMqXlO5w+AsMEiwb2fg3WkOvob7UxFSL3OIttFSGSRFKQsOhJ/X6HQdQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-optional-chaining@7.28.6': + resolution: {integrity: sha512-A4zobikRGJTsX9uqVFdafzGkqD30t26ck2LmOzAuLL8b2x6k3TIqRiT2xVvA9fNmFeTX484VpsdgmKNA0bS23w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-parameters@7.27.7': + resolution: {integrity: sha512-qBkYTYCb76RRxUM6CcZA5KRu8K4SM8ajzVeUgVdMVO9NN9uI/GaVmBg/WKJJGnNokV9SY8FxNOVWGXzqzUidBg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-private-methods@7.28.6': + resolution: {integrity: sha512-piiuapX9CRv7+0st8lmuUlRSmX6mBcVeNQ1b4AYzJxfCMuBfB0vBXDiGSmm03pKJw1v6cZ8KSeM+oUnM6yAExg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-private-property-in-object@7.28.6': + resolution: {integrity: sha512-b97jvNSOb5+ehyQmBpmhOCiUC5oVK4PMnpRvO7+ymFBoqYjeDHIU9jnrNUuwHOiL9RpGDoKBpSViarV+BU+eVA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-display-name@7.28.0': + resolution: {integrity: sha512-D6Eujc2zMxKjfa4Zxl4GHMsmhKKZ9VpcqIchJLvwTxad9zWIYulwYItBovpDOoNLISpcZSXoDJ5gaGbQUDqViA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-development@7.27.1': + resolution: {integrity: sha512-ykDdF5yI4f1WrAolLqeF3hmYU12j9ntLQl/AOG1HAS21jxyg1Q0/J/tpREuYLfatGdGmXp/3yS0ZA76kOlVq9Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-self@7.27.1': + resolution: {integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-source@7.27.1': + resolution: {integrity: sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx@7.28.6': + resolution: {integrity: sha512-61bxqhiRfAACulXSLd/GxqmAedUSrRZIu/cbaT18T1CetkTmtDN15it7i80ru4DVqRK1WMxQhXs+Lf9kajm5Ow==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-pure-annotations@7.27.1': + resolution: {integrity: sha512-JfuinvDOsD9FVMTHpzA/pBLisxpv1aSf+OIV8lgH3MuWrks19R27e6a6DipIg4aX1Zm9Wpb04p8wljfKrVSnPA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-regenerator@7.29.0': + resolution: {integrity: sha512-FijqlqMA7DmRdg/aINBSs04y8XNTYw/lr1gJ2WsmBnnaNw1iS43EPkJW+zK7z65auG3AWRFXWj+NcTQwYptUog==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-runtime@7.29.0': + resolution: {integrity: sha512-jlaRT5dJtMaMCV6fAuLbsQMSwz/QkvaHOHOSXRitGGwSpR1blCY4KUKoyP2tYO8vJcqYe8cEj96cqSztv3uF9w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-shorthand-properties@7.27.1': + resolution: {integrity: sha512-N/wH1vcn4oYawbJ13Y/FxcQrWk63jhfNa7jef0ih7PHSIHX2LB7GWE1rkPrOnka9kwMxb6hMl19p7lidA+EHmQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-spread@7.28.6': + resolution: {integrity: sha512-9U4QObUC0FtJl05AsUcodau/RWDytrU6uKgkxu09mLR9HLDAtUMoPuuskm5huQsoktmsYpI+bGmq+iapDcriKA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-sticky-regex@7.27.1': + resolution: {integrity: sha512-lhInBO5bi/Kowe2/aLdBAawijx+q1pQzicSgnkB6dUPc1+RC8QmJHKf2OjvU+NZWitguJHEaEmbV6VWEouT58g==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-template-literals@7.27.1': + resolution: {integrity: sha512-fBJKiV7F2DxZUkg5EtHKXQdbsbURW3DZKQUWphDum0uRP6eHGGa/He9mc0mypL680pb+e/lDIthRohlv8NCHkg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-typescript@7.28.6': + resolution: {integrity: sha512-0YWL2RFxOqEm9Efk5PvreamxPME8OyY0wM5wh5lHjF+VtVhdneCWGzZeSqzOfiobVqQaNCd2z0tQvnI9DaPWPw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-unicode-regex@7.27.1': + resolution: {integrity: sha512-xvINq24TRojDuyt6JGtHmkVkrfVV3FPT16uytxImLeBZqW3/H52yN+kM1MGuyPkIQxrzKwPHs5U/MP3qKyzkGw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/preset-react@7.28.5': + resolution: {integrity: sha512-Z3J8vhRq7CeLjdC58jLv4lnZ5RKFUJWqH5emvxmv9Hv3BD1T9R/Im713R4MTKwvFaV74ejZ3sM01LyEKk4ugNQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/preset-typescript@7.28.5': + resolution: {integrity: sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/runtime@7.29.2': + resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} + engines: {node: '>=6.9.0'} + + '@babel/template@7.28.6': + resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.0': + resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} + engines: {node: '>=6.9.0'} + + '@cloudflare/kv-asset-handler@0.4.2': + resolution: {integrity: sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ==} + engines: {node: '>=18.0.0'} + + '@egjs/hammerjs@2.0.17': + resolution: {integrity: sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A==} + engines: {node: '>=0.8.0'} + + '@electric-sql/pglite-socket@0.1.1': + resolution: {integrity: sha512-p2hoXw3Z3LQHwTeikdZNsFBOvXGqKY2hk51BBw+8NKND8eoH+8LFOtW9Z8CQKmTJ2qqGYu82ipqiyFZOTTXNfw==} + hasBin: true + peerDependencies: + '@electric-sql/pglite': 0.4.1 + + '@electric-sql/pglite-tools@0.3.1': + resolution: {integrity: sha512-C+T3oivmy9bpQvSxVqXA1UDY8cB9Eb9vZHL9zxWwEUfDixbXv4G3r2LjoTdR33LD8aomR3O9ZXEO3XEwr/cUCA==} + peerDependencies: + '@electric-sql/pglite': 0.4.1 + + '@electric-sql/pglite@0.4.1': + resolution: {integrity: sha512-mZ9NzzUSYPOCnxHH1oAHPRzoMFJHY472raDKwXl/+6oPbpdJ7g8LsCN4FSaIIfkiCKHhb3iF/Zqo3NYxaIhU7Q==} + + '@esbuild/aix-ppc64@0.28.0': + resolution: {integrity: sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.0': + resolution: {integrity: sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.0': + resolution: {integrity: sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.0': + resolution: {integrity: sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.0': + resolution: {integrity: sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.0': + resolution: {integrity: sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.0': + resolution: {integrity: sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.0': + resolution: {integrity: sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.0': + resolution: {integrity: sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.0': + resolution: {integrity: sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.0': + resolution: {integrity: sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.0': + resolution: {integrity: sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.0': + resolution: {integrity: sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.0': + resolution: {integrity: sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.0': + resolution: {integrity: sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.0': + resolution: {integrity: sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.0': + resolution: {integrity: sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.0': + resolution: {integrity: sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.0': + resolution: {integrity: sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.0': + resolution: {integrity: sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.0': + resolution: {integrity: sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.0': + resolution: {integrity: sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.0': + resolution: {integrity: sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.0': + resolution: {integrity: sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.0': + resolution: {integrity: sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.0': + resolution: {integrity: sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@expo-google-fonts/nunito@0.2.3': + resolution: {integrity: sha512-z+Bx3IuT0t3jguoMxiyWuC7pW3wDVNHgYko/G9V23QhR/yDSjEsT+Kx+VGDT/hu9TXSxw3CtpQ5MFHikqSVDYw==} + + '@expo/cli@0.24.24': + resolution: {integrity: sha512-XybHfF2QNPJNnHoUKHcG796iEkX5126UuTAs6MSpZuvZRRQRj/sGCLX+driCOVHbDOpcCOusMuHrhxHbtTApyg==} + hasBin: true + + '@expo/code-signing-certificates@0.0.6': + resolution: {integrity: sha512-iNe0puxwBNEcuua9gmTGzq+SuMDa0iATai1FlFTMHJ/vUmKvN/V//drXoLJkVb5i5H3iE/n/qIJxyoBnXouD0w==} + + '@expo/config-plugins@10.1.2': + resolution: {integrity: sha512-IMYCxBOcnuFStuK0Ay+FzEIBKrwW8OVUMc65+v0+i7YFIIe8aL342l7T4F8lR4oCfhXn7d6M5QPgXvjtc/gAcw==} + + '@expo/config-types@53.0.5': + resolution: {integrity: sha512-kqZ0w44E+HEGBjy+Lpyn0BVL5UANg/tmNixxaRMLS6nf37YsDrLk2VMAmeKMMk5CKG0NmOdVv3ngeUjRQMsy9g==} + + '@expo/config@11.0.13': + resolution: {integrity: sha512-TnGb4u/zUZetpav9sx/3fWK71oCPaOjZHoVED9NaEncktAd0Eonhq5NUghiJmkUGt3gGSjRAEBXiBbbY9/B1LA==} + + '@expo/devcert@1.2.1': + resolution: {integrity: sha512-qC4eaxmKMTmJC2ahwyui6ud8f3W60Ss7pMkpBq40Hu3zyiAaugPXnZ24145U7K36qO9UHdZUVxsCvIpz2RYYCA==} + + '@expo/env@1.0.7': + resolution: {integrity: sha512-qSTEnwvuYJ3umapO9XJtrb1fAqiPlmUUg78N0IZXXGwQRt+bkp0OBls+Y5Mxw/Owj8waAM0Z3huKKskRADR5ow==} + + '@expo/fingerprint@0.13.4': + resolution: {integrity: sha512-MYfPYBTMfrrNr07DALuLhG6EaLVNVrY/PXjEzsjWdWE4ZFn0yqI0IdHNkJG7t1gePT8iztHc7qnsx+oo/rDo6w==} + hasBin: true + + '@expo/image-utils@0.7.6': + resolution: {integrity: sha512-GKnMqC79+mo/1AFrmAcUcGfbsXXTRqOMNS1umebuevl3aaw+ztsYEFEiuNhHZW7PQ3Xs3URNT513ZxKhznDscw==} + + '@expo/json-file@10.0.14': + resolution: {integrity: sha512-yWwBFywFv+SxkJp/pIzzA416JVYflNUh7pqQzgaA6nXDqRyK7KfrqVzk8PdUfDnqbBcaZZxpzNssfQZzp5KHrA==} + + '@expo/json-file@9.1.5': + resolution: {integrity: sha512-prWBhLUlmcQtvN6Y7BpW2k9zXGd3ySa3R6rAguMJkp1z22nunLN64KYTUWfijFlprFoxm9r2VNnGkcbndAlgKA==} + + '@expo/metro-config@0.20.18': + resolution: {integrity: sha512-qPYq3Cq61KQO1CppqtmxA1NGKpzFOmdiL7WxwLhEVnz73LPSgneW7dV/3RZwVFkjThzjA41qB4a9pxDqtpepPg==} + + '@expo/metro-runtime@5.0.5': + resolution: {integrity: sha512-P8UFTi+YsmiD1BmdTdiIQITzDMcZgronsA3RTQ4QKJjHM3bas11oGzLQOnFaIZnlEV8Rrr3m1m+RHxvnpL+t/A==} + peerDependencies: + react-native: '*' + + '@expo/osascript@2.4.3': + resolution: {integrity: sha512-wbuj3EebM7W9hN/Wp4xTzKd6rQ2zKJzAxkFxkOOwyysLp0HOAgQ4/5RINyoS241pZUX2rUHq7mAJ7pcCQ8U0Ow==} + engines: {node: '>=12'} + + '@expo/package-manager@1.10.5': + resolution: {integrity: sha512-nCP9Mebfl3jvOr0/P6VAuyah6PAtun+aihIL2zAtuE8uSe94JWkVZ7051i0MUVO+y3gFpBqnr8IIH5ch+VJjHA==} + + '@expo/plist@0.3.5': + resolution: {integrity: sha512-9RYVU1iGyCJ7vWfg3e7c/NVyMFs8wbl+dMWZphtFtsqyN9zppGREU3ctlD3i8KUE0sCUTVnLjCWr+VeUIDep2g==} + + '@expo/prebuild-config@9.0.12': + resolution: {integrity: sha512-AKH5Scf+gEMgGxZZaimrJI2wlUJlRoqzDNn7/rkhZa5gUTnO4l6slKak2YdaH+nXlOWCNfAQWa76NnpQIfmv6Q==} + + '@expo/schema-utils@0.1.8': + resolution: {integrity: sha512-9I6ZqvnAvKKDiO+ZF8BpQQFYWXOJvTAL5L/227RUbWG1OVZDInFifzCBiqAZ3b67NRfeAgpgvbA7rejsqhY62A==} + + '@expo/sdk-runtime-versions@1.0.0': + resolution: {integrity: sha512-Doz2bfiPndXYFPMRwPyGa1k5QaKDVpY806UJj570epIiMzWaYyCtobasyfC++qfIXVb5Ocy7r3tP9d62hAQ7IQ==} + + '@expo/server@0.6.3': + resolution: {integrity: sha512-Ea7NJn9Xk1fe4YeJ86rObHSv/bm3u/6WiQPXEqXJ2GrfYpVab2Swoh9/PnSM3KjR64JAgKjArDn1HiPjITCfHA==} + + '@expo/spawn-async@1.7.2': + resolution: {integrity: sha512-QdWi16+CHB9JYP7gma19OVVg0BFkvU8zNj9GjWorYI8Iv8FUxjOCcYRuAmX4s/h91e4e7BPsskc8cSrZYho9Ew==} + engines: {node: '>=12'} + + '@expo/sudo-prompt@9.3.2': + resolution: {integrity: sha512-HHQigo3rQWKMDzYDLkubN5WQOYXJJE2eNqIQC2axC2iO3mHdwnIR7FgZVvHWtBwAdzBgAP0ECp8KqS8TiMKvgw==} + + '@expo/vector-icons@14.1.0': + resolution: {integrity: sha512-7T09UE9h8QDTsUeMGymB4i+iqvtEeaO5VvUjryFB4tugDTG/bkzViWA74hm5pfjjDEhYMXWaX112mcvhccmIwQ==} + peerDependencies: + expo-font: '*' + react: '*' + react-native: '*' + + '@expo/ws-tunnel@1.0.6': + resolution: {integrity: sha512-nDRbLmSrJar7abvUjp3smDwH8HcbZcoOEa5jVPUv9/9CajgmWw20JNRwTuBRzWIWIkEJDkz20GoNA+tSwUqk0Q==} + + '@expo/xcpretty@4.4.3': + resolution: {integrity: sha512-wC562eD3gS6vO2tWHToFhlFnmHKfKHgF1oyvojeSkLK/ZYop1bMU+7cOMiF9Sq70CzcsLy/EMRy/uRc76QmNRw==} + hasBin: true + + '@hono/node-server@1.19.11': + resolution: {integrity: sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 + + '@ide/backoff@1.0.0': + resolution: {integrity: sha512-F0YfUDjvT+Mtt/R4xdl2X0EYCHMMiJqNLdxHD++jDT5ydEFIyqbCHh51Qx2E211dgZprPKhV7sHmnXKpLuvc5g==} + + '@ioredis/commands@1.5.1': + resolution: {integrity: sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==} + + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + + '@isaacs/fs-minipass@4.0.1': + resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} + engines: {node: '>=18.0.0'} + + '@isaacs/ttlcache@1.4.1': + resolution: {integrity: sha512-RQgQ4uQ+pLbqXfOmieB91ejmLwvSgv9nLx6sT6sD83s7umBypgg+OIBOBbEUiJXrfpnp9j0mRhYYdzp9uqq3lA==} + engines: {node: '>=12'} + + '@istanbuljs/load-nyc-config@1.1.0': + resolution: {integrity: sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==} + engines: {node: '>=8'} + + '@istanbuljs/schema@0.1.6': + resolution: {integrity: sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==} + engines: {node: '>=8'} + + '@jest/create-cache-key-function@29.7.0': + resolution: {integrity: sha512-4QqS3LY5PBmTRHj9sAg1HLoPzqAI0uOX6wI/TRqHIcOxlFidy6YEmCQJk6FSZjNLGCeubDMfmkWL+qaLKhSGQA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/environment@29.7.0': + resolution: {integrity: sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/fake-timers@29.7.0': + resolution: {integrity: sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/schemas@29.6.3': + resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/transform@29.7.0': + resolution: {integrity: sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/types@29.6.3': + resolution: {integrity: sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/source-map@0.3.11': + resolution: {integrity: sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@kurkle/color@0.3.4': + resolution: {integrity: sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==} + + '@mapbox/node-pre-gyp@2.0.3': + resolution: {integrity: sha512-uwPAhccfFJlsfCxMYTwOdVfOz3xqyj8xYL3zJj8f0pb30tLohnnFPhLuqp4/qoEz8sNxe4SESZedcBojRefIzg==} + engines: {node: '>=18'} + hasBin: true + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@parcel/watcher-android-arm64@2.5.6': + resolution: {integrity: sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [android] + + '@parcel/watcher-darwin-arm64@2.5.6': + resolution: {integrity: sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [darwin] + + '@parcel/watcher-darwin-x64@2.5.6': + resolution: {integrity: sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [darwin] + + '@parcel/watcher-freebsd-x64@2.5.6': + resolution: {integrity: sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [freebsd] + + '@parcel/watcher-linux-arm-glibc@2.5.6': + resolution: {integrity: sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==} + engines: {node: '>= 10.0.0'} + cpu: [arm] + os: [linux] + + '@parcel/watcher-linux-arm-musl@2.5.6': + resolution: {integrity: sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==} + engines: {node: '>= 10.0.0'} + cpu: [arm] + os: [linux] + + '@parcel/watcher-linux-arm64-glibc@2.5.6': + resolution: {integrity: sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [linux] + + '@parcel/watcher-linux-arm64-musl@2.5.6': + resolution: {integrity: sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [linux] + + '@parcel/watcher-linux-x64-glibc@2.5.6': + resolution: {integrity: sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [linux] + + '@parcel/watcher-linux-x64-musl@2.5.6': + resolution: {integrity: sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [linux] + + '@parcel/watcher-wasm@2.5.6': + resolution: {integrity: sha512-byAiBZ1t3tXQvc8dMD/eoyE7lTXYorhn+6uVW5AC+JGI1KtJC/LvDche5cfUE+qiefH+Ybq0bUCJU0aB1cSHUA==} + engines: {node: '>= 10.0.0'} + bundledDependencies: + - napi-wasm + + '@parcel/watcher-win32-arm64@2.5.6': + resolution: {integrity: sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [win32] + + '@parcel/watcher-win32-ia32@2.5.6': + resolution: {integrity: sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==} + engines: {node: '>= 10.0.0'} + cpu: [ia32] + os: [win32] + + '@parcel/watcher-win32-x64@2.5.6': + resolution: {integrity: sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [win32] + + '@parcel/watcher@2.5.6': + resolution: {integrity: sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==} + engines: {node: '>= 10.0.0'} + + '@pinojs/redact@0.4.0': + resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + + '@poppinss/colors@4.1.6': + resolution: {integrity: sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==} + + '@poppinss/dumper@0.7.0': + resolution: {integrity: sha512-0UTYalzk2t6S4rA2uHOz5bSSW2CHdv4vggJI6Alg90yvl0UgXs6XSXpH96OH+bRkX4J/06djv29pqXJ0lq5Kag==} + + '@poppinss/exception@1.2.3': + resolution: {integrity: sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==} + + '@prisma/adapter-pg@7.8.0': + resolution: {integrity: sha512-ygb3UkerK3v8MDpXVgCISdRNDozpxh6+JVJgiIGbSr5KBgz10LLf5ejUskPGoXlsIjxsOu6nuy1JVQr2EKGSlg==} + + '@prisma/client-runtime-utils@7.8.0': + resolution: {integrity: sha512-5NQZztQ0oY/ADFkmd9gPuweH5A1/CCY8YQPorLLO0Mu6a87mY5gsnDkzmFmIHs9NFaLnZojzgddFVN4RpKYrdw==} + + '@prisma/client@7.8.0': + resolution: {integrity: sha512-HFp3Dawv/3sU3JtlPha90IB+48lS7zHiH4LKZPjmcE8YH5P9DOXGPvo8dqOtO7MqLDd1p2hOWMcFlRT1DMblHw==} + engines: {node: ^20.19 || ^22.12 || >=24.0} + peerDependencies: + prisma: '*' + typescript: '>=5.4.0' + peerDependenciesMeta: + prisma: + optional: true + typescript: + optional: true + + '@prisma/config@7.8.0': + resolution: {integrity: sha512-HFESzd9rx2ZQxlK+TL7tu1HPvCqrHiL6LCxYykI2c34mvaUuIVVl3lYuicJD/MNnzgPnyeBEMlK4WTomJCV5jw==} + + '@prisma/debug@7.2.0': + resolution: {integrity: sha512-YSGTiSlBAVJPzX4ONZmMotL+ozJwQjRmZweQNIq/ER0tQJKJynNkRB3kyvt37eOfsbMCXk3gnLF6J9OJ4QWftw==} + + '@prisma/debug@7.8.0': + resolution: {integrity: sha512-p+QZReysDUqXC+mk17q9a+Y/qzh4c2KYliDK30buYUyfrGeTGSyfmc0AIrJRhZJrLHhRiJa9Au/J72h3C+szvA==} + + '@prisma/dev@0.24.3': + resolution: {integrity: sha512-ffHlQuKXZiaDt9Go0OnCTdJZrHxK0k7omJKNV86/VjpsXu5EIHZLK0T7JSWgvNlJwh56kW9JFu9v0qJciFzepg==} + + '@prisma/driver-adapter-utils@7.8.0': + resolution: {integrity: sha512-/Q13o0ZT0rjc1Xk0Q9KhZYwuq2EW/vSbWUBKfgEKkaCuB/Sg6bqnjmTZqC5cD4d6y1vfFAEwBRzfzoSMIVJ55A==} + + '@prisma/engines-version@7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a': + resolution: {integrity: sha512-fJPQxCkLgA5EayWaW8eArgCvjJ+N+Kz3VyeNKMEeYiQC4alNkxRKFVAGxv/ZUzuJISKqdw+zGeDbS6mn6RCPOA==} + + '@prisma/engines@7.8.0': + resolution: {integrity: sha512-jx3rCnNNrt5uzbkKlegtQ2GZHxSlihMCzutgT/BP6UIDF1r9tDI39hV/0T/cHZgzJ3ELbuQPXlVZy+Y1n0pcgw==} + + '@prisma/fetch-engine@7.8.0': + resolution: {integrity: sha512-gwB0Euiz/DDRyxFRpLXYlK3RfaZUj1c5dAYMuhZYfApg7arknJlcb9bIsOHDppJmbqYaVA+yBIiFMDBfprsNPQ==} + + '@prisma/get-platform@7.2.0': + resolution: {integrity: sha512-k1V0l0Td1732EHpAfi2eySTezyllok9dXb6UQanajkJQzPUGi3vO2z7jdkz67SypFTdmbnyGYxvEvYZdZsMAVA==} + + '@prisma/get-platform@7.8.0': + resolution: {integrity: sha512-WlxgRGnolL8VH2EmkH1R/DkKNr/mVdS3G2h42IZFFZ3eUrH9OT6t73kIOSlkkrv50wG123Iq8d96ufv5LlZktw==} + + '@prisma/query-plan-executor@7.2.0': + resolution: {integrity: sha512-EOZmNzcV8uJ0mae3DhTsiHgoNCuu1J9mULQpGCh62zN3PxPTd+qI9tJvk5jOst8WHKQNwJWR3b39t0XvfBB0WQ==} + + '@prisma/streams-local@0.1.2': + resolution: {integrity: sha512-l49yTxKKF2odFxaAXTmwmkBKL3+bVQ1tFOooGifu4xkdb9NMNLxHj27XAhTylWZod8I+ISGM5erU1xcl/oBCtg==} + engines: {bun: '>=1.3.6', node: '>=22.0.0'} + + '@prisma/studio-core@0.27.3': + resolution: {integrity: sha512-AADjNFPdsrglxHQVTmHFqv6DuKQZ5WY4p5/gVFY017twvNrSwpLJ9lqUbYYxEu2W7nbvVxTZA8deJ8LseNALsw==} + engines: {node: ^20.19 || ^22.12 || >=24.0, pnpm: '8'} + peerDependencies: + '@types/react': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + + '@radix-ui/primitive@1.1.3': + resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} + + '@radix-ui/react-compose-refs@1.1.2': + resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-primitive@2.1.3': + resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-slot@1.2.0': + resolution: {integrity: sha512-ujc+V6r0HNDviYqIK3rW4ffgYiZ8g5DEHrGJVk4x7kTlLXRDILnKX9vAUYeIsLOoDpDJ0ujpqMkjH4w2ofuo6w==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-slot@1.2.3': + resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-toggle@1.1.10': + resolution: {integrity: sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-use-controllable-state@1.2.2': + resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-effect-event@0.0.2': + resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-layout-effect@1.1.1': + resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@react-email/render@1.1.2': + resolution: {integrity: sha512-RnRehYN3v9gVlNMehHPHhyp2RQo7+pSkHDtXPvg3s0GbzM9SQMW4Qrf8GRNvtpLC4gsI+Wt0VatNRUFqjvevbw==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^18.0 || ^19.0 || ^19.0.0-rc + + '@react-native-async-storage/async-storage@2.2.0': + resolution: {integrity: sha512-gvRvjR5JAaUZF8tv2Kcq/Gbt3JHwbKFYfmb445rhOj6NUMx3qPLixmDx5pZAyb9at1bYvJ4/eTUipU5aki45xw==} + peerDependencies: + react-native: ^0.0.0-0 || >=0.65 <1.0 + + '@react-native-community/slider@5.2.0': + resolution: {integrity: sha512-484sH8aWEaSjxaZ7HT3YZ8CKDcNes2synko1vdEz5DFEdvKAduxKJTj22L/qBMD7rtIkfbX69DMzWDAGbOAV6w==} + + '@react-native/assets-registry@0.79.6': + resolution: {integrity: sha512-UVSP1224PWg0X+mRlZNftV5xQwZGfawhivuW8fGgxNK9MS/U84xZ+16lkqcPh1ank6MOt239lIWHQ1S33CHgqA==} + engines: {node: '>=18'} + + '@react-native/babel-plugin-codegen@0.79.6': + resolution: {integrity: sha512-CS5OrgcMPixOyUJ/Sk/HSsKsKgyKT5P7y3CojimOQzWqRZBmoQfxdST4ugj7n1H+ebM2IKqbgovApFbqXsoX0g==} + engines: {node: '>=18'} + + '@react-native/babel-preset@0.79.6': + resolution: {integrity: sha512-H+FRO+r2Ql6b5IwfE0E7D52JhkxjeGSBSUpCXAI5zQ60zSBJ54Hwh2bBJOohXWl4J+C7gKYSAd2JHMUETu+c/A==} + engines: {node: '>=18'} + peerDependencies: + '@babel/core': '*' + + '@react-native/codegen@0.79.6': + resolution: {integrity: sha512-iRBX8Lgbqypwnfba7s6opeUwVyaR23mowh9ILw7EcT2oLz3RqMmjJdrbVpWhGSMGq2qkPfqAH7bhO8C7O+xfjQ==} + engines: {node: '>=18'} + peerDependencies: + '@babel/core': '*' + + '@react-native/community-cli-plugin@0.79.6': + resolution: {integrity: sha512-ZHVst9vByGsegeaddkD2YbZ6NvYb4n3pD9H7Pit94u+NlByq2uBJghoOjT6EKqg+UVl8tLRdi88cU2pDPwdHqA==} + engines: {node: '>=18'} + peerDependencies: + '@react-native-community/cli': '*' + peerDependenciesMeta: + '@react-native-community/cli': + optional: true + + '@react-native/debugger-frontend@0.79.6': + resolution: {integrity: sha512-lIK/KkaH7ueM22bLO0YNaQwZbT/oeqhaghOvmZacaNVbJR1Cdh/XAqjT8FgCS+7PUnbxA8B55NYNKGZG3O2pYw==} + engines: {node: '>=18'} + + '@react-native/dev-middleware@0.79.6': + resolution: {integrity: sha512-BK3GZBa9c7XSNR27EDRtxrgyyA3/mf1j3/y+mPk7Ac0Myu85YNrXnC9g3mL5Ytwo0g58TKrAIgs1fF2Q5Mn6mQ==} + engines: {node: '>=18'} + + '@react-native/gradle-plugin@0.79.6': + resolution: {integrity: sha512-C5odetI6py3CSELeZEVz+i00M+OJuFZXYnjVD4JyvpLn462GesHRh+Se8mSkU5QSaz9cnpMnyFLJAx05dokWbA==} + engines: {node: '>=18'} + + '@react-native/js-polyfills@0.79.6': + resolution: {integrity: sha512-6wOaBh1namYj9JlCNgX2ILeGUIwc6OP6MWe3Y5jge7Xz9fVpRqWQk88Q5Y9VrAtTMTcxoX3CvhrfRr3tGtSfQw==} + engines: {node: '>=18'} + + '@react-native/normalize-colors@0.79.6': + resolution: {integrity: sha512-0v2/ruY7eeKun4BeKu+GcfO+SHBdl0LJn4ZFzTzjHdWES0Cn+ONqKljYaIv8p9MV2Hx/kcdEvbY4lWI34jC/mQ==} + + '@react-native/virtualized-lists@0.79.6': + resolution: {integrity: sha512-khA/Hrbb+rB68YUHrLubfLgMOD9up0glJhw25UE3Kntj32YDyuO0Tqc81ryNTcCekFKJ8XrAaEjcfPg81zBGPw==} + engines: {node: '>=18'} + peerDependencies: + '@types/react': ^19.0.0 + react: '*' + react-native: '*' + peerDependenciesMeta: + '@types/react': + optional: true + + '@react-navigation/bottom-tabs@7.15.11': + resolution: {integrity: sha512-+WtNbd6fJgbViDNjmBUUP7eTgGH+zBtrl3jHuNnfUfXTs9YGuI5q3SiHIc9a5gY3voBOxbOXEiHJyW4xea7nAw==} + peerDependencies: + '@react-navigation/native': ^7.2.2 + react: '>= 18.2.0' + react-native: '*' + react-native-safe-area-context: '>= 4.0.0' + react-native-screens: '>= 4.0.0' + + '@react-navigation/core@7.17.2': + resolution: {integrity: sha512-Rt2OZwcgOmjv401uLGAKaRM6xo0fiBce/A7LfRHI1oe5FV+KooWcgAoZ2XOtgKj6UzVMuQWt3b2e6rxo/mDJRA==} + peerDependencies: + react: '>= 18.2.0' + + '@react-navigation/elements@2.9.15': + resolution: {integrity: sha512-cyz/pPiyyC6gaTVLsGFc1g0MYgrmuCFqklAWGXMWPscr5YU3ui94vPI4vnZwcsEy0T758TQWLzmS5XudZeRKcA==} + peerDependencies: + '@react-native-masked-view/masked-view': '>= 0.2.0' + '@react-navigation/native': ^7.2.2 + react: '>= 18.2.0' + react-native: '*' + react-native-safe-area-context: '>= 4.0.0' + peerDependenciesMeta: + '@react-native-masked-view/masked-view': + optional: true + + '@react-navigation/native-stack@7.14.12': + resolution: {integrity: sha512-dUfpkrVeVKKV8iqXsmoUp3Rv0iH3YaB3eZwScru/FlcqAp/r3/qA6zEXkGX9hZK+/ziWAPFrf1frBSNbgOYSFQ==} + peerDependencies: + '@react-navigation/native': ^7.2.2 + react: '>= 18.2.0' + react-native: '*' + react-native-safe-area-context: '>= 4.0.0' + react-native-screens: '>= 4.0.0' + + '@react-navigation/native@7.2.2': + resolution: {integrity: sha512-kem1Ko2BcbAjmbQIv66dNmr6EtfDut3QU0qjsVhMnLLhktwyXb6FzZYp8gTrUb6AvkAbaJoi+BF5Pl55pAUa5w==} + peerDependencies: + react: '>= 18.2.0' + react-native: '*' + + '@react-navigation/routers@7.5.3': + resolution: {integrity: sha512-1tJHg4KKRJuQ1/EvJxatrMef3NZXEPzwUIUZ3n1yJ2t7Q97siwRtbynRpQG9/69ebbtiZ8W3ScOZF/OmhvM4Rg==} + + '@rollup/plugin-alias@6.0.0': + resolution: {integrity: sha512-tPCzJOtS7uuVZd+xPhoy5W4vThe6KWXNmsFCNktaAh5RTqcLiSfT4huPQIXkgJ6YCOjJHvecOAzQxLFhPxKr+g==} + engines: {node: '>=20.19.0'} + peerDependencies: + rollup: '>=4.0.0' + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/plugin-commonjs@29.0.2': + resolution: {integrity: sha512-S/ggWH1LU7jTyi9DxZOKyxpVd4hF/OZ0JrEbeLjXk/DFXwRny0tjD2c992zOUYQobLrVkRVMDdmHP16HKP7GRg==} + engines: {node: '>=16.0.0 || 14 >= 14.17'} + peerDependencies: + rollup: ^2.68.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/plugin-inject@5.0.5': + resolution: {integrity: sha512-2+DEJbNBoPROPkgTDNe8/1YXWcqxbN5DTjASVIOx8HS+pITXushyNiBV56RB08zuptzz8gT3YfkqriTBVycepg==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/plugin-json@6.1.0': + resolution: {integrity: sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/plugin-node-resolve@16.0.3': + resolution: {integrity: sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^2.78.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/plugin-replace@6.0.3': + resolution: {integrity: sha512-J4RZarRvQAm5IF0/LwUUg+obsm+xZhYnbMXmXROyoSE1ATJe3oXSb9L5MMppdxP2ylNSjv6zFBwKYjcKMucVfA==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/plugin-terser@1.0.0': + resolution: {integrity: sha512-FnCxhTBx6bMOYQrar6C8h3scPt8/JwIzw3+AJ2K++6guogH5fYaIFia+zZuhqv0eo1RN7W1Pz630SyvLbDjhtQ==} + engines: {node: '>=20.0.0'} + peerDependencies: + rollup: ^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/pluginutils@5.3.0': + resolution: {integrity: sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/rollup-android-arm-eabi@4.60.3': + resolution: {integrity: sha512-x35CNW/ANXG3hE/EZpRU8MXX1JDN86hBb2wMGAtltkz7pc6cxgjpy1OMMfDosOQ+2hWqIkag/fGok1Yady9nGw==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.60.3': + resolution: {integrity: sha512-xw3xtkDApIOGayehp2+Rz4zimfkaX65r4t47iy+ymQB2G4iJCBBfj0ogVg5jpvjpn8UWn/+q9tprxleYeNp3Hw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.60.3': + resolution: {integrity: sha512-vo6Y5Qfpx7/5EaamIwi0WqW2+zfiusVihKatLvtN1VFVy3D13uERk/6gZLU1UiHRL6fDXqj/ELIeVRGnvcTE1g==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.60.3': + resolution: {integrity: sha512-D+0QGcZhBzTN82weOnsSlY7V7+RMmPuF1CkbxyMAGE8+ZHeUjyb76ZiWmBlCu//AQQONvxcqRbwZTajZKqjuOw==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.60.3': + resolution: {integrity: sha512-6HnvHCT7fDyj6R0Ph7A6x8dQS/S38MClRWeDLqc0MdfWkxjiu1HSDYrdPhqSILzjTIC/pnXbbJbo+ft+gy/9hQ==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.60.3': + resolution: {integrity: sha512-KHLgC3WKlUYW3ShFKnnosZDOJ0xjg9zp7au3sIm2bs/tGBeC2ipmvRh/N7JKi0t9Ue20C0dpEshi8WUubg+cnA==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.60.3': + resolution: {integrity: sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.60.3': + resolution: {integrity: sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.60.3': + resolution: {integrity: sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.60.3': + resolution: {integrity: sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loong64-gnu@4.60.3': + resolution: {integrity: sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-loong64-musl@4.60.3': + resolution: {integrity: sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.60.3': + resolution: {integrity: sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-ppc64-musl@4.60.3': + resolution: {integrity: sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.60.3': + resolution: {integrity: sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.60.3': + resolution: {integrity: sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.60.3': + resolution: {integrity: sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.60.3': + resolution: {integrity: sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.60.3': + resolution: {integrity: sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openbsd-x64@4.60.3': + resolution: {integrity: sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.60.3': + resolution: {integrity: sha512-AaXwSvUi3QIPtroAUw1t5yHGIyqKEXwH54WUocFolZhpGDruJcs8c+xPNDRn4XiQsS7MEwnYsHW2l0MBLDMkWg==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.60.3': + resolution: {integrity: sha512-65LAKM/bAWDqKNEelHlcHvm2V+Vfb8C6INFxQXRHCvaVN1rJfwr4NvdP4FyzUaLqWfaCGaadf6UbTm8xJeYfEg==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.60.3': + resolution: {integrity: sha512-EEM2gyhBF5MFnI6vMKdX1LAosE627RGBzIoGMdLloPZkXrUN0Ckqgr2Qi8+J3zip/8NVVro3/FjB+tjhZUgUHA==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.60.3': + resolution: {integrity: sha512-E5Eb5H/DpxaoXH++Qkv28RcUJboMopmdDUALBczvHMf7hNIxaDZqwY5lK12UK1BHacSmvupoEWGu+n993Z0y1A==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.60.3': + resolution: {integrity: sha512-hPt/bgL5cE+Qp+/TPHBqptcAgPzgj46mPcg/16zNUmbQk0j+mOEQV/+Lqu8QRtDV3Ek95Q6FeFITpuhl6OTsAA==} + cpu: [x64] + os: [win32] + + '@selderee/plugin-htmlparser2@0.11.0': + resolution: {integrity: sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==} + + '@sinclair/typebox@0.27.10': + resolution: {integrity: sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==} + + '@sindresorhus/is@7.2.0': + resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==} + engines: {node: '>=18'} + + '@sindresorhus/merge-streams@4.0.0': + resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} + engines: {node: '>=18'} + + '@sinonjs/commons@3.0.1': + resolution: {integrity: sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==} + + '@sinonjs/fake-timers@10.3.0': + resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==} + + '@speed-highlight/core@1.2.15': + resolution: {integrity: sha512-BMq1K3DsElxDWawkX6eLg9+CKJrTVGCBAWVuHXVUV2u0s2711qiChLSId6ikYPfxhdYocLNt3wWwSvDiTvFabw==} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@supabase/auth-js@2.105.3': + resolution: {integrity: sha512-hMFuzP++mjRfe0/BUq4/e82CXIDgyjUgg0khLN8waol/gzoM1t2iGmhfJSGvQHQ1dr3XqWpP6ThAw4bLHMot5Q==} + engines: {node: '>=20.0.0'} + + '@supabase/functions-js@2.105.3': + resolution: {integrity: sha512-KyutUwLLUZ9fRXsiFACL6lq7akBVHFl0fnqQnrxjbsPco8jeb4EyirQuvr52QCLnikzjMRC0uxAHOSM54aDrZA==} + engines: {node: '>=20.0.0'} + + '@supabase/phoenix@0.4.1': + resolution: {integrity: sha512-hWGJkDAfWUNY8k0C080u3sGNFd2ncl9erhKgP7hnGkgJWEfT5Pd/SXal4QmWXBECVlZrannMAc9sBaaRyWpiUA==} + + '@supabase/postgrest-js@2.105.3': + resolution: {integrity: sha512-jFVYRHcri0ZMcTzKpQ2r2wWOB8/rPsbj92kxmCmVJUiRrdgiMtuYlkS06Fhs8UJZhEOL0UpGhh06XDwh8JwtBQ==} + engines: {node: '>=20.0.0'} + + '@supabase/realtime-js@2.105.3': + resolution: {integrity: sha512-L+qPiJlq1RKh3QD2fORGCFo2RKDKlvG9mjvPtUEQJ2tMixrx70VIV6j8BdWzQkbc1Nao6mvTWajyDhX3TFgljw==} + engines: {node: '>=20.0.0'} + + '@supabase/storage-js@2.105.3': + resolution: {integrity: sha512-M7oPCCcHim/FsR6rKIs10Nd9mW051N2SQvA27jiVLa7oQMFFb7faX5dCQRV4GS5QeFsBcV5J/fWl4Ppoaw8cBQ==} + engines: {node: '>=20.0.0'} + + '@supabase/supabase-js@2.105.3': + resolution: {integrity: sha512-5Dm9+I61LAWwjw+0zcqXhSmTxUJaYHBPyHwMCIBH4TBUNwDn2pYUIsi6oUu0I5r9HtLtaFl7w4wa+DV9gRsbDg==} + engines: {node: '>=20.0.0'} + + '@tanstack/query-core@5.100.9': + resolution: {integrity: sha512-SJSFw1S8+kQ0+knv/XGfrbocWoAlT7vDKsSImtLx3ZPQmEcR46hkDjLSvynSy25N8Ms4tIEini1FuBd5k7IscQ==} + + '@tanstack/react-query@5.100.9': + resolution: {integrity: sha512-Oa44XkaI3kCNN6ME0KByU3xT3SEUNOMfZpHxL6+wFoTm+OeUFYHKdeYVe0aOXlRDm/f15sgLwEt2HDorIdW8+A==} + peerDependencies: + react: ^18 || ^19 + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/graceful-fs@4.1.9': + resolution: {integrity: sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==} + + '@types/hammerjs@2.0.46': + resolution: {integrity: sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw==} + + '@types/istanbul-lib-coverage@2.0.6': + resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==} + + '@types/istanbul-lib-report@3.0.3': + resolution: {integrity: sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==} + + '@types/istanbul-reports@3.0.4': + resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==} + + '@types/node-fetch@2.6.13': + resolution: {integrity: sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==} + + '@types/node@18.19.130': + resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==} + + '@types/node@22.19.17': + resolution: {integrity: sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==} + + '@types/pg@8.20.0': + resolution: {integrity: sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==} + + '@types/react@19.0.14': + resolution: {integrity: sha512-ixLZ7zG7j1fM0DijL9hDArwhwcCb4vqmePgwtV0GfnkHRSCUEv4LvzarcTdhoqgyMznUx/EhoTUv31CKZzkQlw==} + + '@types/resolve@1.20.2': + resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==} + + '@types/stack-utils@2.0.3': + resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} + + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + + '@types/yargs-parser@21.0.3': + resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} + + '@types/yargs@17.0.35': + resolution: {integrity: sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==} + + '@urql/core@5.2.0': + resolution: {integrity: sha512-/n0ieD0mvvDnVAXEQgX/7qJiVcvYvNkOHeBvkwtylfjydar123caCXcl58PXFY11oU1oquJocVXHxLAbtv4x1A==} + + '@urql/exchange-retry@1.3.2': + resolution: {integrity: sha512-TQMCz2pFJMfpNxmSfX1VSfTjwUIFx/mL+p1bnfM1xjjdla7Z+KnGMW/EhFbpckp3LyWAH4PgOsMwOMnIN+MBFg==} + peerDependencies: + '@urql/core': ^5.0.0 + + '@vercel/nft@1.5.0': + resolution: {integrity: sha512-IWTDeIoWhQ7ZtRO/JRKH+jhmeQvZYhtGPmzw/QGDY+wDCQqfm25P9yIdoAFagu4fWsK4IwZXDFIjrmp5rRm/sA==} + engines: {node: '>=20'} + hasBin: true + + '@xmldom/xmldom@0.8.13': + resolution: {integrity: sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==} + engines: {node: '>=10.0.0'} + + '@xmldom/xmldom@0.9.10': + resolution: {integrity: sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==} + engines: {node: '>=14.6'} + + '@zone-eu/mailsplit@5.4.9': + resolution: {integrity: sha512-Qq7k6FzA5SmGf5HFPcr17gE7M+O1gttlmWn7tlGUlhGsbbjUaBL/4cEWIwExeCzqu5+kyZJ91mcBZbQ9zEwwYA==} + + abbrev@3.0.1: + resolution: {integrity: sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==} + engines: {node: ^18.17.0 || >=20.5.0} + + abort-controller@3.0.0: + resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} + engines: {node: '>=6.5'} + + accepts@1.3.8: + resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} + engines: {node: '>= 0.6'} + + acorn-import-attributes@1.9.5: + resolution: {integrity: sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==} + peerDependencies: + acorn: ^8 + + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true + + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + + agentkeepalive@4.6.0: + resolution: {integrity: sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==} + engines: {node: '>= 8.0.0'} + + ajv@8.11.0: + resolution: {integrity: sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==} + + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + + anser@1.4.10: + resolution: {integrity: sha512-hCv9AqTQ8ycjpSd3upOJd7vFwW1JaoYQ7tpham03GJ1ca8/65rqn0RpaWpItOAd6ylW9wAw6luXYPJIyPFVOww==} + + ansi-escapes@4.3.2: + resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} + engines: {node: '>=8'} + + ansi-regex@4.1.1: + resolution: {integrity: sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==} + engines: {node: '>=6'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + + ansi-styles@3.2.1: + resolution: {integrity: sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==} + engines: {node: '>=4'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + + any-promise@1.3.0: + resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + archiver-utils@5.0.2: + resolution: {integrity: sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA==} + engines: {node: '>= 14'} + + archiver@7.0.1: + resolution: {integrity: sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ==} + engines: {node: '>= 14'} + + arg@5.0.2: + resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} + + argparse@1.0.10: + resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + array-timsort@1.0.3: + resolution: {integrity: sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ==} + + asap@2.0.6: + resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} + + assert@2.1.0: + resolution: {integrity: sha512-eLHpSK/Y4nhMJ07gDaAzoX/XAKS8PSaojml3M0DM4JpV1LAi5JOJ/p6H/XWrl8L+DzVEvVCW1z3vWAaB9oTsQw==} + + async-limiter@1.0.1: + resolution: {integrity: sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==} + + async-sema@3.1.1: + resolution: {integrity: sha512-tLRNUXati5MFePdAk8dw7Qt7DpxPB60ofAgn8WRhW6a2rcimZnYBP9oxHiv0OHy+Wz7kPMG+t4LGdt31+4EmGg==} + + async@3.2.6: + resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + atomic-sleep@1.0.0: + resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} + engines: {node: '>=8.0.0'} + + available-typed-arrays@1.0.7: + resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} + engines: {node: '>= 0.4'} + + aws-ssl-profiles@1.1.2: + resolution: {integrity: sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==} + engines: {node: '>= 6.0.0'} + + b4a@1.8.1: + resolution: {integrity: sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==} + peerDependencies: + react-native-b4a: '*' + peerDependenciesMeta: + react-native-b4a: + optional: true + + babel-jest@29.7.0: + resolution: {integrity: sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + '@babel/core': ^7.8.0 + + babel-plugin-istanbul@6.1.1: + resolution: {integrity: sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==} + engines: {node: '>=8'} + + babel-plugin-jest-hoist@29.6.3: + resolution: {integrity: sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + babel-plugin-polyfill-corejs2@0.4.17: + resolution: {integrity: sha512-aTyf30K/rqAsNwN76zYrdtx8obu0E4KoUME29B1xj+B3WxgvWkp943vYQ+z8Mv3lw9xHXMHpvSPOBxzAkIa94w==} + peerDependencies: + '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + + babel-plugin-polyfill-corejs3@0.13.0: + resolution: {integrity: sha512-U+GNwMdSFgzVmfhNm8GJUX88AadB3uo9KpJqS3FaqNIPKgySuvMb+bHPsOmmuWyIcuqZj/pzt1RUIUZns4y2+A==} + peerDependencies: + '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + + babel-plugin-polyfill-regenerator@0.6.8: + resolution: {integrity: sha512-M762rNHfSF1EV3SLtnCJXFoQbbIIz0OyRwnCmV0KPC7qosSfCO0QLTSuJX3ayAebubhE6oYBAYPrBA5ljowaZg==} + peerDependencies: + '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + + babel-plugin-react-native-web@0.19.13: + resolution: {integrity: sha512-4hHoto6xaN23LCyZgL9LJZc3olmAxd7b6jDzlZnKXAh4rRAbZRKNBJoOOdp46OBqgy+K0t0guTj5/mhA8inymQ==} + + babel-plugin-syntax-hermes-parser@0.25.1: + resolution: {integrity: sha512-IVNpGzboFLfXZUAwkLFcI/bnqVbwky0jP3eBno4HKtqvQJAHBLdgxiG6lQ4to0+Q/YCN3PO0od5NZwIKyY4REQ==} + + babel-plugin-transform-flow-enums@0.0.2: + resolution: {integrity: sha512-g4aaCrDDOsWjbm0PUUeVnkcVd6AKJsVc/MbnPhEotEpkeJQP6b8nzewohQi7+QS8UyPehOhGWn0nOwjvWpmMvQ==} + + babel-preset-current-node-syntax@1.2.0: + resolution: {integrity: sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==} + peerDependencies: + '@babel/core': ^7.0.0 || ^8.0.0-0 + + babel-preset-expo@13.2.5: + resolution: {integrity: sha512-YjVkP1bOLO2OgR2fyCedruYMPR7GFbAtCvvWITBW1UAp6e3ACYZtN6uoqkXgXP6PHQkb6M7qf2vZreBPEZK38A==} + peerDependencies: + babel-plugin-react-compiler: ^19.0.0-beta-e993439-20250405 + peerDependenciesMeta: + babel-plugin-react-compiler: + optional: true + + babel-preset-jest@29.6.3: + resolution: {integrity: sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + '@babel/core': ^7.0.0 + + badgin@1.2.3: + resolution: {integrity: sha512-NQGA7LcfCpSzIbGRbkgjgdWkjy7HI+Th5VLxTJfW5EeaAf3fnS+xWQaQOCYiny+q6QSvxqoSO04vCx+4u++EJw==} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + bare-events@2.8.2: + resolution: {integrity: sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==} + peerDependencies: + bare-abort-controller: '*' + peerDependenciesMeta: + bare-abort-controller: + optional: true + + bare-fs@4.7.1: + resolution: {integrity: sha512-WDRsyVN52eAx/lBamKD6uyw8H4228h/x0sGGGegOamM2cd7Pag88GfMQalobXI+HaEUxpCkbKQUDOQqt9wawRw==} + engines: {bare: '>=1.16.0'} + peerDependencies: + bare-buffer: '*' + peerDependenciesMeta: + bare-buffer: + optional: true + + bare-os@3.9.1: + resolution: {integrity: sha512-6M5XjcnsygQNPMCMPXSK379xrJFiZ/AEMNBmFEmQW8d/789VQATvriyi5r0HYTL9TkQ26rn3kgdTG3aisbrXkQ==} + engines: {bare: '>=1.14.0'} + + bare-path@3.0.0: + resolution: {integrity: sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==} + + bare-stream@2.13.1: + resolution: {integrity: sha512-Vp0cnjYyrEC4whYTymQ+YZi6pBpfiICZO3cfRG8sy67ZNWe951urv1x4eW1BKNngw3U+3fPYb5JQvHbCtxH7Ow==} + peerDependencies: + bare-abort-controller: '*' + bare-buffer: '*' + bare-events: '*' + peerDependenciesMeta: + bare-abort-controller: + optional: true + bare-buffer: + optional: true + bare-events: + optional: true + + bare-url@2.4.3: + resolution: {integrity: sha512-Kccpc7ACfXaxfeInfqKcZtW4pT5YBn1mesc4sCsun6sRwtbJ4h+sNOaksUpYEJUKfN65YWC6Bw2OJEFiKxq8nQ==} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + baseline-browser-mapping@2.10.27: + resolution: {integrity: sha512-zEs/ufmZoUd7WftKpKyXaT6RFxpQ5Qm9xytKRHvJfxFV9DFJkZph9RvJ1LcOUi0Z1ZVijMte65JbILeV+8QQEA==} + engines: {node: '>=6.0.0'} + hasBin: true + + better-opn@3.0.2: + resolution: {integrity: sha512-aVNobHnJqLiUelTaHat9DZ1qM2w0C0Eym4LPI/3JxOnSokGVdsl1T1kN7TFvsEAD8G47A6VKQ0TVHqbBnYMJlQ==} + engines: {node: '>=12.0.0'} + + better-result@2.9.2: + resolution: {integrity: sha512-WIFoBPCdnTOdk9inkE1ZRvCZ4P0CpSkAiLlchC65N7n9DcjZ3NhqkBOlafzpOVnO8ixyi37kicmSJ3ENhPZl7Q==} + + big-integer@1.6.52: + resolution: {integrity: sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==} + engines: {node: '>=0.6'} + + binary-extensions@2.3.0: + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} + engines: {node: '>=8'} + + bindings@1.5.0: + resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} + + boolbase@1.0.0: + resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + + bplist-creator@0.1.0: + resolution: {integrity: sha512-sXaHZicyEEmY86WyueLTQesbeoH/mquvarJaQNbjuOQO+7gbFcDEWqKmcWA4cOTLzFlfgvkiVxolk1k5bBIpmg==} + + bplist-parser@0.3.1: + resolution: {integrity: sha512-PyJxiNtA5T2PlLIeBot4lbp7rj4OadzjnMZD/G5zuBNt8ei/yCU7+wW0h2bag9vr8c+/WuRWmSxbqAl9hL1rBA==} + engines: {node: '>= 5.10.0'} + + bplist-parser@0.3.2: + resolution: {integrity: sha512-apC2+fspHGI3mMKj+dGevkGo/tCqVB8jMb6i+OX+E29p0Iposz07fABkRIfVUPNd5A5VbuOz1bZbnmkKLYF+wQ==} + engines: {node: '>= 5.10.0'} + + brace-expansion@1.1.14: + resolution: {integrity: sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==} + + brace-expansion@2.1.0: + resolution: {integrity: sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==} + + brace-expansion@5.0.5: + resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==} + engines: {node: 18 || 20 || >=22} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browserslist@4.28.2: + resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + bser@2.1.1: + resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==} + + buffer-crc32@1.0.0: + resolution: {integrity: sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==} + engines: {node: '>=8.0.0'} + + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + + buffer@6.0.3: + resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + + bundle-name@4.1.0: + resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} + engines: {node: '>=18'} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + c12@3.3.4: + resolution: {integrity: sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA==} + peerDependencies: + magicast: '*' + peerDependenciesMeta: + magicast: + optional: true + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bind@1.0.9: + resolution: {integrity: sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + caller-callsite@2.0.0: + resolution: {integrity: sha512-JuG3qI4QOftFsZyOn1qq87fq5grLIyk1JYd5lJmdA+fG7aQ9pA/i3JIJGcO3q0MrRcHlOt1U+ZeHW8Dq9axALQ==} + engines: {node: '>=4'} + + caller-path@2.0.0: + resolution: {integrity: sha512-MCL3sf6nCSXOwCTzvPKhN18TU7AHTvdtam8DAogxcrJ8Rjfbbg7Lgng64H9Iy+vUV6VGFClN/TyxBkAebLRR4A==} + engines: {node: '>=4'} + + callsites@2.0.0: + resolution: {integrity: sha512-ksWePWBloaWPxJYQ8TL0JHvtci6G5QTKwQ95RcWAa/lzoAKuAOflGdAK92hpHXjkwb8zLxoLNUoNYZgVsaJzvQ==} + engines: {node: '>=4'} + + camelcase-css@2.0.1: + resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} + engines: {node: '>= 6'} + + camelcase@5.3.1: + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} + engines: {node: '>=6'} + + camelcase@6.3.0: + resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} + engines: {node: '>=10'} + + caniuse-lite@1.0.30001791: + resolution: {integrity: sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==} + + chalk@2.4.2: + resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==} + engines: {node: '>=4'} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + chart.js@4.5.1: + resolution: {integrity: sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==} + engines: {pnpm: '>=8'} + + chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} + + chokidar@5.0.0: + resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} + engines: {node: '>= 20.19.0'} + + chownr@3.0.0: + resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} + engines: {node: '>=18'} + + chrome-launcher@0.15.2: + resolution: {integrity: sha512-zdLEwNo3aUVzIhKhTtXfxhdvZhUghrnmkvcAq2NoDd+LeOHKf03H5jwZ8T/STsAlzyALkBVK552iaG1fGf1xVQ==} + engines: {node: '>=12.13.0'} + hasBin: true + + chromium-edge-launcher@0.2.0: + resolution: {integrity: sha512-JfJjUnq25y9yg4FABRRVPmBGWPZZi+AQXT4mxupb67766/0UlhG8PAZCz6xzEMXTbW3CsSoE8PcCWA49n35mKg==} + + ci-info@2.0.0: + resolution: {integrity: sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==} + + ci-info@3.9.0: + resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==} + engines: {node: '>=8'} + + citty@0.1.6: + resolution: {integrity: sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==} + + citty@0.2.2: + resolution: {integrity: sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==} + + cli-cursor@2.1.0: + resolution: {integrity: sha512-8lgKz8LmCRYZZQDpRyT2m5rKJ08TnU4tR9FFFW2rxpxR1FzWi4PQ/NfyODchAatHaUgnSPVcx/R5w6NuTBzFiw==} + engines: {node: '>=4'} + + cli-spinners@2.9.2: + resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} + engines: {node: '>=6'} + + client-only@0.0.1: + resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + + cliui@9.0.1: + resolution: {integrity: sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==} + engines: {node: '>=20'} + + clone@1.0.4: + resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} + engines: {node: '>=0.8'} + + cluster-key-slot@1.1.2: + resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} + engines: {node: '>=0.10.0'} + + color-convert@1.9.3: + resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.3: + resolution: {integrity: sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + color-string@1.9.1: + resolution: {integrity: sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==} + + color@4.2.3: + resolution: {integrity: sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==} + engines: {node: '>=12.5.0'} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + commander@12.1.0: + resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} + engines: {node: '>=18'} + + commander@2.20.3: + resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} + + commander@4.1.1: + resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} + engines: {node: '>= 6'} + + commander@7.2.0: + resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} + engines: {node: '>= 10'} + + comment-json@4.6.2: + resolution: {integrity: sha512-R2rze/hDX30uul4NZoIZ76ImSJLFxn/1/ZxtKC1L77y2X1k+yYu1joKbAtMA2Fg3hZrTOiw0I5mwVMo0cf250w==} + engines: {node: '>= 6'} + + commondir@1.0.1: + resolution: {integrity: sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==} + + compatx@0.2.0: + resolution: {integrity: sha512-6gLRNt4ygsi5NyMVhceOCFv14CIdDFN7fQjX1U4+47qVE/+kjPoXMK65KWK+dWxmFzMTuKazoQ9sch6pM0p5oA==} + + compress-commons@6.0.2: + resolution: {integrity: sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg==} + engines: {node: '>= 14'} + + compressible@2.0.18: + resolution: {integrity: sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==} + engines: {node: '>= 0.6'} + + compression@1.8.1: + resolution: {integrity: sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==} + engines: {node: '>= 0.8.0'} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + + confbox@0.2.4: + resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==} + + connect@3.7.0: + resolution: {integrity: sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ==} + engines: {node: '>= 0.10.0'} + + consola@3.4.2: + resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} + engines: {node: ^14.18.0 || >=16.10.0} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie-es@1.2.3: + resolution: {integrity: sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw==} + + cookie-es@2.0.1: + resolution: {integrity: sha512-aVf4A4hI2w70LnF7GG+7xDQUkliwiXWXFvTjkip4+b64ygDQ2sJPRSKFDHbxn8o0xu9QzPkMuuiWIXyFSE2slA==} + + cookie-es@3.1.1: + resolution: {integrity: sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==} + + core-js-compat@3.49.0: + resolution: {integrity: sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA==} + + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + + cosmiconfig@5.2.1: + resolution: {integrity: sha512-H65gsXo1SKjf8zmrJ67eJk8aIRKV5ff2D4uKZIBZShbhGSpEmsQOPW/SKMKYhSTrqR7ufy6RP69rPogdaPh/kA==} + engines: {node: '>=4'} + + crc-32@1.2.2: + resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==} + engines: {node: '>=0.8'} + hasBin: true + + crc32-stream@6.0.0: + resolution: {integrity: sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g==} + engines: {node: '>= 14'} + + croner@10.0.1: + resolution: {integrity: sha512-ixNtAJndqh173VQ4KodSdJEI6nuioBWI0V1ITNKhZZsO0pEMoDxz539T4FTTbSZ/xIOSuDnzxLVRqBVSvPNE2g==} + engines: {node: '>=18.0'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + crossws@0.3.5: + resolution: {integrity: sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA==} + + crypto-random-string@2.0.0: + resolution: {integrity: sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==} + engines: {node: '>=8'} + + css-select@5.2.2: + resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==} + + css-tree@1.1.3: + resolution: {integrity: sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==} + engines: {node: '>=8.0.0'} + + css-what@6.2.2: + resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==} + engines: {node: '>= 6'} + + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + db0@0.3.4: + resolution: {integrity: sha512-RiXXi4WaNzPTHEOu8UPQKMooIbqOEyqA1t7Z6MsdxSCeb8iUC9ko3LcmsLmeUt2SM5bctfArZKkRQggKZz7JNw==} + peerDependencies: + '@electric-sql/pglite': '*' + '@libsql/client': '*' + better-sqlite3: '*' + drizzle-orm: '*' + mysql2: '*' + sqlite3: '*' + peerDependenciesMeta: + '@electric-sql/pglite': + optional: true + '@libsql/client': + optional: true + better-sqlite3: + optional: true + drizzle-orm: + optional: true + mysql2: + optional: true + sqlite3: + optional: true + + debug@2.6.9: + resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + debug@3.2.7: + resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decode-uri-component@0.2.2: + resolution: {integrity: sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==} + engines: {node: '>=0.10'} + + deep-extend@0.6.0: + resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} + engines: {node: '>=4.0.0'} + + deepmerge-ts@7.1.5: + resolution: {integrity: sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==} + engines: {node: '>=16.0.0'} + + deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + + default-browser-id@5.0.1: + resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==} + engines: {node: '>=18'} + + default-browser@5.5.0: + resolution: {integrity: sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==} + engines: {node: '>=18'} + + defaults@1.0.4: + resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} + + define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} + engines: {node: '>= 0.4'} + + define-lazy-prop@2.0.0: + resolution: {integrity: sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==} + engines: {node: '>=8'} + + define-lazy-prop@3.0.0: + resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==} + engines: {node: '>=12'} + + define-properties@1.2.1: + resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} + engines: {node: '>= 0.4'} + + defu@6.1.7: + resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + denque@2.1.0: + resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} + engines: {node: '>=0.10'} + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + destr@2.0.5: + resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} + + destroy@1.2.0: + resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + + detect-libc@1.0.3: + resolution: {integrity: sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==} + engines: {node: '>=0.10'} + hasBin: true + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + didyoumean@1.2.2: + resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} + + dlv@1.1.3: + resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} + + dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + + domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + + domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + + domutils@3.2.2: + resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + + dot-prop@10.1.0: + resolution: {integrity: sha512-MVUtAugQMOff5RnBy2d9N31iG0lNwg1qAoAOn7pOK5wf94WIaE3My2p3uwTQuvS2AcqchkcR3bHByjaM0mmi7Q==} + engines: {node: '>=20'} + + dotenv-expand@11.0.7: + resolution: {integrity: sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==} + engines: {node: '>=12'} + + dotenv@16.4.7: + resolution: {integrity: sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==} + engines: {node: '>=12'} + + dotenv@17.4.2: + resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} + engines: {node: '>=12'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + duplexer@0.1.2: + resolution: {integrity: sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==} + + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + effect@3.20.0: + resolution: {integrity: sha512-qMLfDJscrNG8p/aw+IkT9W7fgj50Z4wG5bLBy0Txsxz8iUHjDIkOgO3SV0WZfnQbNG2VJYb0b+rDLMrhM4+Krw==} + + electron-to-chromium@1.5.351: + resolution: {integrity: sha512-9D7Iqx8RImSvCnOsj86rCH6eQjZFQoM04Jn6HnZVM0Nu/G58/gmKYQ1d12MZTbjQbQSTGI8nwEy07ErsA2slLA==} + + emoji-regex@10.6.0: + resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + + empathic@2.0.0: + resolution: {integrity: sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==} + engines: {node: '>=14'} + + encodeurl@1.0.2: + resolution: {integrity: sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==} + engines: {node: '>= 0.8'} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + encoding-japanese@2.2.0: + resolution: {integrity: sha512-EuJWwlHPZ1LbADuKTClvHtwbaFn4rOD+dRAbWysqEOXRc2Uui0hJInNJrsdH0c+OhJA4nrCBdSkW4DD5YxAo6A==} + engines: {node: '>=8.10.0'} + + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + + env-editor@0.4.2: + resolution: {integrity: sha512-ObFo8v4rQJAE59M69QzwloxPZtd33TpYEIjtKD1rrFDcM1Gd7IkDxEBU+HriziN6HSHQnBJi8Dmy+JWkav5HKA==} + engines: {node: '>=8'} + + env-paths@3.0.0: + resolution: {integrity: sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + error-ex@1.3.4: + resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} + + error-stack-parser-es@1.0.5: + resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==} + + error-stack-parser@2.1.4: + resolution: {integrity: sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + esbuild@0.28.0: + resolution: {integrity: sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + escape-string-regexp@1.0.5: + resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} + engines: {node: '>=0.8.0'} + + escape-string-regexp@2.0.0: + resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} + engines: {node: '>=8'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + escape-string-regexp@5.0.0: + resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} + engines: {node: '>=12'} + + esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} + hasBin: true + + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + event-target-shim@5.0.1: + resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} + engines: {node: '>=6'} + + events-universal@1.0.1: + resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==} + + events@3.3.0: + resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} + engines: {node: '>=0.8.x'} + + expo-apple-authentication@7.2.4: + resolution: {integrity: sha512-T2agaLLPT4Ax97FeXImB7BCCEzEJ0gB+ZwlFa/FXBtbp6WFKcGRlTVKiX2YPYLZzN5QjXcmQ9HHJ17jRthNHMg==} + peerDependencies: + expo: '*' + react-native: '*' + + expo-application@6.1.5: + resolution: {integrity: sha512-ToImFmzw8luY043pWFJhh2ZMm4IwxXoHXxNoGdlhD4Ym6+CCmkAvCglg0FK8dMLzAb+/XabmOE7Rbm8KZb6NZg==} + peerDependencies: + expo: '*' + + expo-asset@11.1.7: + resolution: {integrity: sha512-b5P8GpjUh08fRCf6m5XPVAh7ra42cQrHBIMgH2UXP+xsj4Wufl6pLy6jRF5w6U7DranUMbsXm8TOyq4EHy7ADg==} + peerDependencies: + expo: '*' + react: '*' + react-native: '*' + + expo-av@15.1.7: + resolution: {integrity: sha512-NC+JR+65sxXfQN1mOHp3QBaXTL2J+BzNwVO27XgUEc5s9NaoBTdHWElYXrfxvik6xwytZ+a7abrqfNNgsbQzsA==} + peerDependencies: + expo: '*' + react: '*' + react-native: '*' + react-native-web: '*' + peerDependenciesMeta: + react-native-web: + optional: true + + expo-build-properties@0.14.8: + resolution: {integrity: sha512-GTFNZc5HaCS9RmCi6HspCe2+isleuOWt2jh7UEKHTDQ9tdvzkIoWc7U6bQO9lH3Mefk4/BcCUZD/utl7b1wdqw==} + peerDependencies: + expo: '*' + + expo-clipboard@55.0.13: + resolution: {integrity: sha512-PrOmmuVsGW4bAkNQmGKtxMXj3invsfN+jfIKmQxHwE/dn7ODqwFWviUTa+PMUjP3XZmYCDLyu/i0GLeu7HF9Ew==} + peerDependencies: + expo: '*' + react: '*' + react-native: '*' + + expo-constants@17.1.8: + resolution: {integrity: sha512-sOCeMN/BWLA7hBP6lMwoEQzFNgTopk6YY03sBAmwT216IHyL54TjNseg8CRU1IQQ/+qinJ2fYWCl7blx2TiNcA==} + peerDependencies: + expo: '*' + react-native: '*' + + expo-dev-client@5.2.4: + resolution: {integrity: sha512-s/N/nK5LPo0QZJpV4aPijxyrzV4O49S3dN8D2fljqrX2WwFZzWwFO6dX1elPbTmddxumdcpczsdUPY+Ms8g43g==} + peerDependencies: + expo: '*' + + expo-dev-launcher@5.1.16: + resolution: {integrity: sha512-tbCske9pvbozaEblyxoyo/97D6od9Ma4yAuyUnXtRET1CKAPKYS+c4fiZ+I3B4qtpZwN3JNFUjG3oateN0y6Hg==} + peerDependencies: + expo: '*' + + expo-dev-menu-interface@1.10.0: + resolution: {integrity: sha512-NxtM/qot5Rh2cY333iOE87dDg1S8CibW+Wu4WdLua3UMjy81pXYzAGCZGNOeY7k9GpNFqDPNDXWyBSlk9r2pBg==} + peerDependencies: + expo: '*' + + expo-dev-menu@6.1.14: + resolution: {integrity: sha512-yonNMg2GHJZtuisVowdl1iQjZfYP85r1D1IO+ar9D9zlrBPBJhq2XEju52jd1rDmDkmDuEhBSbPNhzIcsBNiPg==} + peerDependencies: + expo: '*' + + expo-file-system@18.1.11: + resolution: {integrity: sha512-HJw/m0nVOKeqeRjPjGdvm+zBi5/NxcdPf8M8P3G2JFvH5Z8vBWqVDic2O58jnT1OFEy0XXzoH9UqFu7cHg9DTQ==} + peerDependencies: + expo: '*' + react-native: '*' + + expo-font@13.0.4: + resolution: {integrity: sha512-eAP5hyBgC8gafFtprsz0HMaB795qZfgJWqTmU0NfbSin1wUuVySFMEPMOrTkTgmazU73v4Cb4x7p86jY1XXYUw==} + peerDependencies: + expo: '*' + react: '*' + + expo-font@13.3.2: + resolution: {integrity: sha512-wUlMdpqURmQ/CNKK/+BIHkDA5nGjMqNlYmW0pJFXY/KE/OG80Qcavdu2sHsL4efAIiNGvYdBS10WztuQYU4X0A==} + peerDependencies: + expo: '*' + react: '*' + + expo-haptics@55.0.14: + resolution: {integrity: sha512-KjDItBsA9mi1f5nRwf8g1wOdfEcLHwvEdt5Jl1sMCDETR/homcGOl+F3QIiPOl/PRlbGVieQsjTtF4DGtHOj6g==} + peerDependencies: + expo: '*' + + expo-image-loader@5.1.0: + resolution: {integrity: sha512-sEBx3zDQIODWbB5JwzE7ZL5FJD+DK3LVLWBVJy6VzsqIA6nDEnSFnsnWyCfCTSvbGigMATs1lgkC2nz3Jpve1Q==} + peerDependencies: + expo: '*' + + expo-image-picker@16.1.4: + resolution: {integrity: sha512-bTmmxtw1AohUT+HxEBn2vYwdeOrj1CLpMXKjvi9FKSoSbpcarT4xxI0z7YyGwDGHbrJqyyic3I9TTdP2J2b4YA==} + peerDependencies: + expo: '*' + + expo-json-utils@0.15.0: + resolution: {integrity: sha512-duRT6oGl80IDzH2LD2yEFWNwGIC2WkozsB6HF3cDYNoNNdUvFk6uN3YiwsTsqVM/D0z6LEAQ01/SlYvN+Fw0JQ==} + + expo-keep-awake@14.1.4: + resolution: {integrity: sha512-wU9qOnosy4+U4z/o4h8W9PjPvcFMfZXrlUoKTMBW7F4pLqhkkP/5G4EviPZixv4XWFMjn1ExQ5rV6BX8GwJsWA==} + peerDependencies: + expo: '*' + react: '*' + + expo-linking@7.1.7: + resolution: {integrity: sha512-ZJaH1RIch2G/M3hx2QJdlrKbYFUTOjVVW4g39hfxrE5bPX9xhZUYXqxqQtzMNl1ylAevw9JkgEfWbBWddbZ3UA==} + peerDependencies: + react: '*' + react-native: '*' + + expo-localization@16.1.6: + resolution: {integrity: sha512-v4HwNzs8QvyKHwl40MvETNEKr77v1o9/eVC8WCBY++DIlBAvonHyJe2R9CfqpZbC4Tlpl7XV+07nLXc8O5PQsA==} + peerDependencies: + expo: '*' + react: '*' + + expo-manifests@0.16.6: + resolution: {integrity: sha512-1A+do6/mLUWF9xd3uCrlXr9QFDbjbfqAYmUy8UDLOjof1lMrOhyeC4Yi6WexA/A8dhZEpIxSMCKfn7G4aHAh4w==} + peerDependencies: + expo: '*' + + expo-modules-autolinking@2.1.15: + resolution: {integrity: sha512-IUITUERdkgooXjr9bXsX0PmhrZUIGTMyP6NtmQpAxN5+qtf/I7ewbwLx1/rX7tgiAOzaYme+PZOp/o6yqIhFsw==} + hasBin: true + + expo-modules-core@2.5.0: + resolution: {integrity: sha512-aIbQxZE2vdCKsolQUl6Q9Farlf8tjh/ROR4hfN1qT7QBGPl1XrJGnaOKkcgYaGrlzCPg/7IBe0Np67GzKMZKKQ==} + + expo-notifications@0.31.5: + resolution: {integrity: sha512-HsitfTrSESFDWwaX0Y+6GQlWEooQqZKdGbNTwTPHfp5PNCr02tVPwwya9j1tdg3Awj8/vmfXmSxzNhULfmgJhQ==} + peerDependencies: + expo: '*' + react: '*' + react-native: '*' + + expo-router@5.1.11: + resolution: {integrity: sha512-6YQGqQM2rviVSiU6++hrJDPMByHZ7Oiux4XmgoSaGdaHku5QOn9911f2puEUZh2H9ALKBipw5v3ZkrECBd6Zbw==} + peerDependencies: + '@react-navigation/drawer': ^7.3.9 + '@testing-library/jest-native': '*' + expo: '*' + expo-constants: '*' + expo-linking: '*' + react-native-reanimated: '*' + react-native-safe-area-context: '*' + react-native-screens: '*' + react-server-dom-webpack: ~19.0.4 || ~19.1.5 || ~19.2.4 + peerDependenciesMeta: + '@react-navigation/drawer': + optional: true + '@testing-library/jest-native': + optional: true + react-native-reanimated: + optional: true + react-server-dom-webpack: + optional: true + + expo-speech@13.1.7: + resolution: {integrity: sha512-RMMgK6IIPQD9uLhmY2Q9v+2j3wmTGqB/qZ7sdy5//5TLCzFwAq1vlpy5A2psVsctoWVxUO4EmlaNai0ahQmKRg==} + peerDependencies: + expo: '*' + + expo-splash-screen@0.30.10: + resolution: {integrity: sha512-Tt9va/sLENQDQYeOQ6cdLdGvTZ644KR3YG9aRlnpcs2/beYjOX1LHT510EGzVN9ljUTg+1ebEo5GGt2arYtPjw==} + peerDependencies: + expo: '*' + + expo-status-bar@2.2.3: + resolution: {integrity: sha512-+c8R3AESBoduunxTJ8353SqKAKpxL6DvcD8VKBuh81zzJyUUbfB4CVjr1GufSJEKsMzNPXZU+HJwXx7Xh7lx8Q==} + peerDependencies: + react: '*' + react-native: '*' + + expo-updates-interface@1.1.0: + resolution: {integrity: sha512-DeB+fRe0hUDPZhpJ4X4bFMAItatFBUPjw/TVSbJsaf3Exeami+2qbbJhWkcTMoYHOB73nOIcaYcWXYJnCJXO0w==} + peerDependencies: + expo: '*' + + expo-web-browser@14.2.0: + resolution: {integrity: sha512-6S51d8pVlDRDsgGAp8BPpwnxtyKiMWEFdezNz+5jVIyT+ctReW42uxnjRgtsdn5sXaqzhaX+Tzk/CWaKCyC0hw==} + peerDependencies: + expo: '*' + react-native: '*' + + expo@53.0.27: + resolution: {integrity: sha512-iQwe2uWLb88opUY4vBYEW1d2GUq3lsa43gsMBEdDV+6pw0Oek93l/4nDLe0ODDdrBRjIJm/rdhKqJC/ehHCUqw==} + hasBin: true + peerDependencies: + '@expo/dom-webview': '*' + '@expo/metro-runtime': '*' + react: '*' + react-native: '*' + react-native-webview: '*' + peerDependenciesMeta: + '@expo/dom-webview': + optional: true + '@expo/metro-runtime': + optional: true + react-native-webview: + optional: true + + exponential-backoff@3.1.3: + resolution: {integrity: sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==} + + exsolve@1.0.8: + resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} + + fast-check@3.23.2: + resolution: {integrity: sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==} + engines: {node: '>=8.0.0'} + + fast-deep-equal@2.0.1: + resolution: {integrity: sha512-bCK/2Z4zLidyB4ReuIsvALH6w31YfAQDmXMqMx6FyfHqvBxtjC0eRumeSu4Bs3XtXwpyIywtSTrVT99BxY1f9w==} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-fifo@1.3.2: + resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} + + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-uri@3.1.2: + resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} + + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + + fb-watchman@2.0.2: + resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-uri-to-path@1.0.0: + resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + filter-obj@1.1.0: + resolution: {integrity: sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ==} + engines: {node: '>=0.10.0'} + + finalhandler@1.1.2: + resolution: {integrity: sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==} + engines: {node: '>= 0.8'} + + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flow-enums-runtime@0.0.6: + resolution: {integrity: sha512-3PYnM29RFXwvAN6Pc/scUfkI7RwhQ/xqyLUyPNlXUp9S40zI8nup9tUSrTLSVnWGBN38FNiGWbwZOB6uR4OGdw==} + + fontfaceobserver@2.3.0: + resolution: {integrity: sha512-6FPvD/IVyT4ZlNe7Wcn5Fb/4ChigpucKYSvD6a+0iMoLn2inpo711eyIcKjmDtE5XNcgAkSH9uN/nfAeZzHEfg==} + + for-each@0.3.5: + resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} + engines: {node: '>= 0.4'} + + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + + form-data-encoder@1.7.2: + resolution: {integrity: sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==} + + form-data@4.0.5: + resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + engines: {node: '>= 6'} + + formdata-node@4.4.1: + resolution: {integrity: sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==} + engines: {node: '>= 12.20'} + + freeport-async@2.0.0: + resolution: {integrity: sha512-K7od3Uw45AJg00XUmy15+Hae2hOcgKcmN3/EF6Y7i01O0gaqiRx8sUSpsb9+BRNL8RPBrhzPsVfy8q9ADlJuWQ==} + engines: {node: '>=8'} + + fresh@0.5.2: + resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} + engines: {node: '>= 0.6'} + + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + + fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + generate-function@2.3.1: + resolution: {integrity: sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==} + + generator-function@2.0.1: + resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} + engines: {node: '>= 0.4'} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-east-asian-width@1.5.0: + resolution: {integrity: sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==} + engines: {node: '>=18'} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-package-type@0.1.0: + resolution: {integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==} + engines: {node: '>=8.0.0'} + + get-port-please@3.2.0: + resolution: {integrity: sha512-I9QVvBw5U/hw3RmWpYKRumUeaDgxTPd401x364rLmWBJcOQ753eov1eTgzDqRG9bqFIfDc7gfzcQEWrUri3o1A==} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + getenv@2.0.0: + resolution: {integrity: sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ==} + engines: {node: '>=6'} + + giget@3.2.0: + resolution: {integrity: sha512-GvHTWcykIR/fP8cj8dMpuMMkvaeJfPvYnhq0oW+chSeIr+ldX21ifU2Ms6KBoyKZQZmVaUAAhQ2EZ68KJF8a7A==} + hasBin: true + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + glob@10.5.0: + resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + hasBin: true + + glob@13.0.6: + resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} + engines: {node: 18 || 20 || >=22} + + glob@7.2.3: + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + + globby@16.2.0: + resolution: {integrity: sha512-QrJia2qDf5BB/V6HYlDTs0I0lBahyjLzpGQg3KT7FnCdTonAyPy2RtY802m2k4ALx6Dp752f82WsOczEVr3l6Q==} + engines: {node: '>=20'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + grammex@3.1.12: + resolution: {integrity: sha512-6ufJOsSA7LcQehIJNCO7HIBykfM7DXQual0Ny780/DEcJIpBlHRvcqEBWGPYd7hrXL2GJ3oJI1MIhaXjWmLQOQ==} + + graphmatch@1.1.1: + resolution: {integrity: sha512-5ykVn/EXM1hF0XCaWh05VbYvEiOL2lY1kBxZtaYsyvjp7cmWOU1XsAdfQBwClraEofXDT197lFbXOEVMHpvQOg==} + + groq-sdk@0.7.0: + resolution: {integrity: sha512-OgPqrRtti5MjEVclR8sgBHrhSkTLdFCmi47yrEF29uJZaiCkX3s7bXpnMhq8Lwoe1f4AwgC0qGOeHXpeSgu5lg==} + + gzip-size@7.0.0: + resolution: {integrity: sha512-O1Ld7Dr+nqPnmGpdhzLmMTQ4vAsD+rHwMm1NLUmoUFFymBOMKxCCrtDxqdBRYXdeEPEi3SyoR4TizJLQrnKBNA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + h3@1.15.11: + resolution: {integrity: sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg==} + + has-flag@3.0.0: + resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} + engines: {node: '>=4'} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-property-descriptors@1.0.2: + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.3: + resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==} + engines: {node: '>= 0.4'} + + hermes-estree@0.25.1: + resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==} + + hermes-estree@0.29.1: + resolution: {integrity: sha512-jl+x31n4/w+wEqm0I2r4CMimukLbLQEYpisys5oCre611CI5fc9TxhqkBBCJ1edDG4Kza0f7CgNz8xVMLZQOmQ==} + + hermes-parser@0.25.1: + resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} + + hermes-parser@0.29.1: + resolution: {integrity: sha512-xBHWmUtRC5e/UL0tI7Ivt2riA/YBq9+SiYFU7C1oBa/j2jYGlIF9043oak1F47ihuDIxQ5nbsKueYJDRY02UgA==} + + hoist-non-react-statics@3.3.2: + resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==} + + hono@4.12.17: + resolution: {integrity: sha512-FbJJNb/XgX7YW0hX/V8w5oYLztKEsRLykCMZWt1WdLtsfjzMvmoqWBA4H4t5norinq8/rh20oiZYr+WSl4UzAQ==} + engines: {node: '>=16.9.0'} + + hookable@5.5.3: + resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} + + hosted-git-info@7.0.2: + resolution: {integrity: sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==} + engines: {node: ^16.14.0 || >=18.0.0} + + html-parse-stringify@3.0.1: + resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==} + + html-to-text@9.0.5: + resolution: {integrity: sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==} + engines: {node: '>=14'} + + htmlparser2@8.0.2: + resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + http-shutdown@1.2.2: + resolution: {integrity: sha512-S9wWkJ/VSY9/k4qcjG318bqJNruzE4HySUhFYknwmu6LBP97KLLfwNf+n4V1BHurvFNkSKLFnK/RsuUnRTf9Vw==} + engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} + + http-status-codes@2.3.0: + resolution: {integrity: sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA==} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + httpxy@0.5.1: + resolution: {integrity: sha512-JPhqYiixe1A1I+MXDewWDZqeudBGU8Q9jCHYN8ML+779RQzLjTi78HBvWz4jMxUD6h2/vUL12g4q/mFM0OUw1A==} + + humanize-ms@1.2.1: + resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==} + + i18next@23.16.8: + resolution: {integrity: sha512-06r/TitrM88Mg5FdUXAKL96dJMzgqLE5dv3ryBAra4KCwD9mJ4ndOTS95ZuymIGoE+2hzfdaMak2X11/es7ZWg==} + + iceberg-js@0.8.1: + resolution: {integrity: sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==} + engines: {node: '>=20.0.0'} + + iconv-lite@0.7.2: + resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} + engines: {node: '>=0.10.0'} + + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + + image-size@1.2.1: + resolution: {integrity: sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw==} + engines: {node: '>=16.x'} + hasBin: true + + imapflow@1.3.3: + resolution: {integrity: sha512-lx7nWcUDfNgITEKYYfunUDqJ3LT6ImuiA1ReqJepVEA2nqBQNUqa3ppF7Yz5CNjuDYG95pmzsCcNqRjMrwh/Vg==} + + import-fresh@2.0.0: + resolution: {integrity: sha512-eZ5H8rcgYazHbKC3PG4ClHNykCSxtAhxSSEM+2mb+7evD2CKF5V7c0dNum7AdpDh0ZdICwZY9sRSn8f+KH96sg==} + engines: {node: '>=4'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + inflight@1.0.6: + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + + invariant@2.2.4: + resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==} + + ioredis@5.10.1: + resolution: {integrity: sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==} + engines: {node: '>=12.22.0'} + + ip-address@10.2.0: + resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} + engines: {node: '>= 12'} + + iron-webcrypto@1.2.1: + resolution: {integrity: sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==} + + is-arguments@1.2.0: + resolution: {integrity: sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==} + engines: {node: '>= 0.4'} + + is-arrayish@0.2.1: + resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + + is-arrayish@0.3.4: + resolution: {integrity: sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==} + + is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + + is-callable@1.2.7: + resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} + engines: {node: '>= 0.4'} + + is-core-module@2.16.2: + resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==} + engines: {node: '>= 0.4'} + + is-directory@0.3.1: + resolution: {integrity: sha512-yVChGzahRFvbkscn2MlwGismPO12i9+znNruC5gVEntG3qu0xQMzsGg/JFbrsqDOHtHFPci+V5aP5T9I+yeKqw==} + engines: {node: '>=0.10.0'} + + is-docker@2.2.1: + resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==} + engines: {node: '>=8'} + hasBin: true + + is-docker@3.0.0: + resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + hasBin: true + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-generator-function@1.1.2: + resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==} + engines: {node: '>= 0.4'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-in-ssh@1.0.0: + resolution: {integrity: sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==} + engines: {node: '>=20'} + + is-inside-container@1.0.0: + resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} + engines: {node: '>=14.16'} + hasBin: true + + is-module@1.0.0: + resolution: {integrity: sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==} + + is-nan@1.3.2: + resolution: {integrity: sha512-E+zBKpQ2t6MEo1VsonYmluk9NxGrbzpeeLC2xIViuO2EjU2xsXsBPwTr3Ykv9l08UYEVEdWeRZNouaZqF6RN0w==} + engines: {node: '>= 0.4'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-path-inside@4.0.0: + resolution: {integrity: sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA==} + engines: {node: '>=12'} + + is-plain-obj@2.1.0: + resolution: {integrity: sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==} + engines: {node: '>=8'} + + is-property@1.0.2: + resolution: {integrity: sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==} + + is-reference@1.2.1: + resolution: {integrity: sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==} + + is-regex@1.2.1: + resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} + engines: {node: '>= 0.4'} + + is-stream@2.0.1: + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + engines: {node: '>=8'} + + is-typed-array@1.1.15: + resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} + engines: {node: '>= 0.4'} + + is-wsl@2.2.0: + resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==} + engines: {node: '>=8'} + + is-wsl@3.1.1: + resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} + engines: {node: '>=16'} + + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-instrument@5.2.1: + resolution: {integrity: sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==} + engines: {node: '>=8'} + + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + + jest-environment-node@29.7.0: + resolution: {integrity: sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-get-type@29.6.3: + resolution: {integrity: sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-haste-map@29.7.0: + resolution: {integrity: sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-message-util@29.7.0: + resolution: {integrity: sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-mock@29.7.0: + resolution: {integrity: sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-regex-util@29.6.3: + resolution: {integrity: sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-util@29.7.0: + resolution: {integrity: sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-validate@29.7.0: + resolution: {integrity: sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-worker@29.7.0: + resolution: {integrity: sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jimp-compact@0.16.1: + resolution: {integrity: sha512-dZ6Ra7u1G8c4Letq/B5EzAxj4tLFHL+cGtdpR+PVm4yzPDj+lCk+AbivWt1eOM+ikzkowtyV7qSqX6qr3t71Ww==} + + jiti@1.21.7: + resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} + hasBin: true + + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + + jose@6.2.3: + resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + + js-yaml@3.14.2: + resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==} + hasBin: true + + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + hasBin: true + + jsc-safe-url@0.2.4: + resolution: {integrity: sha512-0wM3YBWtYePOjfyXQH5MWQ8H7sdk5EXSwZvmSLKk2RboVQ2Bu239jycHDz5J/8Blf3K0Qnoy2b6xD+z10MFB+Q==} + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-parse-better-errors@1.0.2: + resolution: {integrity: sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + kleur@3.0.3: + resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} + engines: {node: '>=6'} + + kleur@4.1.5: + resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} + engines: {node: '>=6'} + + klona@2.0.6: + resolution: {integrity: sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA==} + engines: {node: '>= 8'} + + knitwork@1.3.0: + resolution: {integrity: sha512-4LqMNoONzR43B1W0ek0fhXMsDNW/zxa1NdFAVMY+k28pgZLovR4G3PB5MrpTxCy1QaZCqNoiaKPr5w5qZHfSNw==} + + lan-network@0.1.7: + resolution: {integrity: sha512-mnIlAEMu4OyEvUNdzco9xpuB9YVcPkQec+QsgycBCtPZvEqWPCDPfbAE4OJMdBBWpZWtpCn1xw9jJYlwjWI5zQ==} + hasBin: true + + lazystream@1.0.1: + resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==} + engines: {node: '>= 0.6.3'} + + leac@0.6.0: + resolution: {integrity: sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==} + + leven@3.1.0: + resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} + engines: {node: '>=6'} + + libbase64@1.3.0: + resolution: {integrity: sha512-GgOXd0Eo6phYgh0DJtjQ2tO8dc0IVINtZJeARPeiIJqge+HdsWSuaDTe8ztQ7j/cONByDZ3zeB325AHiv5O0dg==} + + libmime@5.3.8: + resolution: {integrity: sha512-ZrCY+Q66mPvasAfjsQ/IgahzoBvfE1VdtGRpo1hwRB1oK3wJKxhKA3GOcd2a6j7AH5eMFccxK9fBoCpRZTf8ng==} + + libqp@2.1.1: + resolution: {integrity: sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow==} + + lighthouse-logger@1.4.2: + resolution: {integrity: sha512-gPWxznF6TKmUHrOQjlVo2UbaL2EJ71mb2CCeRs/2qBpi4L/g4LUVc9+3lKQ6DTUZwJswfM7ainGrLO1+fOqa2g==} + + lightningcss-darwin-arm64@1.27.0: + resolution: {integrity: sha512-Gl/lqIXY+d+ySmMbgDf0pgaWSqrWYxVHoc88q+Vhf2YNzZ8DwoRzGt5NZDVqqIW5ScpSnmmjcgXP87Dn2ylSSQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.27.0: + resolution: {integrity: sha512-0+mZa54IlcNAoQS9E0+niovhyjjQWEMrwW0p2sSdLRhLDc8LMQ/b67z7+B5q4VmjYCMSfnFi3djAAQFIDuj/Tg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.27.0: + resolution: {integrity: sha512-n1sEf85fePoU2aDN2PzYjoI8gbBqnmLGEhKq7q0DKLj0UTVmOTwDC7PtLcy/zFxzASTSBlVQYJUhwIStQMIpRA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.27.0: + resolution: {integrity: sha512-MUMRmtdRkOkd5z3h986HOuNBD1c2lq2BSQA1Jg88d9I7bmPGx08bwGcnB75dvr17CwxjxD6XPi3Qh8ArmKFqCA==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.27.0: + resolution: {integrity: sha512-cPsxo1QEWq2sfKkSq2Bq5feQDHdUEwgtA9KaB27J5AX22+l4l0ptgjMZZtYtUnteBofjee+0oW1wQ1guv04a7A==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-arm64-musl@1.27.0: + resolution: {integrity: sha512-rCGBm2ax7kQ9pBSeITfCW9XSVF69VX+fm5DIpvDZQl4NnQoMQyRwhZQm9pd59m8leZ1IesRqWk2v/DntMo26lg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-x64-gnu@1.27.0: + resolution: {integrity: sha512-Dk/jovSI7qqhJDiUibvaikNKI2x6kWPN79AQiD/E/KeQWMjdGe9kw51RAgoWFDi0coP4jinaH14Nrt/J8z3U4A==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-linux-x64-musl@1.27.0: + resolution: {integrity: sha512-QKjTxXm8A9s6v9Tg3Fk0gscCQA1t/HMoF7Woy1u68wCk5kS4fR+q3vXa1p3++REW784cRAtkYKrPy6JKibrEZA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-win32-arm64-msvc@1.27.0: + resolution: {integrity: sha512-/wXegPS1hnhkeG4OXQKEMQeJd48RDC3qdh+OA8pCuOPCyvnm/yEayrJdJVqzBsqpy1aJklRCVxscpFur80o6iQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.27.0: + resolution: {integrity: sha512-/OJLj94Zm/waZShL8nB5jsNj3CfNATLCTyFxZyouilfTmSoLDX7VlVAmhPHoZWVFp4vdmoiEbPEYC8HID3m6yw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.27.0: + resolution: {integrity: sha512-8f7aNmS1+etYSLHht0fQApPc2kNO8qGRutifN5rVIc6Xo6ABsEbqOr758UwI7ALVbTt4x1fllKt0PYgzD9S3yQ==} + engines: {node: '>= 12.0.0'} + + lilconfig@3.1.3: + resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} + engines: {node: '>=14'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + listhen@1.10.0: + resolution: {integrity: sha512-kfz4C0OrC6IpaVMtYDJtf6PFjurxe9NBBoDAh/o2p587INryFOO4DQ9OetbCdDrWFt1m1CJKvYrzkGsuPHw8nQ==} + hasBin: true + + local-pkg@1.1.2: + resolution: {integrity: sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==} + engines: {node: '>=14'} + + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash.debounce@4.0.8: + resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} + + lodash.defaults@4.2.0: + resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} + + lodash.isarguments@3.1.0: + resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==} + + lodash.throttle@4.1.1: + resolution: {integrity: sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==} + + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + + log-symbols@2.2.0: + resolution: {integrity: sha512-VeIAFslyIerEJLXHziedo2basKbMKtTw3vfn5IzG0XTjhAVEJyNHnL2p7vc+wBDSdQuUpNw3M2u6xb9QsAY5Eg==} + engines: {node: '>=4'} + + long@5.3.2: + resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + + lottie-react-native@7.2.2: + resolution: {integrity: sha512-pp3dnFVFZlfZzIL5qKGXju2d6RfnYhPbb8xQL9dYqvPbPy2EbnK2aFlv6jAZLYh0QjUGPEmRAgAAnsOOtT+H9Q==} + peerDependencies: + '@lottiefiles/dotlottie-react': ^0.6.5 + react: '*' + react-native: '>=0.46' + react-native-windows: '>=0.63.x' + peerDependenciesMeta: + '@lottiefiles/dotlottie-react': + optional: true + react-native-windows: + optional: true + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + lru-cache@11.3.6: + resolution: {integrity: sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==} + engines: {node: 20 || >=22} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + lru.min@1.1.4: + resolution: {integrity: sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==} + engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + magicast@0.5.2: + resolution: {integrity: sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==} + + makeerror@1.0.12: + resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==} + + marky@1.3.0: + resolution: {integrity: sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + mdn-data@2.0.14: + resolution: {integrity: sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==} + + memoize-one@5.2.1: + resolution: {integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==} + + merge-options@3.0.4: + resolution: {integrity: sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ==} + engines: {node: '>=10'} + + merge-stream@2.0.0: + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + metro-babel-transformer@0.82.5: + resolution: {integrity: sha512-W/scFDnwJXSccJYnOFdGiYr9srhbHPdxX9TvvACOFsIXdLilh3XuxQl/wXW6jEJfgIb0jTvoTlwwrqvuwymr6Q==} + engines: {node: '>=18.18'} + + metro-cache-key@0.82.5: + resolution: {integrity: sha512-qpVmPbDJuRLrT4kcGlUouyqLGssJnbTllVtvIgXfR7ZuzMKf0mGS+8WzcqzNK8+kCyakombQWR0uDd8qhWGJcA==} + engines: {node: '>=18.18'} + + metro-cache@0.82.5: + resolution: {integrity: sha512-AwHV9607xZpedu1NQcjUkua8v7HfOTKfftl6Vc9OGr/jbpiJX6Gpy8E/V9jo/U9UuVYX2PqSUcVNZmu+LTm71Q==} + engines: {node: '>=18.18'} + + metro-config@0.82.5: + resolution: {integrity: sha512-/r83VqE55l0WsBf8IhNmc/3z71y2zIPe5kRSuqA5tY/SL/ULzlHUJEMd1szztd0G45JozLwjvrhAzhDPJ/Qo/g==} + engines: {node: '>=18.18'} + + metro-core@0.82.5: + resolution: {integrity: sha512-OJL18VbSw2RgtBm1f2P3J5kb892LCVJqMvslXxuxjAPex8OH7Eb8RBfgEo7VZSjgb/LOf4jhC4UFk5l5tAOHHA==} + engines: {node: '>=18.18'} + + metro-file-map@0.82.5: + resolution: {integrity: sha512-vpMDxkGIB+MTN8Af5hvSAanc6zXQipsAUO+XUx3PCQieKUfLwdoa8qaZ1WAQYRpaU+CJ8vhBcxtzzo3d9IsCIQ==} + engines: {node: '>=18.18'} + + metro-minify-terser@0.82.5: + resolution: {integrity: sha512-v6Nx7A4We6PqPu/ta1oGTqJ4Usz0P7c+3XNeBxW9kp8zayS3lHUKR0sY0wsCHInxZlNAEICx791x+uXytFUuwg==} + engines: {node: '>=18.18'} + + metro-resolver@0.82.5: + resolution: {integrity: sha512-kFowLnWACt3bEsuVsaRNgwplT8U7kETnaFHaZePlARz4Fg8tZtmRDUmjaD68CGAwc0rwdwNCkWizLYpnyVcs2g==} + engines: {node: '>=18.18'} + + metro-runtime@0.82.5: + resolution: {integrity: sha512-rQZDoCUf7k4Broyw3Ixxlq5ieIPiR1ULONdpcYpbJQ6yQ5GGEyYjtkztGD+OhHlw81LCR2SUAoPvtTus2WDK5g==} + engines: {node: '>=18.18'} + + metro-source-map@0.82.5: + resolution: {integrity: sha512-wH+awTOQJVkbhn2SKyaw+0cd+RVSCZ3sHVgyqJFQXIee/yLs3dZqKjjeKKhhVeudgjXo7aE/vSu/zVfcQEcUfw==} + engines: {node: '>=18.18'} + + metro-symbolicate@0.82.5: + resolution: {integrity: sha512-1u+07gzrvYDJ/oNXuOG1EXSvXZka/0JSW1q2EYBWerVKMOhvv9JzDGyzmuV7hHbF2Hg3T3S2uiM36sLz1qKsiw==} + engines: {node: '>=18.18'} + hasBin: true + + metro-transform-plugins@0.82.5: + resolution: {integrity: sha512-57Bqf3rgq9nPqLrT2d9kf/2WVieTFqsQ6qWHpEng5naIUtc/Iiw9+0bfLLWSAw0GH40iJ4yMjFcFJDtNSYynMA==} + engines: {node: '>=18.18'} + + metro-transform-worker@0.82.5: + resolution: {integrity: sha512-mx0grhAX7xe+XUQH6qoHHlWedI8fhSpDGsfga7CpkO9Lk9W+aPitNtJWNGrW8PfjKEWbT9Uz9O50dkI8bJqigw==} + engines: {node: '>=18.18'} + + metro@0.82.5: + resolution: {integrity: sha512-8oAXxL7do8QckID/WZEKaIFuQJFUTLzfVcC48ghkHhNK2RGuQq8Xvf4AVd+TUA0SZtX0q8TGNXZ/eba1ckeGCg==} + engines: {node: '>=18.18'} + hasBin: true + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + + mime@1.6.0: + resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} + engines: {node: '>=4'} + hasBin: true + + mime@4.1.0: + resolution: {integrity: sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw==} + engines: {node: '>=16'} + hasBin: true + + mimic-fn@1.2.0: + resolution: {integrity: sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ==} + engines: {node: '>=4'} + + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + + minimatch@5.1.9: + resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==} + engines: {node: '>=10'} + + minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} + engines: {node: '>=16 || 14 >=14.17'} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + + minizlib@3.1.0: + resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} + engines: {node: '>= 18'} + + mkdirp@1.0.4: + resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} + engines: {node: '>=10'} + hasBin: true + + mlly@1.8.2: + resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} + + ms@2.0.0: + resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + mysql2@3.15.3: + resolution: {integrity: sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==} + engines: {node: '>= 8.0'} + + mz@2.7.0: + resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + + named-placeholders@1.1.6: + resolution: {integrity: sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==} + engines: {node: '>=8.0.0'} + + nanoid@3.3.12: + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + nativewind@4.2.3: + resolution: {integrity: sha512-HglF1v6A8CqBFpXWs0d3yf4qQGurrreLuyE8FTRI/VDH8b0npZa2SDG5tviTkLiBg0s5j09mQALZOjxuocgMLA==} + engines: {node: '>=16'} + peerDependencies: + tailwindcss: '>3.3.0' + + negotiator@0.6.3: + resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} + engines: {node: '>= 0.6'} + + negotiator@0.6.4: + resolution: {integrity: sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==} + engines: {node: '>= 0.6'} + + nested-error-stacks@2.0.1: + resolution: {integrity: sha512-SrQrok4CATudVzBS7coSz26QRSmlK9TzzoFbeKfcPBUFPjcQM9Rqvr/DlJkOrwI/0KcgvMub1n1g5Jt9EgRn4A==} + + nitropack@2.13.4: + resolution: {integrity: sha512-tX7bT6zxNeMwkc6hxHiZeUoTOjVrcjoh1Z3cmxOlodIqjl4HISgqfGOmkWSayky3Nv9Z5+KQH52F8nmXJY5AAA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + xml2js: ^0.6.2 + peerDependenciesMeta: + xml2js: + optional: true + + node-addon-api@7.1.1: + resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} + + node-domexception@1.0.0: + resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} + engines: {node: '>=10.5.0'} + deprecated: Use your platform's native DOMException instead + + node-fetch-native@1.6.7: + resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} + + node-fetch@2.7.0: + resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} + engines: {node: 4.x || >=6.0.0} + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + + node-forge@1.4.0: + resolution: {integrity: sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==} + engines: {node: '>= 6.13.0'} + + node-gyp-build@4.8.4: + resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} + hasBin: true + + node-int64@0.4.0: + resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} + + node-mock-http@1.0.4: + resolution: {integrity: sha512-8DY+kFsDkNXy1sJglUfuODx1/opAGJGyrTuFqEoN90oRc2Vk0ZbD4K2qmKXBBEhZQzdKHIVfEJpDU8Ak2NJEvQ==} + + node-releases@2.0.38: + resolution: {integrity: sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==} + + nodemailer@8.0.7: + resolution: {integrity: sha512-pkjE4mkBzQjdJT4/UmlKl3pX0rC9fZmjh7c6C9o7lv66Ac6w9WCnzPzhbPNxwZAzlF4mdq4CSWB5+FbK6FWCow==} + engines: {node: '>=6.0.0'} + + nopt@8.1.0: + resolution: {integrity: sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A==} + engines: {node: ^18.17.0 || >=20.5.0} + hasBin: true + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + npm-package-arg@11.0.3: + resolution: {integrity: sha512-sHGJy8sOC1YraBywpzQlIKBE4pBbGbiF95U6Auspzyem956E0+FtDtsx1ZxlOJkQCZ1AFXAY/yuvtFYrOxF+Bw==} + engines: {node: ^16.14.0 || >=18.0.0} + + nth-check@2.1.1: + resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + + nullthrows@1.1.1: + resolution: {integrity: sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw==} + + ob1@0.82.5: + resolution: {integrity: sha512-QyQQ6e66f+Ut/qUVjEce0E/wux5nAGLXYZDn1jr15JWstHsCH3l6VVrg8NKDptW9NEiBXKOJeGF/ydxeSDF3IQ==} + engines: {node: '>=18.18'} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-hash@3.0.0: + resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} + engines: {node: '>= 6'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + object-is@1.1.6: + resolution: {integrity: sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==} + engines: {node: '>= 0.4'} + + object-keys@1.1.1: + resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} + engines: {node: '>= 0.4'} + + object.assign@4.1.7: + resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==} + engines: {node: '>= 0.4'} + + ofetch@1.5.1: + resolution: {integrity: sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==} + + ohash@2.0.11: + resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} + + on-exit-leak-free@2.1.2: + resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} + engines: {node: '>=14.0.0'} + + on-finished@2.3.0: + resolution: {integrity: sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==} + engines: {node: '>= 0.8'} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + on-headers@1.1.0: + resolution: {integrity: sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + onetime@2.0.1: + resolution: {integrity: sha512-oyyPpiMaKARvvcgip+JV+7zci5L8D1W9RZIz2l1o08AM3pfspitVWnPt3mzHcBPp12oYMTy0pqrFs/C+m3EwsQ==} + engines: {node: '>=4'} + + open@11.0.0: + resolution: {integrity: sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==} + engines: {node: '>=20'} + + open@7.4.2: + resolution: {integrity: sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==} + engines: {node: '>=8'} + + open@8.4.2: + resolution: {integrity: sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==} + engines: {node: '>=12'} + + openai@4.104.0: + resolution: {integrity: sha512-p99EFNsA/yX6UhVO93f5kJsDRLAg+CTA2RBqdHK4RtK8u5IJw32Hyb2dTGKbnnFmnuoBv5r7Z2CURI9sGZpSuA==} + hasBin: true + peerDependencies: + ws: ^8.18.0 + zod: ^3.23.8 + peerDependenciesMeta: + ws: + optional: true + zod: + optional: true + + ora@3.4.0: + resolution: {integrity: sha512-eNwHudNbO1folBP3JsZ19v9azXWtQZjICdr3Q0TDPIaeBQ3mXLrh54wM+er0+hSp+dWKf+Z8KM58CYzEyIYxYg==} + engines: {node: '>=6'} + + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + + parse-json@4.0.0: + resolution: {integrity: sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw==} + engines: {node: '>=4'} + + parse-png@2.1.0: + resolution: {integrity: sha512-Nt/a5SfCLiTnQAjx3fHlqp8hRgTL3z7kTQZzvIMS9uCAepnCyjpdEc6M/sz69WqMBdaDBw9sF1F1UaHROYzGkQ==} + engines: {node: '>=10'} + + parseley@0.12.1: + resolution: {integrity: sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw==} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + + path-scurry@2.0.2: + resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} + engines: {node: 18 || 20 || >=22} + + pathe@1.1.2: + resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + peberminta@0.9.0: + resolution: {integrity: sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==} + + perfect-debounce@2.1.0: + resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==} + + pg-cloudflare@1.3.0: + resolution: {integrity: sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==} + + pg-connection-string@2.12.0: + resolution: {integrity: sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==} + + pg-int8@1.0.1: + resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} + engines: {node: '>=4.0.0'} + + pg-pool@3.13.0: + resolution: {integrity: sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==} + peerDependencies: + pg: '>=8.0' + + pg-protocol@1.13.0: + resolution: {integrity: sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==} + + pg-types@2.2.0: + resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} + engines: {node: '>=4'} + + pg@8.20.0: + resolution: {integrity: sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==} + engines: {node: '>= 16.0.0'} + peerDependencies: + pg-native: '>=3.0.1' + peerDependenciesMeta: + pg-native: + optional: true + + pgpass@1.0.5: + resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + + picomatch@3.0.2: + resolution: {integrity: sha512-cfDHL6LStTEKlNilboNtobT/kEa30PtAf2Q1OgszfrG/rpVl1xaFWT9ktfkS306GmHgmnad1Sw4wabhlvFtsTw==} + engines: {node: '>=10'} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + pify@2.3.0: + resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} + engines: {node: '>=0.10.0'} + + pino-abstract-transport@3.0.0: + resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==} + + pino-std-serializers@7.1.0: + resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==} + + pino@10.3.1: + resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==} + hasBin: true + + pirates@4.0.7: + resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} + engines: {node: '>= 6'} + + pkg-types@1.3.1: + resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + + pkg-types@2.3.1: + resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==} + + plist@3.1.1: + resolution: {integrity: sha512-ZIfcLJC+7E7FBFnDxm9MPmt7D+DidyQ26lewieO75AdhA2ayMtsJSES0iWzqJQbcVRSrTufQoy0DR94xHue0oA==} + engines: {node: '>=10.4.0'} + + pngjs@3.4.0: + resolution: {integrity: sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w==} + engines: {node: '>=4.0.0'} + + possible-typed-array-names@1.1.0: + resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} + engines: {node: '>= 0.4'} + + postcss-import@15.1.0: + resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==} + engines: {node: '>=14.0.0'} + peerDependencies: + postcss: ^8.0.0 + + postcss-js@4.1.0: + resolution: {integrity: sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==} + engines: {node: ^12 || ^14 || >= 16} + peerDependencies: + postcss: ^8.4.21 + + postcss-load-config@6.0.1: + resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} + engines: {node: '>= 18'} + peerDependencies: + jiti: '>=1.21.0' + postcss: '>=8.0.9' + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + jiti: + optional: true + postcss: + optional: true + tsx: + optional: true + yaml: + optional: true + + postcss-nested@6.2.0: + resolution: {integrity: sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==} + engines: {node: '>=12.0'} + peerDependencies: + postcss: ^8.2.14 + + postcss-selector-parser@6.1.2: + resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==} + engines: {node: '>=4'} + + postcss-value-parser@4.2.0: + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + + postcss@8.4.49: + resolution: {integrity: sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==} + engines: {node: ^10 || ^12 || >=14} + + postcss@8.5.14: + resolution: {integrity: sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==} + engines: {node: ^10 || ^12 || >=14} + + postgres-array@2.0.0: + resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} + engines: {node: '>=4'} + + postgres-array@3.0.4: + resolution: {integrity: sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ==} + engines: {node: '>=12'} + + postgres-bytea@1.0.1: + resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==} + engines: {node: '>=0.10.0'} + + postgres-date@1.0.7: + resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} + engines: {node: '>=0.10.0'} + + postgres-interval@1.2.0: + resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} + engines: {node: '>=0.10.0'} + + postgres@3.4.7: + resolution: {integrity: sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==} + engines: {node: '>=12'} + + powershell-utils@0.1.0: + resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==} + engines: {node: '>=20'} + + prettier@3.8.3: + resolution: {integrity: sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==} + engines: {node: '>=14'} + hasBin: true + + pretty-bytes@5.6.0: + resolution: {integrity: sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==} + engines: {node: '>=6'} + + pretty-bytes@7.1.0: + resolution: {integrity: sha512-nODzvTiYVRGRqAOvE84Vk5JDPyyxsVk0/fbA/bq7RqlnhksGpset09XTxbpvLTIjoaF7K8Z8DG8yHtKGTPSYRw==} + engines: {node: '>=20'} + + pretty-format@29.7.0: + resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + prisma@7.8.0: + resolution: {integrity: sha512-yfN4yrw7HV9kEJhoy1+jgah0jafEIQsf7uWouSsM8MvJtlubsk+kM7AIBWZ8+GJl74Yj3c+nbYqBkMOxtsZ3Lw==} + engines: {node: ^20.19 || ^22.12 || >=24.0} + hasBin: true + peerDependencies: + better-sqlite3: '>=9.0.0' + typescript: '>=5.4.0' + peerDependenciesMeta: + better-sqlite3: + optional: true + typescript: + optional: true + + proc-log@4.2.0: + resolution: {integrity: sha512-g8+OnU/L2v+wyiVK+D5fA34J7EH8jZ8DDlvwhRCMxmMj7UCBvxiO1mGeN+36JXIKF4zevU4kRBd8lVgG9vLelA==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + + process-warning@5.0.0: + resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} + + process@0.11.10: + resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} + engines: {node: '>= 0.6.0'} + + progress@2.0.3: + resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} + engines: {node: '>=0.4.0'} + + promise@8.3.0: + resolution: {integrity: sha512-rZPNPKTOYVNEEKFaq1HqTgOwZD+4/YHS5ukLzQCypkj+OkYx7iv0mA91lJlpPPZ8vMau3IIGj5Qlwrx+8iiSmg==} + + prompts@2.4.2: + resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} + engines: {node: '>= 6'} + + proper-lockfile@4.1.2: + resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + pure-rand@6.1.0: + resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} + + qrcode-terminal@0.11.0: + resolution: {integrity: sha512-Uu7ii+FQy4Qf82G4xu7ShHhjhGahEpCWc3x8UavY3CTcWV+ufmmCtwkr7ZKsX42jdL0kr1B5FKUeqJvAn51jzQ==} + hasBin: true + + qs@6.15.1: + resolution: {integrity: sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==} + engines: {node: '>=0.6'} + + quansync@0.2.11: + resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} + + query-string@7.1.3: + resolution: {integrity: sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg==} + engines: {node: '>=6'} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + queue@6.0.2: + resolution: {integrity: sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==} + + quick-format-unescaped@4.0.4: + resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + + radix3@1.1.2: + resolution: {integrity: sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==} + + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + rc9@3.0.1: + resolution: {integrity: sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ==} + + rc@1.2.8: + resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} + hasBin: true + + react-devtools-core@6.1.5: + resolution: {integrity: sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==} + + react-dom@19.0.0: + resolution: {integrity: sha512-4GV5sHFG0e/0AD4X+ySy6UJd3jVl1iNsNHdpad0qhABJ11twS3TTBnseqsKurKcsNqCEFeGL3uLpVChpIO3QfQ==} + peerDependencies: + react: ^19.0.0 + + react-fast-compare@3.2.2: + resolution: {integrity: sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==} + + react-freeze@1.0.4: + resolution: {integrity: sha512-r4F0Sec0BLxWicc7HEyo2x3/2icUTrRmDjaaRyzzn+7aDyFZliszMDOgLVwSnQnYENOlL1o569Ze2HZefk8clA==} + engines: {node: '>=10'} + peerDependencies: + react: '>=17.0.0' + + react-hook-form@7.75.0: + resolution: {integrity: sha512-Ovv94H+0p3sJ7B9B5QxPuCP1u8V/cHuVGyH55cSwodYDtoJwK+fqk3vjfIgSX59I2U/bU4z0nRJ9HMLpNiWEmw==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: ^16.8.0 || ^17 || ^18 || ^19 + + react-i18next@15.7.4: + resolution: {integrity: sha512-nyU8iKNrI5uDJch0z9+Y5XEr34b0wkyYj3Rp+tfbahxtlswxSCjcUL9H0nqXo9IR3/t5Y5PKIA3fx3MfUyR9Xw==} + peerDependencies: + i18next: '>= 23.4.0' + react: '>= 16.8.0' + react-dom: '*' + react-native: '*' + typescript: ^5 + peerDependenciesMeta: + react-dom: + optional: true + react-native: + optional: true + typescript: + optional: true + + react-is@16.13.1: + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + + react-is@18.3.1: + resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + + react-is@19.2.5: + resolution: {integrity: sha512-Dn0t8IQhCmeIT3wu+Apm1/YVsJXsGWi6k4sPdnBIdqMVtHtv0IGi6dcpNpNkNac0zB2uUAqNX3MHzN8c+z2rwQ==} + + react-native-bottom-tabs@1.2.0: + resolution: {integrity: sha512-ScVPko86ts+m6JMNtI24MCSYJCOZc1aZkn9qwS9ly3o0ubajRWDpCzgRJfRFysi08bKrcqAXKVCHZNHvNb2PTA==} + peerDependencies: + react: '*' + react-native: '*' + + react-native-css-interop@0.2.3: + resolution: {integrity: sha512-wc+JI7iUfdFBqnE18HhMTtD0q9vkhuMczToA87UdHGWwMyxdT5sCcNy+i4KInPCE855IY0Ic8kLQqecAIBWz7w==} + engines: {node: '>=18'} + peerDependencies: + react: '>=18' + react-native: '*' + react-native-reanimated: '>=3.6.2' + react-native-safe-area-context: '*' + react-native-svg: '*' + tailwindcss: ~3 + peerDependenciesMeta: + react-native-safe-area-context: + optional: true + react-native-svg: + optional: true + + react-native-edge-to-edge@1.6.0: + resolution: {integrity: sha512-2WCNdE3Qd6Fwg9+4BpbATUxCLcouF6YRY7K+J36KJ4l3y+tWN6XCqAC4DuoGblAAbb2sLkhEDp4FOlbOIot2Og==} + peerDependencies: + react: '*' + react-native: '*' + + react-native-gesture-handler@2.24.0: + resolution: {integrity: sha512-ZdWyOd1C8axKJHIfYxjJKCcxjWEpUtUWgTOVY2wynbiveSQDm8X/PDyAKXSer/GOtIpjudUbACOndZXCN3vHsw==} + peerDependencies: + react: '*' + react-native: '*' + + react-native-is-edge-to-edge@1.3.1: + resolution: {integrity: sha512-NIXU/iT5+ORyCc7p0z2nnlkouYKX425vuU1OEm6bMMtWWR9yvb+Xg5AZmImTKoF9abxCPqrKC3rOZsKzUYgYZA==} + peerDependencies: + react: '*' + react-native: '*' + + react-native-mmkv@3.3.3: + resolution: {integrity: sha512-GMsfOmNzx0p5+CtrCFRVtpOOMYNJXuksBVARSQrCFaZwjUyHJdQzcN900GGaFFNTxw2fs8s5Xje//RDKj9+PZA==} + peerDependencies: + react: '*' + react-native: '*' + + react-native-reanimated@4.0.3: + resolution: {integrity: sha512-apXILxR2gRi3n0Xi0UILr+72vXj1etooOId/4nCgzKfNnvcp+dRzt7UQdFU0/nc+4bPWlSsiIskDxdYXr2KNmw==} + peerDependencies: + '@babel/core': ^7.0.0-0 + react: '*' + react-native: '*' + react-native-worklets: '>=0.4.0' + + react-native-safe-area-context@5.4.0: + resolution: {integrity: sha512-JaEThVyJcLhA+vU0NU8bZ0a1ih6GiF4faZ+ArZLqpYbL6j7R3caRqj+mE3lEtKCuHgwjLg3bCxLL1GPUJZVqUA==} + peerDependencies: + react: '*' + react-native: '*' + + react-native-screens@4.11.1: + resolution: {integrity: sha512-F0zOzRVa3ptZfLpD0J8ROdo+y1fEPw+VBFq1MTY/iyDu08al7qFUO5hLMd+EYMda5VXGaTFCa8q7bOppUszhJw==} + peerDependencies: + react: '*' + react-native: '*' + + react-native-sse@1.2.1: + resolution: {integrity: sha512-zejanlScF+IB9tYnbdry0MT34qjBXbiV/E72qGz33W/tX1bx8MXsbB4lxiuPETc9v/008vYZ60yjIstW22VlVg==} + + react-native-svg@15.11.2: + resolution: {integrity: sha512-+YfF72IbWQUKzCIydlijV1fLuBsQNGMT6Da2kFlo1sh+LE3BIm/2Q7AR1zAAR6L0BFLi1WaQPLfFUC9bNZpOmw==} + peerDependencies: + react: '*' + react-native: '*' + + react-native-url-polyfill@2.0.0: + resolution: {integrity: sha512-My330Do7/DvKnEvwQc0WdcBnFPploYKp9CYlefDXzIdEaA+PAhDYllkvGeEroEzvc4Kzzj2O4yVdz8v6fjRvhA==} + peerDependencies: + react-native: '*' + + react-native-worklets@0.4.2: + resolution: {integrity: sha512-02IMmU2rOL6vrF7uA6cLAeN4haXOMTBh7opmVYQbjYG8mNAb0cnhmkvkdQupmpFjBpWZRJnBGYJJa471a/9IPg==} + peerDependencies: + '@babel/core': ^7.0.0-0 + react: '*' + react-native: '*' + + react-native@0.79.6: + resolution: {integrity: sha512-kvIWSmf4QPfY41HC25TR285N7Fv0Pyn3DAEK8qRL9dA35usSaxsJkHfw+VqnonqJjXOaoKCEanwudRAJ60TBGA==} + engines: {node: '>=18'} + hasBin: true + peerDependencies: + '@types/react': ^19.0.0 + react: ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + react-promise-suspense@0.3.4: + resolution: {integrity: sha512-I42jl7L3Ze6kZaq+7zXWSunBa3b1on5yfvUW6Eo/3fFOj6dZ5Bqmcd264nJbTK/gn1HjjILAjSwnZbV4RpSaNQ==} + + react-refresh@0.14.2: + resolution: {integrity: sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==} + engines: {node: '>=0.10.0'} + + react@19.0.0: + resolution: {integrity: sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==} + engines: {node: '>=0.10.0'} + + read-cache@1.0.0: + resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==} + + readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + + readable-stream@4.7.0: + resolution: {integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + readdir-glob@1.1.3: + resolution: {integrity: sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==} + + readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + + readdirp@5.0.0: + resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} + engines: {node: '>= 20.19.0'} + + real-require@0.2.0: + resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} + engines: {node: '>= 12.13.0'} + + redis-errors@1.2.0: + resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} + engines: {node: '>=4'} + + redis-parser@3.0.0: + resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} + engines: {node: '>=4'} + + regenerate-unicode-properties@10.2.2: + resolution: {integrity: sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g==} + engines: {node: '>=4'} + + regenerate@1.4.2: + resolution: {integrity: sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==} + + regenerator-runtime@0.13.11: + resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==} + + regexpu-core@6.4.0: + resolution: {integrity: sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA==} + engines: {node: '>=4'} + + regjsgen@0.8.0: + resolution: {integrity: sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==} + + regjsparser@0.13.1: + resolution: {integrity: sha512-dLsljMd9sqwRkby8zhO1gSg3PnJIBFid8f4CQj/sXx+7cKx+E7u0PKhZ+U4wmhx7EfmtvnA318oVaIkAB1lRJw==} + hasBin: true + + remeda@2.33.4: + resolution: {integrity: sha512-ygHswjlc/opg2VrtiYvUOPLjxjtdKvjGz1/plDhkG66hjNjFr1xmfrs2ClNFo/E6TyUFiwYNh53bKV26oBoMGQ==} + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + requireg@0.2.2: + resolution: {integrity: sha512-nYzyjnFcPNGR3lx9lwPPPnuQxv6JWEZd2Ci0u9opN7N5zUEPIhY/GbL3vMGOr2UXwEg9WwSyV9X9Y/kLFgPsOg==} + engines: {node: '>= 4.0.0'} + + resend@4.8.0: + resolution: {integrity: sha512-R8eBOFQDO6dzRTDmaMEdpqrkmgSjPpVXt4nGfWsZdYOet0kqra0xgbvTES6HmCriZEXbmGk3e0DiGIaLFTFSHA==} + engines: {node: '>=18'} + + resolve-from@3.0.0: + resolution: {integrity: sha512-GnlH6vxLymXJNMBo7XP1fJIzBFbdYt49CuTwmB/6N53t+kMPRMFKz783LlQ4tv28XoQfMWinAJX6WCGf2IlaIw==} + engines: {node: '>=4'} + + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + + resolve-workspace-root@2.0.1: + resolution: {integrity: sha512-nR23LHAvaI6aHtMg6RWoaHpdR4D881Nydkzi2CixINyg9T00KgaJdJI6Vwty+Ps8WLxZHuxsS0BseWjxSA4C+w==} + + resolve.exports@2.0.3: + resolution: {integrity: sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==} + engines: {node: '>=10'} + + resolve@1.22.12: + resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} + engines: {node: '>= 0.4'} + hasBin: true + + resolve@1.7.1: + resolution: {integrity: sha512-c7rwLofp8g1U+h1KNyHL/jicrKg1Ek4q+Lr33AL65uZTinUZHe30D5HlyN5V9NW0JX1D5dXQ4jqW5l7Sy/kGfw==} + + restore-cursor@2.0.0: + resolution: {integrity: sha512-6IzJLuGi4+R14vwagDHX+JrXmPVtPpn4mffDJ1UdR7/Edm87fl6yi8mMBIVvFtJaNTUvjughmW4hwLhRG7gC1Q==} + engines: {node: '>=4'} + + retry@0.12.0: + resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} + engines: {node: '>= 4'} + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rimraf@3.0.2: + resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} + deprecated: Rimraf versions prior to v4 are no longer supported + hasBin: true + + rive-react-native@9.8.3: + resolution: {integrity: sha512-QhLZWNzWUzPiRAPo6wWvtdYxPROAvmUprTsbN7UINjS6LTvgNHsuEYSwRf2hcA+44xVQD2Jv7dSMjqvzc/xqcQ==} + engines: {node: '>=16'} + peerDependencies: + react: '*' + react-native: '*' + + rollup-plugin-visualizer@7.0.1: + resolution: {integrity: sha512-UJUT4+1Ho4OcWmPYU3sYXgUqI8B8Ayfe06MX7y0qCJ1K8aGoKtR/NDd/2nZqM7ADkrzny+I99Ul7GgyoiVNAgg==} + engines: {node: '>=22'} + hasBin: true + peerDependencies: + rolldown: 1.x || ^1.0.0-beta || ^1.0.0-rc + rollup: 2.x || 3.x || 4.x + peerDependenciesMeta: + rolldown: + optional: true + rollup: + optional: true + + rollup@4.60.3: + resolution: {integrity: sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + rtl-detect@1.1.2: + resolution: {integrity: sha512-PGMBq03+TTG/p/cRB7HCLKJ1MgDIi07+QU1faSjiYRfmY5UsAttV9Hs08jDAHVwcOwmVLcSJkpwyfXszVjWfIQ==} + + run-applescript@7.1.0: + resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} + engines: {node: '>=18'} + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safe-regex-test@1.1.0: + resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} + engines: {node: '>= 0.4'} + + safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + sax@1.6.0: + resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==} + engines: {node: '>=11.0.0'} + + scheduler@0.25.0: + resolution: {integrity: sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==} + + scule@1.3.0: + resolution: {integrity: sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==} + + selderee@0.11.0: + resolution: {integrity: sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.6.3: + resolution: {integrity: sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==} + engines: {node: '>=10'} + hasBin: true + + semver@7.7.2: + resolution: {integrity: sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==} + engines: {node: '>=10'} + hasBin: true + + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + + send@0.19.2: + resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==} + engines: {node: '>= 0.8.0'} + + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + + seq-queue@0.0.5: + resolution: {integrity: sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==} + + serialize-error@2.1.0: + resolution: {integrity: sha512-ghgmKt5o4Tly5yEG/UJp8qTd0AN7Xalw4XBtDEKP655B699qMEtra1WlXeE6WIvdEG481JvRxULKsInq/iNysw==} + engines: {node: '>=0.10.0'} + + serialize-javascript@7.0.5: + resolution: {integrity: sha512-F4LcB0UqUl1zErq+1nYEEzSHJnIwb3AF2XWB94b+afhrekOUijwooAYqFyRbjYkm2PAKBabx6oYv/xDxNi8IBw==} + engines: {node: '>=20.0.0'} + + serve-placeholder@2.0.2: + resolution: {integrity: sha512-/TMG8SboeiQbZJWRlfTCqMs2DD3SZgWp0kDQePz9yUuCnDfDh/92gf7/PxGhzXTKBIPASIHxFcZndoNbp6QOLQ==} + + serve-static@1.16.3: + resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==} + engines: {node: '>= 0.8.0'} + + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + + server-only@0.0.1: + resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==} + + set-function-length@1.2.2: + resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} + engines: {node: '>= 0.4'} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + sf-symbols-typescript@2.2.0: + resolution: {integrity: sha512-TPbeg0b7ylrswdGCji8FRGFAKuqbpQlLbL8SOle3j1iHSs5Ob5mhvMAxWN2UItOjgALAB5Zp3fmMfj8mbWvXKw==} + engines: {node: '>=10'} + + shallowequal@1.1.0: + resolution: {integrity: sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + shell-quote@1.8.3: + resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==} + engines: {node: '>= 0.4'} + + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + simple-plist@1.3.1: + resolution: {integrity: sha512-iMSw5i0XseMnrhtIzRb7XpQEXepa9xhWxGUojHBL43SIpQuDQkh3Wpy67ZbDzZVr6EKxvwVChnVpdl8hEVLDiw==} + + simple-swizzle@0.2.4: + resolution: {integrity: sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==} + + sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + + slash@3.0.0: + resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} + engines: {node: '>=8'} + + slash@5.1.0: + resolution: {integrity: sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==} + engines: {node: '>=14.16'} + + slugify@1.6.9: + resolution: {integrity: sha512-vZ7rfeehZui7wQs438JXBckYLkIIdfHOXsaVEUMyS5fHo1483l1bMdo0EDSWYclY0yZKFOipDy4KHuKs6ssvdg==} + engines: {node: '>=8.0.0'} + + smart-buffer@4.2.0: + resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} + engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} + + smob@1.6.1: + resolution: {integrity: sha512-KAkBqZl3c2GvNgNhcoyJae1aKldDW0LO279wF9bk1PnluRTETKBq0WyzRXxEhoQLk56yHaOY4JCBEKDuJIET5g==} + engines: {node: '>=20.0.0'} + + socks@2.8.8: + resolution: {integrity: sha512-NlGELfPrgX2f1TAAcz0WawlLn+0r3FyhhCRpFFK2CemXenPYvzMWWZINv3eDNo9ucdwme7oCHRY0Jnbs4aIkog==} + engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} + + sonic-boom@4.2.1: + resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + source-map-support@0.5.21: + resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + + source-map@0.5.7: + resolution: {integrity: sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==} + engines: {node: '>=0.10.0'} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + source-map@0.7.6: + resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} + engines: {node: '>= 12'} + + split-on-first@1.1.0: + resolution: {integrity: sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==} + engines: {node: '>=6'} + + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + + sprintf-js@1.0.3: + resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + + sqlstring@2.3.3: + resolution: {integrity: sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==} + engines: {node: '>= 0.6'} + + stack-utils@2.0.6: + resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} + engines: {node: '>=10'} + + stackframe@1.3.4: + resolution: {integrity: sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==} + + stacktrace-parser@0.1.11: + resolution: {integrity: sha512-WjlahMgHmCJpqzU8bIBy4qtsZdU9lRlcZE3Lvyej6t4tuOuv1vk57OW3MBrj6hXBFx/nNoC9MPMTcr5YA7NQbg==} + engines: {node: '>=6'} + + standard-as-callback@2.1.0: + resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} + + statuses@1.5.0: + resolution: {integrity: sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==} + engines: {node: '>= 0.6'} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + std-env@4.1.0: + resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + + stream-buffers@2.2.0: + resolution: {integrity: sha512-uyQK/mx5QjHun80FLJTfaWE7JtwfRMKBLkMne6udYOmvH0CawotVa7TfgYHzAnpphn4+TweIx1QKMnRIbipmUg==} + engines: {node: '>= 0.10.0'} + + streamx@2.25.0: + resolution: {integrity: sha512-0nQuG6jf1w+wddNEEXCF4nTg3LtufWINB5eFEN+5TNZW7KWJp6x87+JFL43vaAUPyCfH1wID+mNVyW6OHtFamg==} + + strict-uri-encode@2.0.0: + resolution: {integrity: sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ==} + engines: {node: '>=4'} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + + string-width@7.2.0: + resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} + engines: {node: '>=18'} + + string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + + strip-ansi@5.2.0: + resolution: {integrity: sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==} + engines: {node: '>=6'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + + strip-json-comments@2.0.1: + resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} + engines: {node: '>=0.10.0'} + + strip-literal@3.1.0: + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + + stripe@17.7.0: + resolution: {integrity: sha512-aT2BU9KkizY9SATf14WhhYVv2uOapBWX0OFWF4xvcj1mPaNotlSc2CsxpS4DS46ZueSppmCF5BX1sNYBtwBvfw==} + engines: {node: '>=12.*'} + + structured-headers@0.4.1: + resolution: {integrity: sha512-0MP/Cxx5SzeeZ10p/bZI0S6MpgD+yxAhi1BOQ34jgnMXsCq3j1t6tQnZu+KdlL7dvJTLT3g9xN8tl10TqgFMcg==} + + sucrase@3.35.0: + resolution: {integrity: sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==} + engines: {node: '>=16 || 14 >=14.17'} + hasBin: true + + sucrase@3.35.1: + resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} + engines: {node: '>=16 || 14 >=14.17'} + hasBin: true + + supports-color@10.2.2: + resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} + engines: {node: '>=18'} + + supports-color@5.5.0: + resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} + engines: {node: '>=4'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-color@8.1.1: + resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} + engines: {node: '>=10'} + + supports-hyperlinks@2.3.0: + resolution: {integrity: sha512-RpsAZlpWcDwOPQA22aCH4J0t7L8JmAvsCxfOSEwm7cQs3LshN36QaTkwd70DnBOXDWGssw2eUoc8CaRWT0XunA==} + engines: {node: '>=8'} + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + tagged-tag@1.0.0: + resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} + engines: {node: '>=20'} + + tailwindcss@3.4.19: + resolution: {integrity: sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==} + engines: {node: '>=14.0.0'} + hasBin: true + + tar-stream@3.2.0: + resolution: {integrity: sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==} + + tar@7.5.14: + resolution: {integrity: sha512-/7sHKgQO3JLP9ESlwTYUUftHUadOURUqq23xs1vjcnp8Vss6k0wCfzulyEtk5g91pjvnuriimGlyG7k6msrzRw==} + engines: {node: '>=18'} + + teex@1.0.1: + resolution: {integrity: sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==} + + temp-dir@2.0.0: + resolution: {integrity: sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg==} + engines: {node: '>=8'} + + terminal-link@2.1.1: + resolution: {integrity: sha512-un0FmiRUQNr5PJqy9kP7c40F5BOfpGlYTrxonDChEZB7pzZxRNp/bt+ymiy9/npwXya9KH99nJ/GXFIiUkYGFQ==} + engines: {node: '>=8'} + + terser@5.46.2: + resolution: {integrity: sha512-uxfo9fPcSgLDYob/w1FuL0c99MWiJDnv+5qXSQc5+Ki5NjVNsYi66INnMFBjf6uFz6OnX12piJQPF4IpjJTNTw==} + engines: {node: '>=10'} + hasBin: true + + test-exclude@6.0.0: + resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} + engines: {node: '>=8'} + + text-decoder@1.2.7: + resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==} + + thenify-all@1.6.0: + resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} + engines: {node: '>=0.8'} + + thenify@3.3.1: + resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + + thread-stream@4.0.0: + resolution: {integrity: sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==} + engines: {node: '>=20'} + + throat@5.0.0: + resolution: {integrity: sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA==} + + tinyclip@0.1.12: + resolution: {integrity: sha512-Ae3OVUqifDw0wBriIBS7yVaW44Dp6eSHQcyq4Igc7eN2TJH/2YsicswaW+J/OuMvhpDPOKEgpAZCjkb4hpoyeA==} + engines: {node: ^16.14.0 || >= 17.3.0} + + tinyglobby@0.2.16: + resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} + engines: {node: '>=12.0.0'} + + tmpl@1.0.5: + resolution: {integrity: sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + tr46@0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + + ts-interface-checker@0.1.13: + resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + type-detect@4.0.8: + resolution: {integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==} + engines: {node: '>=4'} + + type-fest@0.21.3: + resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} + engines: {node: '>=10'} + + type-fest@0.7.1: + resolution: {integrity: sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg==} + engines: {node: '>=8'} + + type-fest@5.6.0: + resolution: {integrity: sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA==} + engines: {node: '>=20'} + + typescript@5.8.3: + resolution: {integrity: sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==} + engines: {node: '>=14.17'} + hasBin: true + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + ufo@1.6.4: + resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} + + ultrahtml@1.6.0: + resolution: {integrity: sha512-R9fBn90VTJrqqLDwyMph+HGne8eqY1iPfYhPzZrvKpIfwkWZbcYlfpsb8B9dTvBfpy1/hqAD7Wi8EKfP9e8zdw==} + + uncrypto@0.1.3: + resolution: {integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==} + + unctx@2.5.0: + resolution: {integrity: sha512-p+Rz9x0R7X+CYDkT+Xg8/GhpcShTlU8n+cf9OtOEf7zEQsNcCZO1dPKNRDqvUTaq+P32PMMkxWHwfrxkqfqAYg==} + + undici-types@5.26.5: + resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + undici@6.25.0: + resolution: {integrity: sha512-ZgpWDC5gmNiuY9CnLVXEH8rl50xhRCuLNA97fAUnKi8RRuV4E6KG31pDTsLVUKnohJE0I3XDrTeEydAXRw47xg==} + engines: {node: '>=18.17'} + + undici@7.25.0: + resolution: {integrity: sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==} + engines: {node: '>=20.18.1'} + + unenv@2.0.0-rc.24: + resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==} + + unicode-canonical-property-names-ecmascript@2.0.1: + resolution: {integrity: sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==} + engines: {node: '>=4'} + + unicode-match-property-ecmascript@2.0.0: + resolution: {integrity: sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==} + engines: {node: '>=4'} + + unicode-match-property-value-ecmascript@2.2.1: + resolution: {integrity: sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg==} + engines: {node: '>=4'} + + unicode-property-aliases-ecmascript@2.2.0: + resolution: {integrity: sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==} + engines: {node: '>=4'} + + unicorn-magic@0.4.0: + resolution: {integrity: sha512-wH590V9VNgYH9g3lH9wWjTrUoKsjLF6sGLjhR4sH1LWpLmCOH0Zf7PukhDA8BiS7KHe4oPNkcTHqYkj7SOGUOw==} + engines: {node: '>=20'} + + unimport@6.2.0: + resolution: {integrity: sha512-4NcqaphAHQff4eBWQ3pjVOCYNLlmVGGMoLDmboobh8+OQe9yP7UyeoMP043M1bG0YNc3CqtukD2VuINxOqm4rQ==} + engines: {node: '>=18.12.0'} + peerDependencies: + oxc-parser: '*' + peerDependenciesMeta: + oxc-parser: + optional: true + + unique-string@2.0.0: + resolution: {integrity: sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg==} + engines: {node: '>=8'} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + unplugin-utils@0.3.1: + resolution: {integrity: sha512-5lWVjgi6vuHhJ526bI4nlCOmkCIF3nnfXkCMDeMJrtdvxTs6ZFCM8oNufGTsDbKv/tJ/xj8RpvXjRuPBZJuJog==} + engines: {node: '>=20.19.0'} + + unplugin@2.3.11: + resolution: {integrity: sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==} + engines: {node: '>=18.12.0'} + + unplugin@3.0.0: + resolution: {integrity: sha512-0Mqk3AT2TZCXWKdcoaufeXNukv2mTrEZExeXlHIOZXdqYoHHr4n51pymnwV8x2BOVxwXbK2HLlI7usrqMpycdg==} + engines: {node: ^20.19.0 || >=22.12.0} + + unstorage@1.17.5: + resolution: {integrity: sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg==} + peerDependencies: + '@azure/app-configuration': ^1.8.0 + '@azure/cosmos': ^4.2.0 + '@azure/data-tables': ^13.3.0 + '@azure/identity': ^4.6.0 + '@azure/keyvault-secrets': ^4.9.0 + '@azure/storage-blob': ^12.26.0 + '@capacitor/preferences': ^6 || ^7 || ^8 + '@deno/kv': '>=0.9.0' + '@netlify/blobs': ^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0 + '@planetscale/database': ^1.19.0 + '@upstash/redis': ^1.34.3 + '@vercel/blob': '>=0.27.1' + '@vercel/functions': ^2.2.12 || ^3.0.0 + '@vercel/kv': ^1 || ^2 || ^3 + aws4fetch: ^1.0.20 + db0: '>=0.2.1' + idb-keyval: ^6.2.1 + ioredis: ^5.4.2 + uploadthing: ^7.4.4 + peerDependenciesMeta: + '@azure/app-configuration': + optional: true + '@azure/cosmos': + optional: true + '@azure/data-tables': + optional: true + '@azure/identity': + optional: true + '@azure/keyvault-secrets': + optional: true + '@azure/storage-blob': + optional: true + '@capacitor/preferences': + optional: true + '@deno/kv': + optional: true + '@netlify/blobs': + optional: true + '@planetscale/database': + optional: true + '@upstash/redis': + optional: true + '@vercel/blob': + optional: true + '@vercel/functions': + optional: true + '@vercel/kv': + optional: true + aws4fetch: + optional: true + db0: + optional: true + idb-keyval: + optional: true + ioredis: + optional: true + uploadthing: + optional: true + + untun@0.1.3: + resolution: {integrity: sha512-4luGP9LMYszMRZwsvyUd9MrxgEGZdZuZgpVQHEEX0lCYFESasVRvZd0EYpCkOIbJKHMuv0LskpXc/8Un+MJzEQ==} + hasBin: true + + untyped@2.0.0: + resolution: {integrity: sha512-nwNCjxJTjNuLCgFr42fEak5OcLuB3ecca+9ksPFNvtfYSLpjf+iJqSIaSnIile6ZPbKYxI5k2AfXqeopGudK/g==} + hasBin: true + + unwasm@0.5.3: + resolution: {integrity: sha512-keBgTSfp3r6+s9ZcSma+0chwxQdmLbB5+dAD9vjtB21UTMYuKAxHXCU1K2CbCtnP09EaWeRvACnXk0EJtUx+hw==} + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + uqr@0.1.3: + resolution: {integrity: sha512-0rjE8iEJe4YmT9TOhwsZtqCMRLc5DXZUI2UEYUUg63ikBkqqE5EYWaI0etFe/5KUcmcYwLih2RND1kq+hrUJXA==} + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + use-latest-callback@0.2.6: + resolution: {integrity: sha512-FvRG9i1HSo0wagmX63Vrm8SnlUU3LMM3WyZkQ76RnslpBrX694AdG4A0zQBx2B3ZifFA0yv/BaEHGBnEax5rZg==} + peerDependencies: + react: '>=16.8' + + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + util@0.12.5: + resolution: {integrity: sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==} + + utils-merge@1.0.1: + resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} + engines: {node: '>= 0.4.0'} + + uuid@7.0.3: + resolution: {integrity: sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). + hasBin: true + + valibot@1.2.0: + resolution: {integrity: sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==} + peerDependencies: + typescript: '>=5' + peerDependenciesMeta: + typescript: + optional: true + + valibot@1.4.0: + resolution: {integrity: sha512-iC/x7fVcSyOwlm/VSt7RlHnzNGLGvR9GnxdifUeWoCJo0q4ZZvrVkIHC6faTlkxG47I2Y4UrFquPuVHCrOnrLg==} + peerDependencies: + typescript: '>=5' + peerDependenciesMeta: + typescript: + optional: true + + validate-npm-package-name@5.0.1: + resolution: {integrity: sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + vlq@1.0.1: + resolution: {integrity: sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w==} + + void-elements@3.1.0: + resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} + engines: {node: '>=0.10.0'} + + walker@1.0.8: + resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==} + + warn-once@0.1.1: + resolution: {integrity: sha512-VkQZJbO8zVImzYFteBXvBOZEl1qL175WH8VmZcxF2fZAoudNhNDvHi+doCaAEdU2l2vtcIwa2zn0QK5+I1HQ3Q==} + + wcwidth@1.0.1: + resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} + + web-streams-polyfill@4.0.0-beta.3: + resolution: {integrity: sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==} + engines: {node: '>= 14'} + + webidl-conversions@3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + + webidl-conversions@5.0.0: + resolution: {integrity: sha512-VlZwKPCkYKxQgeSbH5EyngOmRp7Ww7I9rQLERETtf5ofd9pGeswWiOtogpEO850jziPRarreGxn5QIiTqpb2wA==} + engines: {node: '>=8'} + + webpack-virtual-modules@0.6.2: + resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} + + whatwg-fetch@3.6.20: + resolution: {integrity: sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==} + + whatwg-url-without-unicode@8.0.0-3: + resolution: {integrity: sha512-HoKuzZrUlgpz35YO27XgD28uh/WJH4B0+3ttFqRo//lmq+9T/mIOJ6kqmINI9HpUpz1imRC/nR/lxKpJiv0uig==} + engines: {node: '>=10'} + + whatwg-url@5.0.0: + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + + which-typed-array@1.1.20: + resolution: {integrity: sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==} + engines: {node: '>= 0.4'} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + wonka@6.3.6: + resolution: {integrity: sha512-MXH+6mDHAZ2GuMpgKS055FR6v0xVP3XwquxIMYXgiW+FejHQlMGlvVRZT4qMCxR+bEo/FCtIdKxwej9WV3YQag==} + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + + wrap-ansi@9.0.2: + resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} + engines: {node: '>=18'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + write-file-atomic@4.0.2: + resolution: {integrity: sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==} + engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + + ws@6.2.3: + resolution: {integrity: sha512-jmTjYU0j60B+vHey6TfR3Z7RD61z/hmxBS3VMSGIrroOWXQEneK1zNuotOUrGyBHQj0yrpsLHPWtigEFd13ndA==} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ^5.0.2 + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + ws@7.5.10: + resolution: {integrity: sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==} + engines: {node: '>=8.3.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ^5.0.2 + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + ws@8.20.0: + resolution: {integrity: sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + wsl-utils@0.3.1: + resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==} + engines: {node: '>=20'} + + xcode@3.0.1: + resolution: {integrity: sha512-kCz5k7J7XbJtjABOvkc5lJmkiDh8VhjVCGNiqdKCscmVpdVUpEAyXv1xmCLkQJ5dsHqx3IPO4XW+NTDhU/fatA==} + engines: {node: '>=10.0.0'} + + xml2js@0.6.0: + resolution: {integrity: sha512-eLTh0kA8uHceqesPqSE+VvO1CDDJWMwlQfB6LuN6T8w6MaDJ8Txm8P7s5cHD0miF0V+GGTZrDQfxPZQVsur33w==} + engines: {node: '>=4.0.0'} + + xmlbuilder@11.0.1: + resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==} + engines: {node: '>=4.0'} + + xmlbuilder@15.1.1: + resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==} + engines: {node: '>=8.0'} + + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yallist@5.0.0: + resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} + engines: {node: '>=18'} + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs-parser@22.0.0: + resolution: {integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=23} + + yargs@17.7.2: + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} + + yargs@18.0.0: + resolution: {integrity: sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=23} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + youch-core@0.3.3: + resolution: {integrity: sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==} + + youch@4.1.1: + resolution: {integrity: sha512-mxW3qiSnl+GRxXsaUMzv2Mbada1Y8CDltET9UxejDQe6DBYlSekghl5U5K0ReAikcHDi0G1vKZEmmo/NWAGKLA==} + + zeptomatch@2.1.0: + resolution: {integrity: sha512-KiGErG2J0G82LSpniV0CtIzjlJ10E04j02VOudJsPyPwNZgGnRKQy7I1R7GMyg/QswnE4l7ohSGrQbQbjXPPDA==} + + zip-stream@6.0.1: + resolution: {integrity: sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==} + engines: {node: '>= 14'} + + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + + zustand@5.0.13: + resolution: {integrity: sha512-efI2tVaVQPqtOh114loML/Z80Y4NP3yc+Ff0fYiZJPauNeWZeIp/bRFD7I9bfmCOYBh/PHxlglQ9+wvlwnPikQ==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=18.0.0' + immer: '>=9.0.6' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + +snapshots: + + '@0no-co/graphql.web@1.2.0': {} + + '@alloc/quick-lru@5.2.0': {} + + '@babel/code-frame@7.10.4': + dependencies: + '@babel/highlight': 7.25.9 + + '@babel/code-frame@7.29.0': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.3': {} + + '@babel/core@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/helpers': 7.29.2 + '@babel/parser': 7.29.3 + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.1': + dependencies: + '@babel/parser': 7.29.3 + '@babel/types': 7.29.0 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-annotate-as-pure@7.27.3': + dependencies: + '@babel/types': 7.29.0 + + '@babel/helper-compilation-targets@7.28.6': + dependencies: + '@babel/compat-data': 7.29.3 + '@babel/helper-validator-option': 7.27.1 + browserslist: 4.28.2 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-create-class-features-plugin@7.29.3(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-member-expression-to-functions': 7.28.5 + '@babel/helper-optimise-call-expression': 7.27.1 + '@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.0) + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + '@babel/traverse': 7.29.0 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/helper-create-regexp-features-plugin@7.28.5(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.27.3 + regexpu-core: 6.4.0 + semver: 6.3.1 + + '@babel/helper-define-polyfill-provider@0.6.8(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + debug: 4.4.3 + lodash.debounce: 4.0.8 + resolve: 1.22.12 + transitivePeerDependencies: + - supports-color + + '@babel/helper-globals@7.28.0': {} + + '@babel/helper-member-expression-to-functions@7.28.5': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-imports@7.28.6': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-validator-identifier': 7.28.5 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-optimise-call-expression@7.27.1': + dependencies: + '@babel/types': 7.29.0 + + '@babel/helper-plugin-utils@7.28.6': {} + + '@babel/helper-remap-async-to-generator@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-wrap-function': 7.28.6 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-replace-supers@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-member-expression-to-functions': 7.28.5 + '@babel/helper-optimise-call-expression': 7.27.1 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-skip-transparent-expression-wrappers@7.27.1': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/helper-validator-option@7.27.1': {} + + '@babel/helper-wrap-function@7.28.6': + dependencies: + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helpers@7.29.2': + dependencies: + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + + '@babel/highlight@7.25.9': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + chalk: 2.4.2 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/parser@7.29.3': + dependencies: + '@babel/types': 7.29.0 + + '@babel/plugin-proposal-decorators@7.29.0(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-create-class-features-plugin': 7.29.3(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + '@babel/plugin-syntax-decorators': 7.28.6(@babel/core@7.29.0) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-proposal-export-default-from@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-decorators@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-dynamic-import@7.8.3(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-export-default-from@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-flow@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-import-attributes@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-arrow-functions@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-async-generator-functions@7.29.0(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-remap-async-to-generator': 7.27.1(@babel/core@7.29.0) + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-async-to-generator@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-remap-async-to-generator': 7.27.1(@babel/core@7.29.0) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-block-scoping@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-class-properties@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-create-class-features-plugin': 7.29.3(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-classes@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-globals': 7.28.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.0) + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-computed-properties@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/template': 7.28.6 + + '@babel/plugin-transform-destructuring@7.28.5(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-export-namespace-from@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-flow-strip-types@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/plugin-syntax-flow': 7.28.6(@babel/core@7.29.0) + + '@babel/plugin-transform-for-of@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-function-name@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-literals@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-logical-assignment-operators@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-modules-commonjs@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-named-capturing-groups-regex@7.29.0(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-nullish-coalescing-operator@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-numeric-separator@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-object-rest-spread@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/plugin-transform-destructuring': 7.28.5(@babel/core@7.29.0) + '@babel/plugin-transform-parameters': 7.27.7(@babel/core@7.29.0) + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-optional-catch-binding@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-optional-chaining@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-parameters@7.27.7(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-private-methods@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-create-class-features-plugin': 7.29.3(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-private-property-in-object@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-create-class-features-plugin': 7.29.3(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-react-display-name@7.28.0(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-react-jsx-development@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/plugin-transform-react-jsx': 7.28.6(@babel/core@7.29.0) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-react-jsx@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-react-pure-annotations@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-regenerator@7.29.0(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-runtime@7.29.0(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + babel-plugin-polyfill-corejs2: 0.4.17(@babel/core@7.29.0) + babel-plugin-polyfill-corejs3: 0.13.0(@babel/core@7.29.0) + babel-plugin-polyfill-regenerator: 0.6.8(@babel/core@7.29.0) + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-shorthand-properties@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-spread@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-sticky-regex@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-template-literals@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-typescript@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-create-class-features-plugin': 7.29.3(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-unicode-regex@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/preset-react@7.28.5(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-validator-option': 7.27.1 + '@babel/plugin-transform-react-display-name': 7.28.0(@babel/core@7.29.0) + '@babel/plugin-transform-react-jsx': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-react-jsx-development': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-react-pure-annotations': 7.27.1(@babel/core@7.29.0) + transitivePeerDependencies: + - supports-color + + '@babel/preset-typescript@7.28.5(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-validator-option': 7.27.1 + '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.0) + transitivePeerDependencies: + - supports-color + + '@babel/runtime@7.29.2': {} + + '@babel/template@7.28.6': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/parser': 7.29.3 + '@babel/types': 7.29.0 + + '@babel/traverse@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-globals': 7.28.0 + '@babel/parser': 7.29.3 + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.0': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@cloudflare/kv-asset-handler@0.4.2': {} + + '@egjs/hammerjs@2.0.17': + dependencies: + '@types/hammerjs': 2.0.46 + + '@electric-sql/pglite-socket@0.1.1(@electric-sql/pglite@0.4.1)': + dependencies: + '@electric-sql/pglite': 0.4.1 + + '@electric-sql/pglite-tools@0.3.1(@electric-sql/pglite@0.4.1)': + dependencies: + '@electric-sql/pglite': 0.4.1 + + '@electric-sql/pglite@0.4.1': {} + + '@esbuild/aix-ppc64@0.28.0': + optional: true + + '@esbuild/android-arm64@0.28.0': + optional: true + + '@esbuild/android-arm@0.28.0': + optional: true + + '@esbuild/android-x64@0.28.0': + optional: true + + '@esbuild/darwin-arm64@0.28.0': + optional: true + + '@esbuild/darwin-x64@0.28.0': + optional: true + + '@esbuild/freebsd-arm64@0.28.0': + optional: true + + '@esbuild/freebsd-x64@0.28.0': + optional: true + + '@esbuild/linux-arm64@0.28.0': + optional: true + + '@esbuild/linux-arm@0.28.0': + optional: true + + '@esbuild/linux-ia32@0.28.0': + optional: true + + '@esbuild/linux-loong64@0.28.0': + optional: true + + '@esbuild/linux-mips64el@0.28.0': + optional: true + + '@esbuild/linux-ppc64@0.28.0': + optional: true + + '@esbuild/linux-riscv64@0.28.0': + optional: true + + '@esbuild/linux-s390x@0.28.0': + optional: true + + '@esbuild/linux-x64@0.28.0': + optional: true + + '@esbuild/netbsd-arm64@0.28.0': + optional: true + + '@esbuild/netbsd-x64@0.28.0': + optional: true + + '@esbuild/openbsd-arm64@0.28.0': + optional: true + + '@esbuild/openbsd-x64@0.28.0': + optional: true + + '@esbuild/openharmony-arm64@0.28.0': + optional: true + + '@esbuild/sunos-x64@0.28.0': + optional: true + + '@esbuild/win32-arm64@0.28.0': + optional: true + + '@esbuild/win32-ia32@0.28.0': + optional: true + + '@esbuild/win32-x64@0.28.0': + optional: true + + '@expo-google-fonts/nunito@0.2.3': {} + + '@expo/cli@0.24.24': + dependencies: + '@0no-co/graphql.web': 1.2.0 + '@babel/runtime': 7.29.2 + '@expo/code-signing-certificates': 0.0.6 + '@expo/config': 11.0.13 + '@expo/config-plugins': 10.1.2 + '@expo/devcert': 1.2.1 + '@expo/env': 1.0.7 + '@expo/image-utils': 0.7.6 + '@expo/json-file': 9.1.5 + '@expo/metro-config': 0.20.18 + '@expo/osascript': 2.4.3 + '@expo/package-manager': 1.10.5 + '@expo/plist': 0.3.5 + '@expo/prebuild-config': 9.0.12 + '@expo/schema-utils': 0.1.8 + '@expo/spawn-async': 1.7.2 + '@expo/ws-tunnel': 1.0.6 + '@expo/xcpretty': 4.4.3 + '@react-native/dev-middleware': 0.79.6 + '@urql/core': 5.2.0 + '@urql/exchange-retry': 1.3.2(@urql/core@5.2.0) + accepts: 1.3.8 + arg: 5.0.2 + better-opn: 3.0.2 + bplist-creator: 0.1.0 + bplist-parser: 0.3.2 + chalk: 4.1.2 + ci-info: 3.9.0 + compression: 1.8.1 + connect: 3.7.0 + debug: 4.4.3 + env-editor: 0.4.2 + freeport-async: 2.0.0 + getenv: 2.0.0 + glob: 10.5.0 + lan-network: 0.1.7 + minimatch: 9.0.9 + node-forge: 1.4.0 + npm-package-arg: 11.0.3 + ora: 3.4.0 + picomatch: 3.0.2 + pretty-bytes: 5.6.0 + pretty-format: 29.7.0 + progress: 2.0.3 + prompts: 2.4.2 + qrcode-terminal: 0.11.0 + require-from-string: 2.0.2 + requireg: 0.2.2 + resolve: 1.22.12 + resolve-from: 5.0.0 + resolve.exports: 2.0.3 + semver: 7.7.4 + send: 0.19.2 + slugify: 1.6.9 + source-map-support: 0.5.21 + stacktrace-parser: 0.1.11 + structured-headers: 0.4.1 + tar: 7.5.14 + terminal-link: 2.1.1 + undici: 6.25.0 + wrap-ansi: 7.0.0 + ws: 8.20.0 + transitivePeerDependencies: + - bufferutil + - graphql + - supports-color + - utf-8-validate + + '@expo/code-signing-certificates@0.0.6': + dependencies: + node-forge: 1.4.0 + + '@expo/config-plugins@10.1.2': + dependencies: + '@expo/config-types': 53.0.5 + '@expo/json-file': 9.1.5 + '@expo/plist': 0.3.5 + '@expo/sdk-runtime-versions': 1.0.0 + chalk: 4.1.2 + debug: 4.4.3 + getenv: 2.0.0 + glob: 10.5.0 + resolve-from: 5.0.0 + semver: 7.7.4 + slash: 3.0.0 + slugify: 1.6.9 + xcode: 3.0.1 + xml2js: 0.6.0 + transitivePeerDependencies: + - supports-color + + '@expo/config-types@53.0.5': {} + + '@expo/config@11.0.13': + dependencies: + '@babel/code-frame': 7.10.4 + '@expo/config-plugins': 10.1.2 + '@expo/config-types': 53.0.5 + '@expo/json-file': 9.1.5 + deepmerge: 4.3.1 + getenv: 2.0.0 + glob: 10.5.0 + require-from-string: 2.0.2 + resolve-from: 5.0.0 + resolve-workspace-root: 2.0.1 + semver: 7.7.4 + slugify: 1.6.9 + sucrase: 3.35.0 + transitivePeerDependencies: + - supports-color + + '@expo/devcert@1.2.1': + dependencies: + '@expo/sudo-prompt': 9.3.2 + debug: 3.2.7 + transitivePeerDependencies: + - supports-color + + '@expo/env@1.0.7': + dependencies: + chalk: 4.1.2 + debug: 4.4.3 + dotenv: 16.4.7 + dotenv-expand: 11.0.7 + getenv: 2.0.0 + transitivePeerDependencies: + - supports-color + + '@expo/fingerprint@0.13.4': + dependencies: + '@expo/spawn-async': 1.7.2 + arg: 5.0.2 + chalk: 4.1.2 + debug: 4.4.3 + find-up: 5.0.0 + getenv: 2.0.0 + glob: 10.5.0 + ignore: 5.3.2 + minimatch: 9.0.9 + p-limit: 3.1.0 + resolve-from: 5.0.0 + semver: 7.7.4 + transitivePeerDependencies: + - supports-color + + '@expo/image-utils@0.7.6': + dependencies: + '@expo/spawn-async': 1.7.2 + chalk: 4.1.2 + getenv: 2.0.0 + jimp-compact: 0.16.1 + parse-png: 2.1.0 + resolve-from: 5.0.0 + semver: 7.7.4 + temp-dir: 2.0.0 + unique-string: 2.0.0 + + '@expo/json-file@10.0.14': + dependencies: + '@babel/code-frame': 7.29.0 + json5: 2.2.3 + + '@expo/json-file@9.1.5': + dependencies: + '@babel/code-frame': 7.10.4 + json5: 2.2.3 + + '@expo/metro-config@0.20.18': + dependencies: + '@babel/core': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/parser': 7.29.3 + '@babel/types': 7.29.0 + '@expo/config': 11.0.13 + '@expo/env': 1.0.7 + '@expo/json-file': 9.1.5 + '@expo/spawn-async': 1.7.2 + chalk: 4.1.2 + debug: 4.4.3 + dotenv: 16.4.7 + dotenv-expand: 11.0.7 + getenv: 2.0.0 + glob: 10.5.0 + jsc-safe-url: 0.2.4 + lightningcss: 1.27.0 + minimatch: 9.0.9 + postcss: 8.4.49 + resolve-from: 5.0.0 + transitivePeerDependencies: + - supports-color + + '@expo/metro-runtime@5.0.5(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))': + dependencies: + react-native: 0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0) + + '@expo/osascript@2.4.3': + dependencies: + '@expo/spawn-async': 1.7.2 + + '@expo/package-manager@1.10.5': + dependencies: + '@expo/json-file': 10.0.14 + '@expo/spawn-async': 1.7.2 + chalk: 4.1.2 + npm-package-arg: 11.0.3 + ora: 3.4.0 + resolve-workspace-root: 2.0.1 + + '@expo/plist@0.3.5': + dependencies: + '@xmldom/xmldom': 0.8.13 + base64-js: 1.5.1 + xmlbuilder: 15.1.1 + + '@expo/prebuild-config@9.0.12': + dependencies: + '@expo/config': 11.0.13 + '@expo/config-plugins': 10.1.2 + '@expo/config-types': 53.0.5 + '@expo/image-utils': 0.7.6 + '@expo/json-file': 9.1.5 + '@react-native/normalize-colors': 0.79.6 + debug: 4.4.3 + resolve-from: 5.0.0 + semver: 7.7.4 + xml2js: 0.6.0 + transitivePeerDependencies: + - supports-color + + '@expo/schema-utils@0.1.8': {} + + '@expo/sdk-runtime-versions@1.0.0': {} + + '@expo/server@0.6.3': + dependencies: + abort-controller: 3.0.0 + debug: 4.4.3 + source-map-support: 0.5.21 + undici: 7.25.0 + transitivePeerDependencies: + - supports-color + + '@expo/spawn-async@1.7.2': + dependencies: + cross-spawn: 7.0.6 + + '@expo/sudo-prompt@9.3.2': {} + + '@expo/vector-icons@14.1.0(expo-font@13.0.4(expo@53.0.27(@babel/core@7.29.0)(@expo/metro-runtime@5.0.5(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0)))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react@19.0.0))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)': + dependencies: + expo-font: 13.0.4(expo@53.0.27(@babel/core@7.29.0)(@expo/metro-runtime@5.0.5(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0)))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react@19.0.0) + react: 19.0.0 + react-native: 0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0) + + '@expo/vector-icons@14.1.0(expo-font@13.3.2(expo@53.0.27(@babel/core@7.29.0)(@expo/metro-runtime@5.0.5(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0)))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react@19.0.0))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)': + dependencies: + expo-font: 13.3.2(expo@53.0.27(@babel/core@7.29.0)(@expo/metro-runtime@5.0.5(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0)))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react@19.0.0) + react: 19.0.0 + react-native: 0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0) + + '@expo/ws-tunnel@1.0.6': {} + + '@expo/xcpretty@4.4.3': + dependencies: + '@babel/code-frame': 7.29.0 + chalk: 4.1.2 + js-yaml: 4.1.1 + + '@hono/node-server@1.19.11(hono@4.12.17)': + dependencies: + hono: 4.12.17 + + '@ide/backoff@1.0.0': {} + + '@ioredis/commands@1.5.1': {} + + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.2.0 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + + '@isaacs/fs-minipass@4.0.1': + dependencies: + minipass: 7.1.3 + + '@isaacs/ttlcache@1.4.1': {} + + '@istanbuljs/load-nyc-config@1.1.0': + dependencies: + camelcase: 5.3.1 + find-up: 4.1.0 + get-package-type: 0.1.0 + js-yaml: 3.14.2 + resolve-from: 5.0.0 + + '@istanbuljs/schema@0.1.6': {} + + '@jest/create-cache-key-function@29.7.0': + dependencies: + '@jest/types': 29.6.3 + + '@jest/environment@29.7.0': + dependencies: + '@jest/fake-timers': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 22.19.17 + jest-mock: 29.7.0 + + '@jest/fake-timers@29.7.0': + dependencies: + '@jest/types': 29.6.3 + '@sinonjs/fake-timers': 10.3.0 + '@types/node': 22.19.17 + jest-message-util: 29.7.0 + jest-mock: 29.7.0 + jest-util: 29.7.0 + + '@jest/schemas@29.6.3': + dependencies: + '@sinclair/typebox': 0.27.10 + + '@jest/transform@29.7.0': + dependencies: + '@babel/core': 7.29.0 + '@jest/types': 29.6.3 + '@jridgewell/trace-mapping': 0.3.31 + babel-plugin-istanbul: 6.1.1 + chalk: 4.1.2 + convert-source-map: 2.0.0 + fast-json-stable-stringify: 2.1.0 + graceful-fs: 4.2.11 + jest-haste-map: 29.7.0 + jest-regex-util: 29.6.3 + jest-util: 29.7.0 + micromatch: 4.0.8 + pirates: 4.0.7 + slash: 3.0.0 + write-file-atomic: 4.0.2 + transitivePeerDependencies: + - supports-color + + '@jest/types@29.6.3': + dependencies: + '@jest/schemas': 29.6.3 + '@types/istanbul-lib-coverage': 2.0.6 + '@types/istanbul-reports': 3.0.4 + '@types/node': 22.19.17 + '@types/yargs': 17.0.35 + chalk: 4.1.2 + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/source-map@0.3.11': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@kurkle/color@0.3.4': {} + + '@mapbox/node-pre-gyp@2.0.3': + dependencies: + consola: 3.4.2 + detect-libc: 2.1.2 + https-proxy-agent: 7.0.6 + node-fetch: 2.7.0 + nopt: 8.1.0 + semver: 7.7.4 + tar: 7.5.14 + transitivePeerDependencies: + - encoding + - supports-color + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.1 + + '@parcel/watcher-android-arm64@2.5.6': + optional: true + + '@parcel/watcher-darwin-arm64@2.5.6': + optional: true + + '@parcel/watcher-darwin-x64@2.5.6': + optional: true + + '@parcel/watcher-freebsd-x64@2.5.6': + optional: true + + '@parcel/watcher-linux-arm-glibc@2.5.6': + optional: true + + '@parcel/watcher-linux-arm-musl@2.5.6': + optional: true + + '@parcel/watcher-linux-arm64-glibc@2.5.6': + optional: true + + '@parcel/watcher-linux-arm64-musl@2.5.6': + optional: true + + '@parcel/watcher-linux-x64-glibc@2.5.6': + optional: true + + '@parcel/watcher-linux-x64-musl@2.5.6': + optional: true + + '@parcel/watcher-wasm@2.5.6': + dependencies: + is-glob: 4.0.3 + picomatch: 4.0.4 + + '@parcel/watcher-win32-arm64@2.5.6': + optional: true + + '@parcel/watcher-win32-ia32@2.5.6': + optional: true + + '@parcel/watcher-win32-x64@2.5.6': + optional: true + + '@parcel/watcher@2.5.6': + dependencies: + detect-libc: 2.1.2 + is-glob: 4.0.3 + node-addon-api: 7.1.1 + picomatch: 4.0.4 + optionalDependencies: + '@parcel/watcher-android-arm64': 2.5.6 + '@parcel/watcher-darwin-arm64': 2.5.6 + '@parcel/watcher-darwin-x64': 2.5.6 + '@parcel/watcher-freebsd-x64': 2.5.6 + '@parcel/watcher-linux-arm-glibc': 2.5.6 + '@parcel/watcher-linux-arm-musl': 2.5.6 + '@parcel/watcher-linux-arm64-glibc': 2.5.6 + '@parcel/watcher-linux-arm64-musl': 2.5.6 + '@parcel/watcher-linux-x64-glibc': 2.5.6 + '@parcel/watcher-linux-x64-musl': 2.5.6 + '@parcel/watcher-win32-arm64': 2.5.6 + '@parcel/watcher-win32-ia32': 2.5.6 + '@parcel/watcher-win32-x64': 2.5.6 + + '@pinojs/redact@0.4.0': {} + + '@pkgjs/parseargs@0.11.0': + optional: true + + '@poppinss/colors@4.1.6': + dependencies: + kleur: 4.1.5 + + '@poppinss/dumper@0.7.0': + dependencies: + '@poppinss/colors': 4.1.6 + '@sindresorhus/is': 7.2.0 + supports-color: 10.2.2 + + '@poppinss/exception@1.2.3': {} + + '@prisma/adapter-pg@7.8.0': + dependencies: + '@prisma/driver-adapter-utils': 7.8.0 + '@types/pg': 8.20.0 + pg: 8.20.0 + postgres-array: 3.0.4 + transitivePeerDependencies: + - pg-native + + '@prisma/client-runtime-utils@7.8.0': {} + + '@prisma/client@7.8.0(prisma@7.8.0(@types/react@19.0.14)(magicast@0.5.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.9.3))(typescript@5.9.3)': + dependencies: + '@prisma/client-runtime-utils': 7.8.0 + optionalDependencies: + prisma: 7.8.0(@types/react@19.0.14)(magicast@0.5.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.9.3) + typescript: 5.9.3 + + '@prisma/config@7.8.0(magicast@0.5.2)': + dependencies: + c12: 3.3.4(magicast@0.5.2) + deepmerge-ts: 7.1.5 + effect: 3.20.0 + empathic: 2.0.0 + transitivePeerDependencies: + - magicast + + '@prisma/debug@7.2.0': {} + + '@prisma/debug@7.8.0': {} + + '@prisma/dev@0.24.3(typescript@5.9.3)': + dependencies: + '@electric-sql/pglite': 0.4.1 + '@electric-sql/pglite-socket': 0.1.1(@electric-sql/pglite@0.4.1) + '@electric-sql/pglite-tools': 0.3.1(@electric-sql/pglite@0.4.1) + '@hono/node-server': 1.19.11(hono@4.12.17) + '@prisma/get-platform': 7.2.0 + '@prisma/query-plan-executor': 7.2.0 + '@prisma/streams-local': 0.1.2 + foreground-child: 3.3.1 + get-port-please: 3.2.0 + hono: 4.12.17 + http-status-codes: 2.3.0 + pathe: 2.0.3 + proper-lockfile: 4.1.2 + remeda: 2.33.4 + std-env: 3.10.0 + valibot: 1.2.0(typescript@5.9.3) + zeptomatch: 2.1.0 + transitivePeerDependencies: + - typescript + + '@prisma/driver-adapter-utils@7.8.0': + dependencies: + '@prisma/debug': 7.8.0 + + '@prisma/engines-version@7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a': {} + + '@prisma/engines@7.8.0': + dependencies: + '@prisma/debug': 7.8.0 + '@prisma/engines-version': 7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a + '@prisma/fetch-engine': 7.8.0 + '@prisma/get-platform': 7.8.0 + + '@prisma/fetch-engine@7.8.0': + dependencies: + '@prisma/debug': 7.8.0 + '@prisma/engines-version': 7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a + '@prisma/get-platform': 7.8.0 + + '@prisma/get-platform@7.2.0': + dependencies: + '@prisma/debug': 7.2.0 + + '@prisma/get-platform@7.8.0': + dependencies: + '@prisma/debug': 7.8.0 + + '@prisma/query-plan-executor@7.2.0': {} + + '@prisma/streams-local@0.1.2': + dependencies: + ajv: 8.20.0 + better-result: 2.9.2 + env-paths: 3.0.0 + proper-lockfile: 4.1.2 + + '@prisma/studio-core@0.27.3(@types/react@19.0.14)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': + dependencies: + '@radix-ui/react-toggle': 1.1.10(@types/react@19.0.14)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@types/react': 19.0.14 + chart.js: 4.5.1 + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + transitivePeerDependencies: + - '@types/react-dom' + + '@radix-ui/primitive@1.1.3': {} + + '@radix-ui/react-compose-refs@1.1.2(@types/react@19.0.14)(react@19.0.0)': + dependencies: + react: 19.0.0 + optionalDependencies: + '@types/react': 19.0.14 + + '@radix-ui/react-primitive@2.1.3(@types/react@19.0.14)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': + dependencies: + '@radix-ui/react-slot': 1.2.3(@types/react@19.0.14)(react@19.0.0) + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + optionalDependencies: + '@types/react': 19.0.14 + + '@radix-ui/react-slot@1.2.0(@types/react@19.0.14)(react@19.0.0)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.0.14)(react@19.0.0) + react: 19.0.0 + optionalDependencies: + '@types/react': 19.0.14 + + '@radix-ui/react-slot@1.2.3(@types/react@19.0.14)(react@19.0.0)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.0.14)(react@19.0.0) + react: 19.0.0 + optionalDependencies: + '@types/react': 19.0.14 + + '@radix-ui/react-toggle@1.1.10(@types/react@19.0.14)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-primitive': 2.1.3(@types/react@19.0.14)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.0.14)(react@19.0.0) + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + optionalDependencies: + '@types/react': 19.0.14 + + '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.0.14)(react@19.0.0)': + dependencies: + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.0.14)(react@19.0.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.0.14)(react@19.0.0) + react: 19.0.0 + optionalDependencies: + '@types/react': 19.0.14 + + '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.0.14)(react@19.0.0)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.0.14)(react@19.0.0) + react: 19.0.0 + optionalDependencies: + '@types/react': 19.0.14 + + '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.0.14)(react@19.0.0)': + dependencies: + react: 19.0.0 + optionalDependencies: + '@types/react': 19.0.14 + + '@react-email/render@1.1.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': + dependencies: + html-to-text: 9.0.5 + prettier: 3.8.3 + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + react-promise-suspense: 0.3.4 + + '@react-native-async-storage/async-storage@2.2.0(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))': + dependencies: + merge-options: 3.0.4 + react-native: 0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0) + + '@react-native-community/slider@5.2.0': {} + + '@react-native/assets-registry@0.79.6': {} + + '@react-native/babel-plugin-codegen@0.79.6(@babel/core@7.29.0)': + dependencies: + '@babel/traverse': 7.29.0 + '@react-native/codegen': 0.79.6(@babel/core@7.29.0) + transitivePeerDependencies: + - '@babel/core' + - supports-color + + '@react-native/babel-preset@0.79.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/plugin-proposal-export-default-from': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-syntax-dynamic-import': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-export-default-from': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-transform-arrow-functions': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-async-generator-functions': 7.29.0(@babel/core@7.29.0) + '@babel/plugin-transform-async-to-generator': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-block-scoping': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-class-properties': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-classes': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-computed-properties': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-destructuring': 7.28.5(@babel/core@7.29.0) + '@babel/plugin-transform-flow-strip-types': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-for-of': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-function-name': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-literals': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-logical-assignment-operators': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-named-capturing-groups-regex': 7.29.0(@babel/core@7.29.0) + '@babel/plugin-transform-nullish-coalescing-operator': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-numeric-separator': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-object-rest-spread': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-optional-catch-binding': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-optional-chaining': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-parameters': 7.27.7(@babel/core@7.29.0) + '@babel/plugin-transform-private-methods': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-private-property-in-object': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-react-display-name': 7.28.0(@babel/core@7.29.0) + '@babel/plugin-transform-react-jsx': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-regenerator': 7.29.0(@babel/core@7.29.0) + '@babel/plugin-transform-runtime': 7.29.0(@babel/core@7.29.0) + '@babel/plugin-transform-shorthand-properties': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-spread': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-sticky-regex': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-unicode-regex': 7.27.1(@babel/core@7.29.0) + '@babel/template': 7.28.6 + '@react-native/babel-plugin-codegen': 0.79.6(@babel/core@7.29.0) + babel-plugin-syntax-hermes-parser: 0.25.1 + babel-plugin-transform-flow-enums: 0.0.2(@babel/core@7.29.0) + react-refresh: 0.14.2 + transitivePeerDependencies: + - supports-color + + '@react-native/codegen@0.79.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/parser': 7.29.3 + glob: 7.2.3 + hermes-parser: 0.25.1 + invariant: 2.2.4 + nullthrows: 1.1.1 + yargs: 17.7.2 + + '@react-native/community-cli-plugin@0.79.6': + dependencies: + '@react-native/dev-middleware': 0.79.6 + chalk: 4.1.2 + debug: 2.6.9 + invariant: 2.2.4 + metro: 0.82.5 + metro-config: 0.82.5 + metro-core: 0.82.5 + semver: 7.7.4 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + '@react-native/debugger-frontend@0.79.6': {} + + '@react-native/dev-middleware@0.79.6': + dependencies: + '@isaacs/ttlcache': 1.4.1 + '@react-native/debugger-frontend': 0.79.6 + chrome-launcher: 0.15.2 + chromium-edge-launcher: 0.2.0 + connect: 3.7.0 + debug: 2.6.9 + invariant: 2.2.4 + nullthrows: 1.1.1 + open: 7.4.2 + serve-static: 1.16.3 + ws: 6.2.3 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + '@react-native/gradle-plugin@0.79.6': {} + + '@react-native/js-polyfills@0.79.6': {} + + '@react-native/normalize-colors@0.79.6': {} + + '@react-native/virtualized-lists@0.79.6(@types/react@19.0.14)(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)': + dependencies: + invariant: 2.2.4 + nullthrows: 1.1.1 + react: 19.0.0 + react-native: 0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0) + optionalDependencies: + '@types/react': 19.0.14 + + '@react-navigation/bottom-tabs@7.15.11(@react-navigation/native@7.2.2(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native-safe-area-context@5.4.0(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native-screens@4.11.1(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)': + dependencies: + '@react-navigation/elements': 2.9.15(@react-navigation/native@7.2.2(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native-safe-area-context@5.4.0(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0) + '@react-navigation/native': 7.2.2(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0) + color: 4.2.3 + react: 19.0.0 + react-native: 0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0) + react-native-safe-area-context: 5.4.0(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0) + react-native-screens: 4.11.1(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0) + sf-symbols-typescript: 2.2.0 + transitivePeerDependencies: + - '@react-native-masked-view/masked-view' + + '@react-navigation/core@7.17.2(react@19.0.0)': + dependencies: + '@react-navigation/routers': 7.5.3 + escape-string-regexp: 4.0.0 + fast-deep-equal: 3.1.3 + nanoid: 3.3.12 + query-string: 7.1.3 + react: 19.0.0 + react-is: 19.2.5 + use-latest-callback: 0.2.6(react@19.0.0) + use-sync-external-store: 1.6.0(react@19.0.0) + + '@react-navigation/elements@2.9.15(@react-navigation/native@7.2.2(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native-safe-area-context@5.4.0(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)': + dependencies: + '@react-navigation/native': 7.2.2(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0) + color: 4.2.3 + react: 19.0.0 + react-native: 0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0) + react-native-safe-area-context: 5.4.0(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0) + use-latest-callback: 0.2.6(react@19.0.0) + use-sync-external-store: 1.6.0(react@19.0.0) + + '@react-navigation/native-stack@7.14.12(@react-navigation/native@7.2.2(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native-safe-area-context@5.4.0(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native-screens@4.11.1(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)': + dependencies: + '@react-navigation/elements': 2.9.15(@react-navigation/native@7.2.2(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native-safe-area-context@5.4.0(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0) + '@react-navigation/native': 7.2.2(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0) + color: 4.2.3 + react: 19.0.0 + react-native: 0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0) + react-native-safe-area-context: 5.4.0(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0) + react-native-screens: 4.11.1(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0) + sf-symbols-typescript: 2.2.0 + warn-once: 0.1.1 + transitivePeerDependencies: + - '@react-native-masked-view/masked-view' + + '@react-navigation/native@7.2.2(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)': + dependencies: + '@react-navigation/core': 7.17.2(react@19.0.0) + escape-string-regexp: 4.0.0 + fast-deep-equal: 3.1.3 + nanoid: 3.3.12 + react: 19.0.0 + react-native: 0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0) + use-latest-callback: 0.2.6(react@19.0.0) + + '@react-navigation/routers@7.5.3': + dependencies: + nanoid: 3.3.12 + + '@rollup/plugin-alias@6.0.0(rollup@4.60.3)': + optionalDependencies: + rollup: 4.60.3 + + '@rollup/plugin-commonjs@29.0.2(rollup@4.60.3)': + dependencies: + '@rollup/pluginutils': 5.3.0(rollup@4.60.3) + commondir: 1.0.1 + estree-walker: 2.0.2 + fdir: 6.5.0(picomatch@4.0.4) + is-reference: 1.2.1 + magic-string: 0.30.21 + picomatch: 4.0.4 + optionalDependencies: + rollup: 4.60.3 + + '@rollup/plugin-inject@5.0.5(rollup@4.60.3)': + dependencies: + '@rollup/pluginutils': 5.3.0(rollup@4.60.3) + estree-walker: 2.0.2 + magic-string: 0.30.21 + optionalDependencies: + rollup: 4.60.3 + + '@rollup/plugin-json@6.1.0(rollup@4.60.3)': + dependencies: + '@rollup/pluginutils': 5.3.0(rollup@4.60.3) + optionalDependencies: + rollup: 4.60.3 + + '@rollup/plugin-node-resolve@16.0.3(rollup@4.60.3)': + dependencies: + '@rollup/pluginutils': 5.3.0(rollup@4.60.3) + '@types/resolve': 1.20.2 + deepmerge: 4.3.1 + is-module: 1.0.0 + resolve: 1.22.12 + optionalDependencies: + rollup: 4.60.3 + + '@rollup/plugin-replace@6.0.3(rollup@4.60.3)': + dependencies: + '@rollup/pluginutils': 5.3.0(rollup@4.60.3) + magic-string: 0.30.21 + optionalDependencies: + rollup: 4.60.3 + + '@rollup/plugin-terser@1.0.0(rollup@4.60.3)': + dependencies: + serialize-javascript: 7.0.5 + smob: 1.6.1 + terser: 5.46.2 + optionalDependencies: + rollup: 4.60.3 + + '@rollup/pluginutils@5.3.0(rollup@4.60.3)': + dependencies: + '@types/estree': 1.0.8 + estree-walker: 2.0.2 + picomatch: 4.0.4 + optionalDependencies: + rollup: 4.60.3 + + '@rollup/rollup-android-arm-eabi@4.60.3': + optional: true + + '@rollup/rollup-android-arm64@4.60.3': + optional: true + + '@rollup/rollup-darwin-arm64@4.60.3': + optional: true + + '@rollup/rollup-darwin-x64@4.60.3': + optional: true + + '@rollup/rollup-freebsd-arm64@4.60.3': + optional: true + + '@rollup/rollup-freebsd-x64@4.60.3': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.60.3': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.60.3': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.60.3': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.60.3': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.60.3': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.60.3': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.60.3': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.60.3': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.60.3': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.60.3': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.60.3': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.60.3': + optional: true + + '@rollup/rollup-linux-x64-musl@4.60.3': + optional: true + + '@rollup/rollup-openbsd-x64@4.60.3': + optional: true + + '@rollup/rollup-openharmony-arm64@4.60.3': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.60.3': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.60.3': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.60.3': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.60.3': + optional: true + + '@selderee/plugin-htmlparser2@0.11.0': + dependencies: + domhandler: 5.0.3 + selderee: 0.11.0 + + '@sinclair/typebox@0.27.10': {} + + '@sindresorhus/is@7.2.0': {} + + '@sindresorhus/merge-streams@4.0.0': {} + + '@sinonjs/commons@3.0.1': + dependencies: + type-detect: 4.0.8 + + '@sinonjs/fake-timers@10.3.0': + dependencies: + '@sinonjs/commons': 3.0.1 + + '@speed-highlight/core@1.2.15': {} + + '@standard-schema/spec@1.1.0': {} + + '@supabase/auth-js@2.105.3': + dependencies: + tslib: 2.8.1 + + '@supabase/functions-js@2.105.3': + dependencies: + tslib: 2.8.1 + + '@supabase/phoenix@0.4.1': {} + + '@supabase/postgrest-js@2.105.3': + dependencies: + tslib: 2.8.1 + + '@supabase/realtime-js@2.105.3': + dependencies: + '@supabase/phoenix': 0.4.1 + '@types/ws': 8.18.1 + tslib: 2.8.1 + ws: 8.20.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@supabase/storage-js@2.105.3': + dependencies: + iceberg-js: 0.8.1 + tslib: 2.8.1 + + '@supabase/supabase-js@2.105.3': + dependencies: + '@supabase/auth-js': 2.105.3 + '@supabase/functions-js': 2.105.3 + '@supabase/postgrest-js': 2.105.3 + '@supabase/realtime-js': 2.105.3 + '@supabase/storage-js': 2.105.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@tanstack/query-core@5.100.9': {} + + '@tanstack/react-query@5.100.9(react@19.0.0)': + dependencies: + '@tanstack/query-core': 5.100.9 + react: 19.0.0 + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.29.3 + '@babel/types': 7.29.0 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.29.0 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.29.3 + '@babel/types': 7.29.0 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.29.0 + + '@types/estree@1.0.8': {} + + '@types/graceful-fs@4.1.9': + dependencies: + '@types/node': 22.19.17 + + '@types/hammerjs@2.0.46': {} + + '@types/istanbul-lib-coverage@2.0.6': {} + + '@types/istanbul-lib-report@3.0.3': + dependencies: + '@types/istanbul-lib-coverage': 2.0.6 + + '@types/istanbul-reports@3.0.4': + dependencies: + '@types/istanbul-lib-report': 3.0.3 + + '@types/node-fetch@2.6.13': + dependencies: + '@types/node': 22.19.17 + form-data: 4.0.5 + + '@types/node@18.19.130': + dependencies: + undici-types: 5.26.5 + + '@types/node@22.19.17': + dependencies: + undici-types: 6.21.0 + + '@types/pg@8.20.0': + dependencies: + '@types/node': 22.19.17 + pg-protocol: 1.13.0 + pg-types: 2.2.0 + + '@types/react@19.0.14': + dependencies: + csstype: 3.2.3 + + '@types/resolve@1.20.2': {} + + '@types/stack-utils@2.0.3': {} + + '@types/ws@8.18.1': + dependencies: + '@types/node': 22.19.17 + + '@types/yargs-parser@21.0.3': {} + + '@types/yargs@17.0.35': + dependencies: + '@types/yargs-parser': 21.0.3 + + '@urql/core@5.2.0': + dependencies: + '@0no-co/graphql.web': 1.2.0 + wonka: 6.3.6 + transitivePeerDependencies: + - graphql + + '@urql/exchange-retry@1.3.2(@urql/core@5.2.0)': + dependencies: + '@urql/core': 5.2.0 + wonka: 6.3.6 + + '@vercel/nft@1.5.0(rollup@4.60.3)': + dependencies: + '@mapbox/node-pre-gyp': 2.0.3 + '@rollup/pluginutils': 5.3.0(rollup@4.60.3) + acorn: 8.16.0 + acorn-import-attributes: 1.9.5(acorn@8.16.0) + async-sema: 3.1.1 + bindings: 1.5.0 + estree-walker: 2.0.2 + glob: 13.0.6 + graceful-fs: 4.2.11 + node-gyp-build: 4.8.4 + picomatch: 4.0.4 + resolve-from: 5.0.0 + transitivePeerDependencies: + - encoding + - rollup + - supports-color + + '@xmldom/xmldom@0.8.13': {} + + '@xmldom/xmldom@0.9.10': {} + + '@zone-eu/mailsplit@5.4.9': + dependencies: + libbase64: 1.3.0 + libmime: 5.3.8 + libqp: 2.1.1 + + abbrev@3.0.1: {} + + abort-controller@3.0.0: + dependencies: + event-target-shim: 5.0.1 + + accepts@1.3.8: + dependencies: + mime-types: 2.1.35 + negotiator: 0.6.3 + + acorn-import-attributes@1.9.5(acorn@8.16.0): + dependencies: + acorn: 8.16.0 + + acorn@8.16.0: {} + + agent-base@7.1.4: {} + + agentkeepalive@4.6.0: + dependencies: + humanize-ms: 1.2.1 + + ajv@8.11.0: + dependencies: + fast-deep-equal: 3.1.3 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + uri-js: 4.4.1 + + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.2 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + anser@1.4.10: {} + + ansi-escapes@4.3.2: + dependencies: + type-fest: 0.21.3 + + ansi-regex@4.1.1: {} + + ansi-regex@5.0.1: {} + + ansi-regex@6.2.2: {} + + ansi-styles@3.2.1: + dependencies: + color-convert: 1.9.3 + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@5.2.0: {} + + ansi-styles@6.2.3: {} + + any-promise@1.3.0: {} + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.2 + + archiver-utils@5.0.2: + dependencies: + glob: 10.5.0 + graceful-fs: 4.2.11 + is-stream: 2.0.1 + lazystream: 1.0.1 + lodash: 4.18.1 + normalize-path: 3.0.0 + readable-stream: 4.7.0 + + archiver@7.0.1: + dependencies: + archiver-utils: 5.0.2 + async: 3.2.6 + buffer-crc32: 1.0.0 + readable-stream: 4.7.0 + readdir-glob: 1.1.3 + tar-stream: 3.2.0 + zip-stream: 6.0.1 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + + arg@5.0.2: {} + + argparse@1.0.10: + dependencies: + sprintf-js: 1.0.3 + + argparse@2.0.1: {} + + array-timsort@1.0.3: {} + + asap@2.0.6: {} + + assert@2.1.0: + dependencies: + call-bind: 1.0.9 + is-nan: 1.3.2 + object-is: 1.1.6 + object.assign: 4.1.7 + util: 0.12.5 + + async-limiter@1.0.1: {} + + async-sema@3.1.1: {} + + async@3.2.6: {} + + asynckit@0.4.0: {} + + atomic-sleep@1.0.0: {} + + available-typed-arrays@1.0.7: + dependencies: + possible-typed-array-names: 1.1.0 + + aws-ssl-profiles@1.1.2: {} + + b4a@1.8.1: {} + + babel-jest@29.7.0(@babel/core@7.29.0): + dependencies: + '@babel/core': 7.29.0 + '@jest/transform': 29.7.0 + '@types/babel__core': 7.20.5 + babel-plugin-istanbul: 6.1.1 + babel-preset-jest: 29.6.3(@babel/core@7.29.0) + chalk: 4.1.2 + graceful-fs: 4.2.11 + slash: 3.0.0 + transitivePeerDependencies: + - supports-color + + babel-plugin-istanbul@6.1.1: + dependencies: + '@babel/helper-plugin-utils': 7.28.6 + '@istanbuljs/load-nyc-config': 1.1.0 + '@istanbuljs/schema': 0.1.6 + istanbul-lib-instrument: 5.2.1 + test-exclude: 6.0.0 + transitivePeerDependencies: + - supports-color + + babel-plugin-jest-hoist@29.6.3: + dependencies: + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + '@types/babel__core': 7.20.5 + '@types/babel__traverse': 7.28.0 + + babel-plugin-polyfill-corejs2@0.4.17(@babel/core@7.29.0): + dependencies: + '@babel/compat-data': 7.29.3 + '@babel/core': 7.29.0 + '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.0) + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + babel-plugin-polyfill-corejs3@0.13.0(@babel/core@7.29.0): + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.0) + core-js-compat: 3.49.0 + transitivePeerDependencies: + - supports-color + + babel-plugin-polyfill-regenerator@0.6.8(@babel/core@7.29.0): + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.0) + transitivePeerDependencies: + - supports-color + + babel-plugin-react-native-web@0.19.13: {} + + babel-plugin-syntax-hermes-parser@0.25.1: + dependencies: + hermes-parser: 0.25.1 + + babel-plugin-transform-flow-enums@0.0.2(@babel/core@7.29.0): + dependencies: + '@babel/plugin-syntax-flow': 7.28.6(@babel/core@7.29.0) + transitivePeerDependencies: + - '@babel/core' + + babel-preset-current-node-syntax@1.2.0(@babel/core@7.29.0): + dependencies: + '@babel/core': 7.29.0 + '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.29.0) + '@babel/plugin-syntax-bigint': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.29.0) + '@babel/plugin-syntax-class-static-block': 7.14.5(@babel/core@7.29.0) + '@babel/plugin-syntax-import-attributes': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.29.0) + '@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.29.0) + '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.29.0) + '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.29.0) + '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.29.0) + + babel-preset-expo@13.2.5(@babel/core@7.29.0): + dependencies: + '@babel/helper-module-imports': 7.28.6 + '@babel/plugin-proposal-decorators': 7.29.0(@babel/core@7.29.0) + '@babel/plugin-proposal-export-default-from': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-syntax-export-default-from': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-export-namespace-from': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-flow-strip-types': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-object-rest-spread': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-parameters': 7.27.7(@babel/core@7.29.0) + '@babel/plugin-transform-private-methods': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-private-property-in-object': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-runtime': 7.29.0(@babel/core@7.29.0) + '@babel/preset-react': 7.28.5(@babel/core@7.29.0) + '@babel/preset-typescript': 7.28.5(@babel/core@7.29.0) + '@react-native/babel-preset': 0.79.6(@babel/core@7.29.0) + babel-plugin-react-native-web: 0.19.13 + babel-plugin-syntax-hermes-parser: 0.25.1 + babel-plugin-transform-flow-enums: 0.0.2(@babel/core@7.29.0) + debug: 4.4.3 + react-refresh: 0.14.2 + resolve-from: 5.0.0 + transitivePeerDependencies: + - '@babel/core' + - supports-color + + babel-preset-jest@29.6.3(@babel/core@7.29.0): + dependencies: + '@babel/core': 7.29.0 + babel-plugin-jest-hoist: 29.6.3 + babel-preset-current-node-syntax: 1.2.0(@babel/core@7.29.0) + + badgin@1.2.3: {} + + balanced-match@1.0.2: {} + + balanced-match@4.0.4: {} + + bare-events@2.8.2: {} + + bare-fs@4.7.1: + dependencies: + bare-events: 2.8.2 + bare-path: 3.0.0 + bare-stream: 2.13.1(bare-events@2.8.2) + bare-url: 2.4.3 + fast-fifo: 1.3.2 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + bare-os@3.9.1: {} + + bare-path@3.0.0: + dependencies: + bare-os: 3.9.1 + + bare-stream@2.13.1(bare-events@2.8.2): + dependencies: + streamx: 2.25.0 + teex: 1.0.1 + optionalDependencies: + bare-events: 2.8.2 + transitivePeerDependencies: + - react-native-b4a + + bare-url@2.4.3: + dependencies: + bare-path: 3.0.0 + + base64-js@1.5.1: {} + + baseline-browser-mapping@2.10.27: {} + + better-opn@3.0.2: + dependencies: + open: 8.4.2 + + better-result@2.9.2: {} + + big-integer@1.6.52: {} + + binary-extensions@2.3.0: {} + + bindings@1.5.0: + dependencies: + file-uri-to-path: 1.0.0 + + boolbase@1.0.0: {} + + bplist-creator@0.1.0: + dependencies: + stream-buffers: 2.2.0 + + bplist-parser@0.3.1: + dependencies: + big-integer: 1.6.52 + + bplist-parser@0.3.2: + dependencies: + big-integer: 1.6.52 + + brace-expansion@1.1.14: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@2.1.0: + dependencies: + balanced-match: 1.0.2 + + brace-expansion@5.0.5: + dependencies: + balanced-match: 4.0.4 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browserslist@4.28.2: + dependencies: + baseline-browser-mapping: 2.10.27 + caniuse-lite: 1.0.30001791 + electron-to-chromium: 1.5.351 + node-releases: 2.0.38 + update-browserslist-db: 1.2.3(browserslist@4.28.2) + + bser@2.1.1: + dependencies: + node-int64: 0.4.0 + + buffer-crc32@1.0.0: {} + + buffer-from@1.1.2: {} + + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + buffer@6.0.3: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + bundle-name@4.1.0: + dependencies: + run-applescript: 7.1.0 + + bytes@3.1.2: {} + + c12@3.3.4(magicast@0.5.2): + dependencies: + chokidar: 5.0.0 + confbox: 0.2.4 + defu: 6.1.7 + dotenv: 17.4.2 + exsolve: 1.0.8 + giget: 3.2.0 + jiti: 2.7.0 + ohash: 2.0.11 + pathe: 2.0.3 + perfect-debounce: 2.1.0 + pkg-types: 2.3.1 + rc9: 3.0.1 + optionalDependencies: + magicast: 0.5.2 + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bind@1.0.9: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + get-intrinsic: 1.3.0 + set-function-length: 1.2.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + caller-callsite@2.0.0: + dependencies: + callsites: 2.0.0 + + caller-path@2.0.0: + dependencies: + caller-callsite: 2.0.0 + + callsites@2.0.0: {} + + camelcase-css@2.0.1: {} + + camelcase@5.3.1: {} + + camelcase@6.3.0: {} + + caniuse-lite@1.0.30001791: {} + + chalk@2.4.2: + dependencies: + ansi-styles: 3.2.1 + escape-string-regexp: 1.0.5 + supports-color: 5.5.0 + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + chart.js@4.5.1: + dependencies: + '@kurkle/color': 0.3.4 + + chokidar@3.6.0: + dependencies: + anymatch: 3.1.3 + braces: 3.0.3 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + + chokidar@5.0.0: + dependencies: + readdirp: 5.0.0 + + chownr@3.0.0: {} + + chrome-launcher@0.15.2: + dependencies: + '@types/node': 22.19.17 + escape-string-regexp: 4.0.0 + is-wsl: 2.2.0 + lighthouse-logger: 1.4.2 + transitivePeerDependencies: + - supports-color + + chromium-edge-launcher@0.2.0: + dependencies: + '@types/node': 22.19.17 + escape-string-regexp: 4.0.0 + is-wsl: 2.2.0 + lighthouse-logger: 1.4.2 + mkdirp: 1.0.4 + rimraf: 3.0.2 + transitivePeerDependencies: + - supports-color + + ci-info@2.0.0: {} + + ci-info@3.9.0: {} + + citty@0.1.6: + dependencies: + consola: 3.4.2 + + citty@0.2.2: {} + + cli-cursor@2.1.0: + dependencies: + restore-cursor: 2.0.0 + + cli-spinners@2.9.2: {} + + client-only@0.0.1: {} + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + cliui@9.0.1: + dependencies: + string-width: 7.2.0 + strip-ansi: 7.2.0 + wrap-ansi: 9.0.2 + + clone@1.0.4: {} + + cluster-key-slot@1.1.2: {} + + color-convert@1.9.3: + dependencies: + color-name: 1.1.3 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.3: {} + + color-name@1.1.4: {} + + color-string@1.9.1: + dependencies: + color-name: 1.1.4 + simple-swizzle: 0.2.4 + + color@4.2.3: + dependencies: + color-convert: 2.0.1 + color-string: 1.9.1 + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + commander@12.1.0: {} + + commander@2.20.3: {} + + commander@4.1.1: {} + + commander@7.2.0: {} + + comment-json@4.6.2: + dependencies: + array-timsort: 1.0.3 + esprima: 4.0.1 + + commondir@1.0.1: {} + + compatx@0.2.0: {} + + compress-commons@6.0.2: + dependencies: + crc-32: 1.2.2 + crc32-stream: 6.0.0 + is-stream: 2.0.1 + normalize-path: 3.0.0 + readable-stream: 4.7.0 + + compressible@2.0.18: + dependencies: + mime-db: 1.54.0 + + compression@1.8.1: + dependencies: + bytes: 3.1.2 + compressible: 2.0.18 + debug: 2.6.9 + negotiator: 0.6.4 + on-headers: 1.1.0 + safe-buffer: 5.2.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + concat-map@0.0.1: {} + + confbox@0.1.8: {} + + confbox@0.2.4: {} + + connect@3.7.0: + dependencies: + debug: 2.6.9 + finalhandler: 1.1.2 + parseurl: 1.3.3 + utils-merge: 1.0.1 + transitivePeerDependencies: + - supports-color + + consola@3.4.2: {} + + convert-source-map@2.0.0: {} + + cookie-es@1.2.3: {} + + cookie-es@2.0.1: {} + + cookie-es@3.1.1: {} + + core-js-compat@3.49.0: + dependencies: + browserslist: 4.28.2 + + core-util-is@1.0.3: {} + + cosmiconfig@5.2.1: + dependencies: + import-fresh: 2.0.0 + is-directory: 0.3.1 + js-yaml: 3.14.2 + parse-json: 4.0.0 + + crc-32@1.2.2: {} + + crc32-stream@6.0.0: + dependencies: + crc-32: 1.2.2 + readable-stream: 4.7.0 + + croner@10.0.1: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + crossws@0.3.5: + dependencies: + uncrypto: 0.1.3 + + crypto-random-string@2.0.0: {} + + css-select@5.2.2: + dependencies: + boolbase: 1.0.0 + css-what: 6.2.2 + domhandler: 5.0.3 + domutils: 3.2.2 + nth-check: 2.1.1 + + css-tree@1.1.3: + dependencies: + mdn-data: 2.0.14 + source-map: 0.6.1 + + css-what@6.2.2: {} + + cssesc@3.0.0: {} + + csstype@3.2.3: {} + + db0@0.3.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3): + optionalDependencies: + '@electric-sql/pglite': 0.4.1 + mysql2: 3.15.3 + + debug@2.6.9: + dependencies: + ms: 2.0.0 + + debug@3.2.7: + dependencies: + ms: 2.1.3 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + decode-uri-component@0.2.2: {} + + deep-extend@0.6.0: {} + + deepmerge-ts@7.1.5: {} + + deepmerge@4.3.1: {} + + default-browser-id@5.0.1: {} + + default-browser@5.5.0: + dependencies: + bundle-name: 4.1.0 + default-browser-id: 5.0.1 + + defaults@1.0.4: + dependencies: + clone: 1.0.4 + + define-data-property@1.1.4: + dependencies: + es-define-property: 1.0.1 + es-errors: 1.3.0 + gopd: 1.2.0 + + define-lazy-prop@2.0.0: {} + + define-lazy-prop@3.0.0: {} + + define-properties@1.2.1: + dependencies: + define-data-property: 1.1.4 + has-property-descriptors: 1.0.2 + object-keys: 1.1.1 + + defu@6.1.7: {} + + delayed-stream@1.0.0: {} + + denque@2.1.0: {} + + depd@2.0.0: {} + + destr@2.0.5: {} + + destroy@1.2.0: {} + + detect-libc@1.0.3: {} + + detect-libc@2.1.2: {} + + didyoumean@1.2.2: {} + + dlv@1.1.3: {} + + dom-serializer@2.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + + domelementtype@2.3.0: {} + + domhandler@5.0.3: + dependencies: + domelementtype: 2.3.0 + + domutils@3.2.2: + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + + dot-prop@10.1.0: + dependencies: + type-fest: 5.6.0 + + dotenv-expand@11.0.7: + dependencies: + dotenv: 16.4.7 + + dotenv@16.4.7: {} + + dotenv@17.4.2: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + duplexer@0.1.2: {} + + eastasianwidth@0.2.0: {} + + ee-first@1.1.1: {} + + effect@3.20.0: + dependencies: + '@standard-schema/spec': 1.1.0 + fast-check: 3.23.2 + + electron-to-chromium@1.5.351: {} + + emoji-regex@10.6.0: {} + + emoji-regex@8.0.0: {} + + emoji-regex@9.2.2: {} + + empathic@2.0.0: {} + + encodeurl@1.0.2: {} + + encodeurl@2.0.0: {} + + encoding-japanese@2.2.0: {} + + entities@4.5.0: {} + + env-editor@0.4.2: {} + + env-paths@3.0.0: {} + + error-ex@1.3.4: + dependencies: + is-arrayish: 0.2.1 + + error-stack-parser-es@1.0.5: {} + + error-stack-parser@2.1.4: + dependencies: + stackframe: 1.3.4 + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.3 + + esbuild@0.28.0: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.0 + '@esbuild/android-arm': 0.28.0 + '@esbuild/android-arm64': 0.28.0 + '@esbuild/android-x64': 0.28.0 + '@esbuild/darwin-arm64': 0.28.0 + '@esbuild/darwin-x64': 0.28.0 + '@esbuild/freebsd-arm64': 0.28.0 + '@esbuild/freebsd-x64': 0.28.0 + '@esbuild/linux-arm': 0.28.0 + '@esbuild/linux-arm64': 0.28.0 + '@esbuild/linux-ia32': 0.28.0 + '@esbuild/linux-loong64': 0.28.0 + '@esbuild/linux-mips64el': 0.28.0 + '@esbuild/linux-ppc64': 0.28.0 + '@esbuild/linux-riscv64': 0.28.0 + '@esbuild/linux-s390x': 0.28.0 + '@esbuild/linux-x64': 0.28.0 + '@esbuild/netbsd-arm64': 0.28.0 + '@esbuild/netbsd-x64': 0.28.0 + '@esbuild/openbsd-arm64': 0.28.0 + '@esbuild/openbsd-x64': 0.28.0 + '@esbuild/openharmony-arm64': 0.28.0 + '@esbuild/sunos-x64': 0.28.0 + '@esbuild/win32-arm64': 0.28.0 + '@esbuild/win32-ia32': 0.28.0 + '@esbuild/win32-x64': 0.28.0 + + escalade@3.2.0: {} + + escape-html@1.0.3: {} + + escape-string-regexp@1.0.5: {} + + escape-string-regexp@2.0.0: {} + + escape-string-regexp@4.0.0: {} + + escape-string-regexp@5.0.0: {} + + esprima@4.0.1: {} + + estree-walker@2.0.2: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + + etag@1.8.1: {} + + event-target-shim@5.0.1: {} + + events-universal@1.0.1: + dependencies: + bare-events: 2.8.2 + transitivePeerDependencies: + - bare-abort-controller + + events@3.3.0: {} + + expo-apple-authentication@7.2.4(expo@53.0.27(@babel/core@7.29.0)(@expo/metro-runtime@5.0.5(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0)))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0)): + dependencies: + expo: 53.0.27(@babel/core@7.29.0)(@expo/metro-runtime@5.0.5(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0)))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0) + react-native: 0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0) + + expo-application@6.1.5(expo@53.0.27(@babel/core@7.29.0)(@expo/metro-runtime@5.0.5(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0)))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)): + dependencies: + expo: 53.0.27(@babel/core@7.29.0)(@expo/metro-runtime@5.0.5(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0)))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0) + + expo-asset@11.1.7(expo@53.0.27(@babel/core@7.29.0)(@expo/metro-runtime@5.0.5(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0)))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0): + dependencies: + '@expo/image-utils': 0.7.6 + expo: 53.0.27(@babel/core@7.29.0)(@expo/metro-runtime@5.0.5(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0)))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0) + expo-constants: 17.1.8(expo@53.0.27(@babel/core@7.29.0)(@expo/metro-runtime@5.0.5(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0)))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0)) + react: 19.0.0 + react-native: 0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0) + transitivePeerDependencies: + - supports-color + + expo-av@15.1.7(expo@53.0.27(@babel/core@7.29.0)(@expo/metro-runtime@5.0.5(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0)))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0): + dependencies: + expo: 53.0.27(@babel/core@7.29.0)(@expo/metro-runtime@5.0.5(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0)))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0) + react: 19.0.0 + react-native: 0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0) + + expo-build-properties@0.14.8(expo@53.0.27(@babel/core@7.29.0)(@expo/metro-runtime@5.0.5(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0)))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)): + dependencies: + ajv: 8.20.0 + expo: 53.0.27(@babel/core@7.29.0)(@expo/metro-runtime@5.0.5(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0)))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0) + semver: 7.7.4 + + expo-clipboard@55.0.13(expo@53.0.27(@babel/core@7.29.0)(@expo/metro-runtime@5.0.5(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0)))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0): + dependencies: + expo: 53.0.27(@babel/core@7.29.0)(@expo/metro-runtime@5.0.5(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0)))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0) + react: 19.0.0 + react-native: 0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0) + + expo-constants@17.1.8(expo@53.0.27(@babel/core@7.29.0)(@expo/metro-runtime@5.0.5(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0)))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0)): + dependencies: + '@expo/config': 11.0.13 + '@expo/env': 1.0.7 + expo: 53.0.27(@babel/core@7.29.0)(@expo/metro-runtime@5.0.5(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0)))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0) + react-native: 0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0) + transitivePeerDependencies: + - supports-color + + expo-dev-client@5.2.4(expo@53.0.27(@babel/core@7.29.0)(@expo/metro-runtime@5.0.5(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0)))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)): + dependencies: + expo: 53.0.27(@babel/core@7.29.0)(@expo/metro-runtime@5.0.5(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0)))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0) + expo-dev-launcher: 5.1.16(expo@53.0.27(@babel/core@7.29.0)(@expo/metro-runtime@5.0.5(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0)))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)) + expo-dev-menu: 6.1.14(expo@53.0.27(@babel/core@7.29.0)(@expo/metro-runtime@5.0.5(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0)))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)) + expo-dev-menu-interface: 1.10.0(expo@53.0.27(@babel/core@7.29.0)(@expo/metro-runtime@5.0.5(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0)))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)) + expo-manifests: 0.16.6(expo@53.0.27(@babel/core@7.29.0)(@expo/metro-runtime@5.0.5(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0)))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)) + expo-updates-interface: 1.1.0(expo@53.0.27(@babel/core@7.29.0)(@expo/metro-runtime@5.0.5(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0)))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)) + transitivePeerDependencies: + - supports-color + + expo-dev-launcher@5.1.16(expo@53.0.27(@babel/core@7.29.0)(@expo/metro-runtime@5.0.5(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0)))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)): + dependencies: + ajv: 8.11.0 + expo: 53.0.27(@babel/core@7.29.0)(@expo/metro-runtime@5.0.5(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0)))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0) + expo-dev-menu: 6.1.14(expo@53.0.27(@babel/core@7.29.0)(@expo/metro-runtime@5.0.5(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0)))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)) + expo-manifests: 0.16.6(expo@53.0.27(@babel/core@7.29.0)(@expo/metro-runtime@5.0.5(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0)))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)) + resolve-from: 5.0.0 + transitivePeerDependencies: + - supports-color + + expo-dev-menu-interface@1.10.0(expo@53.0.27(@babel/core@7.29.0)(@expo/metro-runtime@5.0.5(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0)))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)): + dependencies: + expo: 53.0.27(@babel/core@7.29.0)(@expo/metro-runtime@5.0.5(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0)))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0) + + expo-dev-menu@6.1.14(expo@53.0.27(@babel/core@7.29.0)(@expo/metro-runtime@5.0.5(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0)))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)): + dependencies: + expo: 53.0.27(@babel/core@7.29.0)(@expo/metro-runtime@5.0.5(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0)))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0) + expo-dev-menu-interface: 1.10.0(expo@53.0.27(@babel/core@7.29.0)(@expo/metro-runtime@5.0.5(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0)))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)) + + expo-file-system@18.1.11(expo@53.0.27(@babel/core@7.29.0)(@expo/metro-runtime@5.0.5(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0)))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0)): + dependencies: + expo: 53.0.27(@babel/core@7.29.0)(@expo/metro-runtime@5.0.5(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0)))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0) + react-native: 0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0) + + expo-font@13.0.4(expo@53.0.27(@babel/core@7.29.0)(@expo/metro-runtime@5.0.5(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0)))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react@19.0.0): + dependencies: + expo: 53.0.27(@babel/core@7.29.0)(@expo/metro-runtime@5.0.5(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0)))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0) + fontfaceobserver: 2.3.0 + react: 19.0.0 + + expo-font@13.3.2(expo@53.0.27(@babel/core@7.29.0)(@expo/metro-runtime@5.0.5(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0)))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react@19.0.0): + dependencies: + expo: 53.0.27(@babel/core@7.29.0)(@expo/metro-runtime@5.0.5(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0)))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0) + fontfaceobserver: 2.3.0 + react: 19.0.0 + + expo-haptics@55.0.14(expo@53.0.27(@babel/core@7.29.0)(@expo/metro-runtime@5.0.5(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0)))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)): + dependencies: + expo: 53.0.27(@babel/core@7.29.0)(@expo/metro-runtime@5.0.5(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0)))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0) + + expo-image-loader@5.1.0(expo@53.0.27(@babel/core@7.29.0)(@expo/metro-runtime@5.0.5(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0)))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)): + dependencies: + expo: 53.0.27(@babel/core@7.29.0)(@expo/metro-runtime@5.0.5(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0)))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0) + + expo-image-picker@16.1.4(expo@53.0.27(@babel/core@7.29.0)(@expo/metro-runtime@5.0.5(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0)))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)): + dependencies: + expo: 53.0.27(@babel/core@7.29.0)(@expo/metro-runtime@5.0.5(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0)))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0) + expo-image-loader: 5.1.0(expo@53.0.27(@babel/core@7.29.0)(@expo/metro-runtime@5.0.5(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0)))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)) + + expo-json-utils@0.15.0: {} + + expo-keep-awake@14.1.4(expo@53.0.27(@babel/core@7.29.0)(@expo/metro-runtime@5.0.5(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0)))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react@19.0.0): + dependencies: + expo: 53.0.27(@babel/core@7.29.0)(@expo/metro-runtime@5.0.5(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0)))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0) + react: 19.0.0 + + expo-linking@7.1.7(expo@53.0.27(@babel/core@7.29.0)(@expo/metro-runtime@5.0.5(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0)))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0): + dependencies: + expo-constants: 17.1.8(expo@53.0.27(@babel/core@7.29.0)(@expo/metro-runtime@5.0.5(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0)))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0)) + invariant: 2.2.4 + react: 19.0.0 + react-native: 0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0) + transitivePeerDependencies: + - expo + - supports-color + + expo-localization@16.1.6(expo@53.0.27(@babel/core@7.29.0)(@expo/metro-runtime@5.0.5(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0)))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react@19.0.0): + dependencies: + expo: 53.0.27(@babel/core@7.29.0)(@expo/metro-runtime@5.0.5(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0)))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0) + react: 19.0.0 + rtl-detect: 1.1.2 + + expo-manifests@0.16.6(expo@53.0.27(@babel/core@7.29.0)(@expo/metro-runtime@5.0.5(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0)))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)): + dependencies: + '@expo/config': 11.0.13 + expo: 53.0.27(@babel/core@7.29.0)(@expo/metro-runtime@5.0.5(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0)))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0) + expo-json-utils: 0.15.0 + transitivePeerDependencies: + - supports-color + + expo-modules-autolinking@2.1.15: + dependencies: + '@expo/spawn-async': 1.7.2 + chalk: 4.1.2 + commander: 7.2.0 + find-up: 5.0.0 + glob: 10.5.0 + require-from-string: 2.0.2 + resolve-from: 5.0.0 + + expo-modules-core@2.5.0: + dependencies: + invariant: 2.2.4 + + expo-notifications@0.31.5(expo@53.0.27(@babel/core@7.29.0)(@expo/metro-runtime@5.0.5(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0)))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0): + dependencies: + '@expo/image-utils': 0.7.6 + '@ide/backoff': 1.0.0 + abort-controller: 3.0.0 + assert: 2.1.0 + badgin: 1.2.3 + expo: 53.0.27(@babel/core@7.29.0)(@expo/metro-runtime@5.0.5(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0)))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0) + expo-application: 6.1.5(expo@53.0.27(@babel/core@7.29.0)(@expo/metro-runtime@5.0.5(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0)))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)) + expo-constants: 17.1.8(expo@53.0.27(@babel/core@7.29.0)(@expo/metro-runtime@5.0.5(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0)))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0)) + react: 19.0.0 + react-native: 0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0) + transitivePeerDependencies: + - supports-color + + expo-router@5.1.11(15de8a0d2b6e197a248b53123aa45ca3): + dependencies: + '@expo/metro-runtime': 5.0.5(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0)) + '@expo/schema-utils': 0.1.8 + '@expo/server': 0.6.3 + '@radix-ui/react-slot': 1.2.0(@types/react@19.0.14)(react@19.0.0) + '@react-navigation/bottom-tabs': 7.15.11(@react-navigation/native@7.2.2(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native-safe-area-context@5.4.0(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native-screens@4.11.1(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0) + '@react-navigation/native': 7.2.2(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0) + '@react-navigation/native-stack': 7.14.12(@react-navigation/native@7.2.2(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native-safe-area-context@5.4.0(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native-screens@4.11.1(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0) + client-only: 0.0.1 + expo: 53.0.27(@babel/core@7.29.0)(@expo/metro-runtime@5.0.5(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0)))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0) + expo-constants: 17.1.8(expo@53.0.27(@babel/core@7.29.0)(@expo/metro-runtime@5.0.5(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0)))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0)) + expo-linking: 7.1.7(expo@53.0.27(@babel/core@7.29.0)(@expo/metro-runtime@5.0.5(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0)))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0) + invariant: 2.2.4 + react-fast-compare: 3.2.2 + react-native-is-edge-to-edge: 1.3.1(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0) + react-native-safe-area-context: 5.4.0(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0) + react-native-screens: 4.11.1(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0) + semver: 7.6.3 + server-only: 0.0.1 + shallowequal: 1.1.0 + optionalDependencies: + react-native-reanimated: 4.0.3(@babel/core@7.29.0)(react-native-worklets@0.4.2(@babel/core@7.29.0)(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0) + transitivePeerDependencies: + - '@react-native-masked-view/masked-view' + - '@types/react' + - react + - react-native + - supports-color + + expo-speech@13.1.7(expo@53.0.27(@babel/core@7.29.0)(@expo/metro-runtime@5.0.5(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0)))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)): + dependencies: + expo: 53.0.27(@babel/core@7.29.0)(@expo/metro-runtime@5.0.5(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0)))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0) + + expo-splash-screen@0.30.10(expo@53.0.27(@babel/core@7.29.0)(@expo/metro-runtime@5.0.5(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0)))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)): + dependencies: + '@expo/prebuild-config': 9.0.12 + expo: 53.0.27(@babel/core@7.29.0)(@expo/metro-runtime@5.0.5(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0)))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0) + transitivePeerDependencies: + - supports-color + + expo-status-bar@2.2.3(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0): + dependencies: + react: 19.0.0 + react-native: 0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0) + react-native-edge-to-edge: 1.6.0(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0) + react-native-is-edge-to-edge: 1.3.1(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0) + + expo-updates-interface@1.1.0(expo@53.0.27(@babel/core@7.29.0)(@expo/metro-runtime@5.0.5(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0)))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)): + dependencies: + expo: 53.0.27(@babel/core@7.29.0)(@expo/metro-runtime@5.0.5(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0)))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0) + + expo-web-browser@14.2.0(expo@53.0.27(@babel/core@7.29.0)(@expo/metro-runtime@5.0.5(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0)))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0)): + dependencies: + expo: 53.0.27(@babel/core@7.29.0)(@expo/metro-runtime@5.0.5(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0)))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0) + react-native: 0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0) + + expo@53.0.27(@babel/core@7.29.0)(@expo/metro-runtime@5.0.5(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0)))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0): + dependencies: + '@babel/runtime': 7.29.2 + '@expo/cli': 0.24.24 + '@expo/config': 11.0.13 + '@expo/config-plugins': 10.1.2 + '@expo/fingerprint': 0.13.4 + '@expo/metro-config': 0.20.18 + '@expo/vector-icons': 14.1.0(expo-font@13.3.2(expo@53.0.27(@babel/core@7.29.0)(@expo/metro-runtime@5.0.5(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0)))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react@19.0.0))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0) + babel-preset-expo: 13.2.5(@babel/core@7.29.0) + expo-asset: 11.1.7(expo@53.0.27(@babel/core@7.29.0)(@expo/metro-runtime@5.0.5(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0)))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0) + expo-constants: 17.1.8(expo@53.0.27(@babel/core@7.29.0)(@expo/metro-runtime@5.0.5(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0)))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0)) + expo-file-system: 18.1.11(expo@53.0.27(@babel/core@7.29.0)(@expo/metro-runtime@5.0.5(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0)))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0)) + expo-font: 13.3.2(expo@53.0.27(@babel/core@7.29.0)(@expo/metro-runtime@5.0.5(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0)))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react@19.0.0) + expo-keep-awake: 14.1.4(expo@53.0.27(@babel/core@7.29.0)(@expo/metro-runtime@5.0.5(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0)))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react@19.0.0) + expo-modules-autolinking: 2.1.15 + expo-modules-core: 2.5.0 + react: 19.0.0 + react-native: 0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0) + react-native-edge-to-edge: 1.6.0(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0) + whatwg-url-without-unicode: 8.0.0-3 + optionalDependencies: + '@expo/metro-runtime': 5.0.5(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0)) + transitivePeerDependencies: + - '@babel/core' + - babel-plugin-react-compiler + - bufferutil + - graphql + - supports-color + - utf-8-validate + + exponential-backoff@3.1.3: {} + + exsolve@1.0.8: {} + + fast-check@3.23.2: + dependencies: + pure-rand: 6.1.0 + + fast-deep-equal@2.0.1: {} + + fast-deep-equal@3.1.3: {} + + fast-fifo@1.3.2: {} + + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fast-json-stable-stringify@2.1.0: {} + + fast-uri@3.1.2: {} + + fastq@1.20.1: + dependencies: + reusify: 1.1.0 + + fb-watchman@2.0.2: + dependencies: + bser: 2.1.1 + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + file-uri-to-path@1.0.0: {} + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + filter-obj@1.1.0: {} + + finalhandler@1.1.2: + dependencies: + debug: 2.6.9 + encodeurl: 1.0.2 + escape-html: 1.0.3 + on-finished: 2.3.0 + parseurl: 1.3.3 + statuses: 1.5.0 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flow-enums-runtime@0.0.6: {} + + fontfaceobserver@2.3.0: {} + + for-each@0.3.5: + dependencies: + is-callable: 1.2.7 + + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + + form-data-encoder@1.7.2: {} + + form-data@4.0.5: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.3 + mime-types: 2.1.35 + + formdata-node@4.4.1: + dependencies: + node-domexception: 1.0.0 + web-streams-polyfill: 4.0.0-beta.3 + + freeport-async@2.0.0: {} + + fresh@0.5.2: {} + + fresh@2.0.0: {} + + fs.realpath@1.0.0: {} + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + generate-function@2.3.1: + dependencies: + is-property: 1.0.2 + + generator-function@2.0.1: {} + + gensync@1.0.0-beta.2: {} + + get-caller-file@2.0.5: {} + + get-east-asian-width@1.5.0: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.3 + math-intrinsics: 1.1.0 + + get-package-type@0.1.0: {} + + get-port-please@3.2.0: {} + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + getenv@2.0.0: {} + + giget@3.2.0: {} + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + glob@10.5.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.9 + minipass: 7.1.3 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + + glob@13.0.6: + dependencies: + minimatch: 10.2.5 + minipass: 7.1.3 + path-scurry: 2.0.2 + + glob@7.2.3: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.5 + once: 1.4.0 + path-is-absolute: 1.0.1 + + globby@16.2.0: + dependencies: + '@sindresorhus/merge-streams': 4.0.0 + fast-glob: 3.3.3 + ignore: 7.0.5 + is-path-inside: 4.0.0 + slash: 5.1.0 + unicorn-magic: 0.4.0 + + gopd@1.2.0: {} + + graceful-fs@4.2.11: {} + + grammex@3.1.12: {} + + graphmatch@1.1.1: {} + + groq-sdk@0.7.0: + dependencies: + '@types/node': 18.19.130 + '@types/node-fetch': 2.6.13 + abort-controller: 3.0.0 + agentkeepalive: 4.6.0 + form-data-encoder: 1.7.2 + formdata-node: 4.4.1 + node-fetch: 2.7.0 + transitivePeerDependencies: + - encoding + + gzip-size@7.0.0: + dependencies: + duplexer: 0.1.2 + + h3@1.15.11: + dependencies: + cookie-es: 1.2.3 + crossws: 0.3.5 + defu: 6.1.7 + destr: 2.0.5 + iron-webcrypto: 1.2.1 + node-mock-http: 1.0.4 + radix3: 1.1.2 + ufo: 1.6.4 + uncrypto: 0.1.3 + + has-flag@3.0.0: {} + + has-flag@4.0.0: {} + + has-property-descriptors@1.0.2: + dependencies: + es-define-property: 1.0.1 + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.3: + dependencies: + function-bind: 1.1.2 + + hermes-estree@0.25.1: {} + + hermes-estree@0.29.1: {} + + hermes-parser@0.25.1: + dependencies: + hermes-estree: 0.25.1 + + hermes-parser@0.29.1: + dependencies: + hermes-estree: 0.29.1 + + hoist-non-react-statics@3.3.2: + dependencies: + react-is: 16.13.1 + + hono@4.12.17: {} + + hookable@5.5.3: {} + + hosted-git-info@7.0.2: + dependencies: + lru-cache: 10.4.3 + + html-parse-stringify@3.0.1: + dependencies: + void-elements: 3.1.0 + + html-to-text@9.0.5: + dependencies: + '@selderee/plugin-htmlparser2': 0.11.0 + deepmerge: 4.3.1 + dom-serializer: 2.0.0 + htmlparser2: 8.0.2 + selderee: 0.11.0 + + htmlparser2@8.0.2: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.2.2 + entities: 4.5.0 + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + http-shutdown@1.2.2: {} + + http-status-codes@2.3.0: {} + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + httpxy@0.5.1: {} + + humanize-ms@1.2.1: + dependencies: + ms: 2.1.3 + + i18next@23.16.8: + dependencies: + '@babel/runtime': 7.29.2 + + iceberg-js@0.8.1: {} + + iconv-lite@0.7.2: + dependencies: + safer-buffer: 2.1.2 + + ieee754@1.2.1: {} + + ignore@5.3.2: {} + + ignore@7.0.5: {} + + image-size@1.2.1: + dependencies: + queue: 6.0.2 + + imapflow@1.3.3: + dependencies: + '@zone-eu/mailsplit': 5.4.9 + encoding-japanese: 2.2.0 + iconv-lite: 0.7.2 + libbase64: 1.3.0 + libmime: 5.3.8 + libqp: 2.1.1 + nodemailer: 8.0.7 + pino: 10.3.1 + socks: 2.8.8 + + import-fresh@2.0.0: + dependencies: + caller-path: 2.0.0 + resolve-from: 3.0.0 + + imurmurhash@0.1.4: {} + + inflight@1.0.6: + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + + inherits@2.0.4: {} + + ini@1.3.8: {} + + invariant@2.2.4: + dependencies: + loose-envify: 1.4.0 + + ioredis@5.10.1: + dependencies: + '@ioredis/commands': 1.5.1 + cluster-key-slot: 1.1.2 + debug: 4.4.3 + denque: 2.1.0 + lodash.defaults: 4.2.0 + lodash.isarguments: 3.1.0 + redis-errors: 1.2.0 + redis-parser: 3.0.0 + standard-as-callback: 2.1.0 + transitivePeerDependencies: + - supports-color + + ip-address@10.2.0: {} + + iron-webcrypto@1.2.1: {} + + is-arguments@1.2.0: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-arrayish@0.2.1: {} + + is-arrayish@0.3.4: {} + + is-binary-path@2.1.0: + dependencies: + binary-extensions: 2.3.0 + + is-callable@1.2.7: {} + + is-core-module@2.16.2: + dependencies: + hasown: 2.0.3 + + is-directory@0.3.1: {} + + is-docker@2.2.1: {} + + is-docker@3.0.0: {} + + is-extglob@2.1.1: {} + + is-fullwidth-code-point@3.0.0: {} + + is-generator-function@1.1.2: + dependencies: + call-bound: 1.0.4 + generator-function: 2.0.1 + get-proto: 1.0.1 + has-tostringtag: 1.0.2 + safe-regex-test: 1.1.0 + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-in-ssh@1.0.0: {} + + is-inside-container@1.0.0: + dependencies: + is-docker: 3.0.0 + + is-module@1.0.0: {} + + is-nan@1.3.2: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + + is-number@7.0.0: {} + + is-path-inside@4.0.0: {} + + is-plain-obj@2.1.0: {} + + is-property@1.0.2: {} + + is-reference@1.2.1: + dependencies: + '@types/estree': 1.0.8 + + is-regex@1.2.1: + dependencies: + call-bound: 1.0.4 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + hasown: 2.0.3 + + is-stream@2.0.1: {} + + is-typed-array@1.1.15: + dependencies: + which-typed-array: 1.1.20 + + is-wsl@2.2.0: + dependencies: + is-docker: 2.2.1 + + is-wsl@3.1.1: + dependencies: + is-inside-container: 1.0.0 + + isarray@1.0.0: {} + + isexe@2.0.0: {} + + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-instrument@5.2.1: + dependencies: + '@babel/core': 7.29.0 + '@babel/parser': 7.29.3 + '@istanbuljs/schema': 0.1.6 + istanbul-lib-coverage: 3.2.2 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + + jest-environment-node@29.7.0: + dependencies: + '@jest/environment': 29.7.0 + '@jest/fake-timers': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 22.19.17 + jest-mock: 29.7.0 + jest-util: 29.7.0 + + jest-get-type@29.6.3: {} + + jest-haste-map@29.7.0: + dependencies: + '@jest/types': 29.6.3 + '@types/graceful-fs': 4.1.9 + '@types/node': 22.19.17 + anymatch: 3.1.3 + fb-watchman: 2.0.2 + graceful-fs: 4.2.11 + jest-regex-util: 29.6.3 + jest-util: 29.7.0 + jest-worker: 29.7.0 + micromatch: 4.0.8 + walker: 1.0.8 + optionalDependencies: + fsevents: 2.3.3 + + jest-message-util@29.7.0: + dependencies: + '@babel/code-frame': 7.29.0 + '@jest/types': 29.6.3 + '@types/stack-utils': 2.0.3 + chalk: 4.1.2 + graceful-fs: 4.2.11 + micromatch: 4.0.8 + pretty-format: 29.7.0 + slash: 3.0.0 + stack-utils: 2.0.6 + + jest-mock@29.7.0: + dependencies: + '@jest/types': 29.6.3 + '@types/node': 22.19.17 + jest-util: 29.7.0 + + jest-regex-util@29.6.3: {} + + jest-util@29.7.0: + dependencies: + '@jest/types': 29.6.3 + '@types/node': 22.19.17 + chalk: 4.1.2 + ci-info: 3.9.0 + graceful-fs: 4.2.11 + picomatch: 2.3.2 + + jest-validate@29.7.0: + dependencies: + '@jest/types': 29.6.3 + camelcase: 6.3.0 + chalk: 4.1.2 + jest-get-type: 29.6.3 + leven: 3.1.0 + pretty-format: 29.7.0 + + jest-worker@29.7.0: + dependencies: + '@types/node': 22.19.17 + jest-util: 29.7.0 + merge-stream: 2.0.0 + supports-color: 8.1.1 + + jimp-compact@0.16.1: {} + + jiti@1.21.7: {} + + jiti@2.7.0: {} + + jose@6.2.3: {} + + js-tokens@4.0.0: {} + + js-tokens@9.0.1: {} + + js-yaml@3.14.2: + dependencies: + argparse: 1.0.10 + esprima: 4.0.1 + + js-yaml@4.1.1: + dependencies: + argparse: 2.0.1 + + jsc-safe-url@0.2.4: {} + + jsesc@3.1.0: {} + + json-parse-better-errors@1.0.2: {} + + json-schema-traverse@1.0.0: {} + + json5@2.2.3: {} + + kleur@3.0.3: {} + + kleur@4.1.5: {} + + klona@2.0.6: {} + + knitwork@1.3.0: {} + + lan-network@0.1.7: {} + + lazystream@1.0.1: + dependencies: + readable-stream: 2.3.8 + + leac@0.6.0: {} + + leven@3.1.0: {} + + libbase64@1.3.0: {} + + libmime@5.3.8: + dependencies: + encoding-japanese: 2.2.0 + iconv-lite: 0.7.2 + libbase64: 1.3.0 + libqp: 2.1.1 + + libqp@2.1.1: {} + + lighthouse-logger@1.4.2: + dependencies: + debug: 2.6.9 + marky: 1.3.0 + transitivePeerDependencies: + - supports-color + + lightningcss-darwin-arm64@1.27.0: + optional: true + + lightningcss-darwin-x64@1.27.0: + optional: true + + lightningcss-freebsd-x64@1.27.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.27.0: + optional: true + + lightningcss-linux-arm64-gnu@1.27.0: + optional: true + + lightningcss-linux-arm64-musl@1.27.0: + optional: true + + lightningcss-linux-x64-gnu@1.27.0: + optional: true + + lightningcss-linux-x64-musl@1.27.0: + optional: true + + lightningcss-win32-arm64-msvc@1.27.0: + optional: true + + lightningcss-win32-x64-msvc@1.27.0: + optional: true + + lightningcss@1.27.0: + dependencies: + detect-libc: 1.0.3 + optionalDependencies: + lightningcss-darwin-arm64: 1.27.0 + lightningcss-darwin-x64: 1.27.0 + lightningcss-freebsd-x64: 1.27.0 + lightningcss-linux-arm-gnueabihf: 1.27.0 + lightningcss-linux-arm64-gnu: 1.27.0 + lightningcss-linux-arm64-musl: 1.27.0 + lightningcss-linux-x64-gnu: 1.27.0 + lightningcss-linux-x64-musl: 1.27.0 + lightningcss-win32-arm64-msvc: 1.27.0 + lightningcss-win32-x64-msvc: 1.27.0 + + lilconfig@3.1.3: {} + + lines-and-columns@1.2.4: {} + + listhen@1.10.0: + dependencies: + '@parcel/watcher': 2.5.6 + '@parcel/watcher-wasm': 2.5.6 + citty: 0.2.2 + consola: 3.4.2 + crossws: 0.3.5 + defu: 6.1.7 + get-port-please: 3.2.0 + h3: 1.15.11 + http-shutdown: 1.2.2 + jiti: 2.7.0 + mlly: 1.8.2 + node-forge: 1.4.0 + pathe: 2.0.3 + std-env: 4.1.0 + tinyclip: 0.1.12 + ufo: 1.6.4 + untun: 0.1.3 + uqr: 0.1.3 + + local-pkg@1.1.2: + dependencies: + mlly: 1.8.2 + pkg-types: 2.3.1 + quansync: 0.2.11 + + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash.debounce@4.0.8: {} + + lodash.defaults@4.2.0: {} + + lodash.isarguments@3.1.0: {} + + lodash.throttle@4.1.1: {} + + lodash@4.18.1: {} + + log-symbols@2.2.0: + dependencies: + chalk: 2.4.2 + + long@5.3.2: {} + + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + + lottie-react-native@7.2.2(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0): + dependencies: + react: 19.0.0 + react-native: 0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0) + + lru-cache@10.4.3: {} + + lru-cache@11.3.6: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + lru.min@1.1.4: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + magicast@0.5.2: + dependencies: + '@babel/parser': 7.29.3 + '@babel/types': 7.29.0 + source-map-js: 1.2.1 + + makeerror@1.0.12: + dependencies: + tmpl: 1.0.5 + + marky@1.3.0: {} + + math-intrinsics@1.1.0: {} + + mdn-data@2.0.14: {} + + memoize-one@5.2.1: {} + + merge-options@3.0.4: + dependencies: + is-plain-obj: 2.1.0 + + merge-stream@2.0.0: {} + + merge2@1.4.1: {} + + metro-babel-transformer@0.82.5: + dependencies: + '@babel/core': 7.29.0 + flow-enums-runtime: 0.0.6 + hermes-parser: 0.29.1 + nullthrows: 1.1.1 + transitivePeerDependencies: + - supports-color + + metro-cache-key@0.82.5: + dependencies: + flow-enums-runtime: 0.0.6 + + metro-cache@0.82.5: + dependencies: + exponential-backoff: 3.1.3 + flow-enums-runtime: 0.0.6 + https-proxy-agent: 7.0.6 + metro-core: 0.82.5 + transitivePeerDependencies: + - supports-color + + metro-config@0.82.5: + dependencies: + connect: 3.7.0 + cosmiconfig: 5.2.1 + flow-enums-runtime: 0.0.6 + jest-validate: 29.7.0 + metro: 0.82.5 + metro-cache: 0.82.5 + metro-core: 0.82.5 + metro-runtime: 0.82.5 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + metro-core@0.82.5: + dependencies: + flow-enums-runtime: 0.0.6 + lodash.throttle: 4.1.1 + metro-resolver: 0.82.5 + + metro-file-map@0.82.5: + dependencies: + debug: 4.4.3 + fb-watchman: 2.0.2 + flow-enums-runtime: 0.0.6 + graceful-fs: 4.2.11 + invariant: 2.2.4 + jest-worker: 29.7.0 + micromatch: 4.0.8 + nullthrows: 1.1.1 + walker: 1.0.8 + transitivePeerDependencies: + - supports-color + + metro-minify-terser@0.82.5: + dependencies: + flow-enums-runtime: 0.0.6 + terser: 5.46.2 + + metro-resolver@0.82.5: + dependencies: + flow-enums-runtime: 0.0.6 + + metro-runtime@0.82.5: + dependencies: + '@babel/runtime': 7.29.2 + flow-enums-runtime: 0.0.6 + + metro-source-map@0.82.5: + dependencies: + '@babel/traverse': 7.29.0 + '@babel/traverse--for-generate-function-map': '@babel/traverse@7.29.0' + '@babel/types': 7.29.0 + flow-enums-runtime: 0.0.6 + invariant: 2.2.4 + metro-symbolicate: 0.82.5 + nullthrows: 1.1.1 + ob1: 0.82.5 + source-map: 0.5.7 + vlq: 1.0.1 + transitivePeerDependencies: + - supports-color + + metro-symbolicate@0.82.5: + dependencies: + flow-enums-runtime: 0.0.6 + invariant: 2.2.4 + metro-source-map: 0.82.5 + nullthrows: 1.1.1 + source-map: 0.5.7 + vlq: 1.0.1 + transitivePeerDependencies: + - supports-color + + metro-transform-plugins@0.82.5: + dependencies: + '@babel/core': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + flow-enums-runtime: 0.0.6 + nullthrows: 1.1.1 + transitivePeerDependencies: + - supports-color + + metro-transform-worker@0.82.5: + dependencies: + '@babel/core': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/parser': 7.29.3 + '@babel/types': 7.29.0 + flow-enums-runtime: 0.0.6 + metro: 0.82.5 + metro-babel-transformer: 0.82.5 + metro-cache: 0.82.5 + metro-cache-key: 0.82.5 + metro-minify-terser: 0.82.5 + metro-source-map: 0.82.5 + metro-transform-plugins: 0.82.5 + nullthrows: 1.1.1 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + metro@0.82.5: + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/core': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/parser': 7.29.3 + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + accepts: 1.3.8 + chalk: 4.1.2 + ci-info: 2.0.0 + connect: 3.7.0 + debug: 4.4.3 + error-stack-parser: 2.1.4 + flow-enums-runtime: 0.0.6 + graceful-fs: 4.2.11 + hermes-parser: 0.29.1 + image-size: 1.2.1 + invariant: 2.2.4 + jest-worker: 29.7.0 + jsc-safe-url: 0.2.4 + lodash.throttle: 4.1.1 + metro-babel-transformer: 0.82.5 + metro-cache: 0.82.5 + metro-cache-key: 0.82.5 + metro-config: 0.82.5 + metro-core: 0.82.5 + metro-file-map: 0.82.5 + metro-resolver: 0.82.5 + metro-runtime: 0.82.5 + metro-source-map: 0.82.5 + metro-symbolicate: 0.82.5 + metro-transform-plugins: 0.82.5 + metro-transform-worker: 0.82.5 + mime-types: 2.1.35 + nullthrows: 1.1.1 + serialize-error: 2.1.0 + source-map: 0.5.7 + throat: 5.0.0 + ws: 7.5.10 + yargs: 17.7.2 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.2 + + mime-db@1.52.0: {} + + mime-db@1.54.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + + mime@1.6.0: {} + + mime@4.1.0: {} + + mimic-fn@1.2.0: {} + + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.5 + + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.14 + + minimatch@5.1.9: + dependencies: + brace-expansion: 2.1.0 + + minimatch@9.0.9: + dependencies: + brace-expansion: 2.1.0 + + minimist@1.2.8: {} + + minipass@7.1.3: {} + + minizlib@3.1.0: + dependencies: + minipass: 7.1.3 + + mkdirp@1.0.4: {} + + mlly@1.8.2: + dependencies: + acorn: 8.16.0 + pathe: 2.0.3 + pkg-types: 1.3.1 + ufo: 1.6.4 + + ms@2.0.0: {} + + ms@2.1.3: {} + + mysql2@3.15.3: + dependencies: + aws-ssl-profiles: 1.1.2 + denque: 2.1.0 + generate-function: 2.3.1 + iconv-lite: 0.7.2 + long: 5.3.2 + lru.min: 1.1.4 + named-placeholders: 1.1.6 + seq-queue: 0.0.5 + sqlstring: 2.3.3 + + mz@2.7.0: + dependencies: + any-promise: 1.3.0 + object-assign: 4.1.1 + thenify-all: 1.6.0 + + named-placeholders@1.1.6: + dependencies: + lru.min: 1.1.4 + + nanoid@3.3.12: {} + + nativewind@4.2.3(react-native-reanimated@4.0.3(@babel/core@7.29.0)(react-native-worklets@0.4.2(@babel/core@7.29.0)(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native-safe-area-context@5.4.0(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native-svg@15.11.2(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)(tailwindcss@3.4.19): + dependencies: + comment-json: 4.6.2 + debug: 4.4.3 + react-native-css-interop: 0.2.3(react-native-reanimated@4.0.3(@babel/core@7.29.0)(react-native-worklets@0.4.2(@babel/core@7.29.0)(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native-safe-area-context@5.4.0(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native-svg@15.11.2(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)(tailwindcss@3.4.19) + tailwindcss: 3.4.19 + transitivePeerDependencies: + - react + - react-native + - react-native-reanimated + - react-native-safe-area-context + - react-native-svg + - supports-color + + negotiator@0.6.3: {} + + negotiator@0.6.4: {} + + nested-error-stacks@2.0.1: {} + + nitropack@2.13.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3): + dependencies: + '@cloudflare/kv-asset-handler': 0.4.2 + '@rollup/plugin-alias': 6.0.0(rollup@4.60.3) + '@rollup/plugin-commonjs': 29.0.2(rollup@4.60.3) + '@rollup/plugin-inject': 5.0.5(rollup@4.60.3) + '@rollup/plugin-json': 6.1.0(rollup@4.60.3) + '@rollup/plugin-node-resolve': 16.0.3(rollup@4.60.3) + '@rollup/plugin-replace': 6.0.3(rollup@4.60.3) + '@rollup/plugin-terser': 1.0.0(rollup@4.60.3) + '@vercel/nft': 1.5.0(rollup@4.60.3) + archiver: 7.0.1 + c12: 3.3.4(magicast@0.5.2) + chokidar: 5.0.0 + citty: 0.2.2 + compatx: 0.2.0 + confbox: 0.2.4 + consola: 3.4.2 + cookie-es: 2.0.1 + croner: 10.0.1 + crossws: 0.3.5 + db0: 0.3.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3) + defu: 6.1.7 + destr: 2.0.5 + dot-prop: 10.1.0 + esbuild: 0.28.0 + escape-string-regexp: 5.0.0 + etag: 1.8.1 + exsolve: 1.0.8 + globby: 16.2.0 + gzip-size: 7.0.0 + h3: 1.15.11 + hookable: 5.5.3 + httpxy: 0.5.1 + ioredis: 5.10.1 + jiti: 2.7.0 + klona: 2.0.6 + knitwork: 1.3.0 + listhen: 1.10.0 + magic-string: 0.30.21 + magicast: 0.5.2 + mime: 4.1.0 + mlly: 1.8.2 + node-fetch-native: 1.6.7 + node-mock-http: 1.0.4 + ofetch: 1.5.1 + ohash: 2.0.11 + pathe: 2.0.3 + perfect-debounce: 2.1.0 + pkg-types: 2.3.1 + pretty-bytes: 7.1.0 + radix3: 1.1.2 + rollup: 4.60.3 + rollup-plugin-visualizer: 7.0.1(rollup@4.60.3) + scule: 1.3.0 + semver: 7.7.4 + serve-placeholder: 2.0.2 + serve-static: 2.2.1 + source-map: 0.7.6 + std-env: 4.1.0 + ufo: 1.6.4 + ultrahtml: 1.6.0 + uncrypto: 0.1.3 + unctx: 2.5.0 + unenv: 2.0.0-rc.24 + unimport: 6.2.0 + unplugin-utils: 0.3.1 + unstorage: 1.17.5(db0@0.3.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3))(ioredis@5.10.1) + untyped: 2.0.0 + unwasm: 0.5.3 + youch: 4.1.1 + youch-core: 0.3.3 + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@electric-sql/pglite' + - '@libsql/client' + - '@netlify/blobs' + - '@planetscale/database' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - bare-abort-controller + - bare-buffer + - better-sqlite3 + - drizzle-orm + - encoding + - idb-keyval + - mysql2 + - oxc-parser + - react-native-b4a + - rolldown + - sqlite3 + - supports-color + - uploadthing + + node-addon-api@7.1.1: {} + + node-domexception@1.0.0: {} + + node-fetch-native@1.6.7: {} + + node-fetch@2.7.0: + dependencies: + whatwg-url: 5.0.0 + + node-forge@1.4.0: {} + + node-gyp-build@4.8.4: {} + + node-int64@0.4.0: {} + + node-mock-http@1.0.4: {} + + node-releases@2.0.38: {} + + nodemailer@8.0.7: {} + + nopt@8.1.0: + dependencies: + abbrev: 3.0.1 + + normalize-path@3.0.0: {} + + npm-package-arg@11.0.3: + dependencies: + hosted-git-info: 7.0.2 + proc-log: 4.2.0 + semver: 7.7.4 + validate-npm-package-name: 5.0.1 + + nth-check@2.1.1: + dependencies: + boolbase: 1.0.0 + + nullthrows@1.1.1: {} + + ob1@0.82.5: + dependencies: + flow-enums-runtime: 0.0.6 + + object-assign@4.1.1: {} + + object-hash@3.0.0: {} + + object-inspect@1.13.4: {} + + object-is@1.1.6: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + + object-keys@1.1.1: {} + + object.assign@4.1.7: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + has-symbols: 1.1.0 + object-keys: 1.1.1 + + ofetch@1.5.1: + dependencies: + destr: 2.0.5 + node-fetch-native: 1.6.7 + ufo: 1.6.4 + + ohash@2.0.11: {} + + on-exit-leak-free@2.1.2: {} + + on-finished@2.3.0: + dependencies: + ee-first: 1.1.1 + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + on-headers@1.1.0: {} + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + onetime@2.0.1: + dependencies: + mimic-fn: 1.2.0 + + open@11.0.0: + dependencies: + default-browser: 5.5.0 + define-lazy-prop: 3.0.0 + is-in-ssh: 1.0.0 + is-inside-container: 1.0.0 + powershell-utils: 0.1.0 + wsl-utils: 0.3.1 + + open@7.4.2: + dependencies: + is-docker: 2.2.1 + is-wsl: 2.2.0 + + open@8.4.2: + dependencies: + define-lazy-prop: 2.0.0 + is-docker: 2.2.1 + is-wsl: 2.2.0 + + openai@4.104.0(ws@8.20.0)(zod@3.25.76): + dependencies: + '@types/node': 18.19.130 + '@types/node-fetch': 2.6.13 + abort-controller: 3.0.0 + agentkeepalive: 4.6.0 + form-data-encoder: 1.7.2 + formdata-node: 4.4.1 + node-fetch: 2.7.0 + optionalDependencies: + ws: 8.20.0 + zod: 3.25.76 + transitivePeerDependencies: + - encoding + + ora@3.4.0: + dependencies: + chalk: 2.4.2 + cli-cursor: 2.1.0 + cli-spinners: 2.9.2 + log-symbols: 2.2.0 + strip-ansi: 5.2.0 + wcwidth: 1.0.1 + + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + p-try@2.2.0: {} + + package-json-from-dist@1.0.1: {} + + parse-json@4.0.0: + dependencies: + error-ex: 1.3.4 + json-parse-better-errors: 1.0.2 + + parse-png@2.1.0: + dependencies: + pngjs: 3.4.0 + + parseley@0.12.1: + dependencies: + leac: 0.6.0 + peberminta: 0.9.0 + + parseurl@1.3.3: {} + + path-exists@4.0.0: {} + + path-is-absolute@1.0.1: {} + + path-key@3.1.1: {} + + path-parse@1.0.7: {} + + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.3 + + path-scurry@2.0.2: + dependencies: + lru-cache: 11.3.6 + minipass: 7.1.3 + + pathe@1.1.2: {} + + pathe@2.0.3: {} + + peberminta@0.9.0: {} + + perfect-debounce@2.1.0: {} + + pg-cloudflare@1.3.0: + optional: true + + pg-connection-string@2.12.0: {} + + pg-int8@1.0.1: {} + + pg-pool@3.13.0(pg@8.20.0): + dependencies: + pg: 8.20.0 + + pg-protocol@1.13.0: {} + + pg-types@2.2.0: + dependencies: + pg-int8: 1.0.1 + postgres-array: 2.0.0 + postgres-bytea: 1.0.1 + postgres-date: 1.0.7 + postgres-interval: 1.2.0 + + pg@8.20.0: + dependencies: + pg-connection-string: 2.12.0 + pg-pool: 3.13.0(pg@8.20.0) + pg-protocol: 1.13.0 + pg-types: 2.2.0 + pgpass: 1.0.5 + optionalDependencies: + pg-cloudflare: 1.3.0 + + pgpass@1.0.5: + dependencies: + split2: 4.2.0 + + picocolors@1.1.1: {} + + picomatch@2.3.2: {} + + picomatch@3.0.2: {} + + picomatch@4.0.4: {} + + pify@2.3.0: {} + + pino-abstract-transport@3.0.0: + dependencies: + split2: 4.2.0 + + pino-std-serializers@7.1.0: {} + + pino@10.3.1: + dependencies: + '@pinojs/redact': 0.4.0 + atomic-sleep: 1.0.0 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 3.0.0 + pino-std-serializers: 7.1.0 + process-warning: 5.0.0 + quick-format-unescaped: 4.0.4 + real-require: 0.2.0 + safe-stable-stringify: 2.5.0 + sonic-boom: 4.2.1 + thread-stream: 4.0.0 + + pirates@4.0.7: {} + + pkg-types@1.3.1: + dependencies: + confbox: 0.1.8 + mlly: 1.8.2 + pathe: 2.0.3 + + pkg-types@2.3.1: + dependencies: + confbox: 0.2.4 + exsolve: 1.0.8 + pathe: 2.0.3 + + plist@3.1.1: + dependencies: + '@xmldom/xmldom': 0.9.10 + base64-js: 1.5.1 + xmlbuilder: 15.1.1 + + pngjs@3.4.0: {} + + possible-typed-array-names@1.1.0: {} + + postcss-import@15.1.0(postcss@8.5.14): + dependencies: + postcss: 8.5.14 + postcss-value-parser: 4.2.0 + read-cache: 1.0.0 + resolve: 1.22.12 + + postcss-js@4.1.0(postcss@8.5.14): + dependencies: + camelcase-css: 2.0.1 + postcss: 8.5.14 + + postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.14): + dependencies: + lilconfig: 3.1.3 + optionalDependencies: + jiti: 1.21.7 + postcss: 8.5.14 + + postcss-nested@6.2.0(postcss@8.5.14): + dependencies: + postcss: 8.5.14 + postcss-selector-parser: 6.1.2 + + postcss-selector-parser@6.1.2: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss-value-parser@4.2.0: {} + + postcss@8.4.49: + dependencies: + nanoid: 3.3.12 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + postcss@8.5.14: + dependencies: + nanoid: 3.3.12 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + postgres-array@2.0.0: {} + + postgres-array@3.0.4: {} + + postgres-bytea@1.0.1: {} + + postgres-date@1.0.7: {} + + postgres-interval@1.2.0: + dependencies: + xtend: 4.0.2 + + postgres@3.4.7: {} + + powershell-utils@0.1.0: {} + + prettier@3.8.3: {} + + pretty-bytes@5.6.0: {} + + pretty-bytes@7.1.0: {} + + pretty-format@29.7.0: + dependencies: + '@jest/schemas': 29.6.3 + ansi-styles: 5.2.0 + react-is: 18.3.1 + + prisma@7.8.0(@types/react@19.0.14)(magicast@0.5.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.9.3): + dependencies: + '@prisma/config': 7.8.0(magicast@0.5.2) + '@prisma/dev': 0.24.3(typescript@5.9.3) + '@prisma/engines': 7.8.0 + '@prisma/studio-core': 0.27.3(@types/react@19.0.14)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + mysql2: 3.15.3 + postgres: 3.4.7 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + - magicast + - react + - react-dom + + proc-log@4.2.0: {} + + process-nextick-args@2.0.1: {} + + process-warning@5.0.0: {} + + process@0.11.10: {} + + progress@2.0.3: {} + + promise@8.3.0: + dependencies: + asap: 2.0.6 + + prompts@2.4.2: + dependencies: + kleur: 3.0.3 + sisteransi: 1.0.5 + + proper-lockfile@4.1.2: + dependencies: + graceful-fs: 4.2.11 + retry: 0.12.0 + signal-exit: 3.0.7 + + punycode@2.3.1: {} + + pure-rand@6.1.0: {} + + qrcode-terminal@0.11.0: {} + + qs@6.15.1: + dependencies: + side-channel: 1.1.0 + + quansync@0.2.11: {} + + query-string@7.1.3: + dependencies: + decode-uri-component: 0.2.2 + filter-obj: 1.1.0 + split-on-first: 1.1.0 + strict-uri-encode: 2.0.0 + + queue-microtask@1.2.3: {} + + queue@6.0.2: + dependencies: + inherits: 2.0.4 + + quick-format-unescaped@4.0.4: {} + + radix3@1.1.2: {} + + range-parser@1.2.1: {} + + rc9@3.0.1: + dependencies: + defu: 6.1.7 + destr: 2.0.5 + + rc@1.2.8: + dependencies: + deep-extend: 0.6.0 + ini: 1.3.8 + minimist: 1.2.8 + strip-json-comments: 2.0.1 + + react-devtools-core@6.1.5: + dependencies: + shell-quote: 1.8.3 + ws: 7.5.10 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + react-dom@19.0.0(react@19.0.0): + dependencies: + react: 19.0.0 + scheduler: 0.25.0 + + react-fast-compare@3.2.2: {} + + react-freeze@1.0.4(react@19.0.0): + dependencies: + react: 19.0.0 + + react-hook-form@7.75.0(react@19.0.0): + dependencies: + react: 19.0.0 + + react-i18next@15.7.4(i18next@23.16.8)(react-dom@19.0.0(react@19.0.0))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)(typescript@5.8.3): + dependencies: + '@babel/runtime': 7.29.2 + html-parse-stringify: 3.0.1 + i18next: 23.16.8 + react: 19.0.0 + optionalDependencies: + react-dom: 19.0.0(react@19.0.0) + react-native: 0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0) + typescript: 5.8.3 + + react-is@16.13.1: {} + + react-is@18.3.1: {} + + react-is@19.2.5: {} + + react-native-bottom-tabs@1.2.0(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0): + dependencies: + react: 19.0.0 + react-freeze: 1.0.4(react@19.0.0) + react-native: 0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0) + sf-symbols-typescript: 2.2.0 + use-latest-callback: 0.2.6(react@19.0.0) + + react-native-css-interop@0.2.3(react-native-reanimated@4.0.3(@babel/core@7.29.0)(react-native-worklets@0.4.2(@babel/core@7.29.0)(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native-safe-area-context@5.4.0(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native-svg@15.11.2(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)(tailwindcss@3.4.19): + dependencies: + '@babel/helper-module-imports': 7.28.6 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + debug: 4.4.3 + lightningcss: 1.27.0 + react: 19.0.0 + react-native: 0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0) + react-native-reanimated: 4.0.3(@babel/core@7.29.0)(react-native-worklets@0.4.2(@babel/core@7.29.0)(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0) + semver: 7.7.4 + tailwindcss: 3.4.19 + optionalDependencies: + react-native-safe-area-context: 5.4.0(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0) + react-native-svg: 15.11.2(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0) + transitivePeerDependencies: + - supports-color + + react-native-edge-to-edge@1.6.0(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0): + dependencies: + react: 19.0.0 + react-native: 0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0) + + react-native-gesture-handler@2.24.0(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0): + dependencies: + '@egjs/hammerjs': 2.0.17 + hoist-non-react-statics: 3.3.2 + invariant: 2.2.4 + react: 19.0.0 + react-native: 0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0) + + react-native-is-edge-to-edge@1.3.1(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0): + dependencies: + react: 19.0.0 + react-native: 0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0) + + react-native-mmkv@3.3.3(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0): + dependencies: + react: 19.0.0 + react-native: 0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0) + + react-native-reanimated@4.0.3(@babel/core@7.29.0)(react-native-worklets@0.4.2(@babel/core@7.29.0)(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0): + dependencies: + '@babel/core': 7.29.0 + react: 19.0.0 + react-native: 0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0) + react-native-is-edge-to-edge: 1.3.1(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0) + react-native-worklets: 0.4.2(@babel/core@7.29.0)(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0) + semver: 7.7.2 + + react-native-safe-area-context@5.4.0(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0): + dependencies: + react: 19.0.0 + react-native: 0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0) + + react-native-screens@4.11.1(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0): + dependencies: + react: 19.0.0 + react-freeze: 1.0.4(react@19.0.0) + react-native: 0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0) + react-native-is-edge-to-edge: 1.3.1(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0) + warn-once: 0.1.1 + + react-native-sse@1.2.1: {} + + react-native-svg@15.11.2(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0): + dependencies: + css-select: 5.2.2 + css-tree: 1.1.3 + react: 19.0.0 + react-native: 0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0) + warn-once: 0.1.1 + + react-native-url-polyfill@2.0.0(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0)): + dependencies: + react-native: 0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0) + whatwg-url-without-unicode: 8.0.0-3 + + react-native-worklets@0.4.2(@babel/core@7.29.0)(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0): + dependencies: + '@babel/core': 7.29.0 + '@babel/plugin-transform-arrow-functions': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-class-properties': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-classes': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-nullish-coalescing-operator': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-optional-chaining': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-shorthand-properties': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-template-literals': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-unicode-regex': 7.27.1(@babel/core@7.29.0) + '@babel/preset-typescript': 7.28.5(@babel/core@7.29.0) + convert-source-map: 2.0.0 + react: 19.0.0 + react-native: 0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0) + transitivePeerDependencies: + - supports-color + + react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0): + dependencies: + '@jest/create-cache-key-function': 29.7.0 + '@react-native/assets-registry': 0.79.6 + '@react-native/codegen': 0.79.6(@babel/core@7.29.0) + '@react-native/community-cli-plugin': 0.79.6 + '@react-native/gradle-plugin': 0.79.6 + '@react-native/js-polyfills': 0.79.6 + '@react-native/normalize-colors': 0.79.6 + '@react-native/virtualized-lists': 0.79.6(@types/react@19.0.14)(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0) + abort-controller: 3.0.0 + anser: 1.4.10 + ansi-regex: 5.0.1 + babel-jest: 29.7.0(@babel/core@7.29.0) + babel-plugin-syntax-hermes-parser: 0.25.1 + base64-js: 1.5.1 + chalk: 4.1.2 + commander: 12.1.0 + event-target-shim: 5.0.1 + flow-enums-runtime: 0.0.6 + glob: 7.2.3 + invariant: 2.2.4 + jest-environment-node: 29.7.0 + memoize-one: 5.2.1 + metro-runtime: 0.82.5 + metro-source-map: 0.82.5 + nullthrows: 1.1.1 + pretty-format: 29.7.0 + promise: 8.3.0 + react: 19.0.0 + react-devtools-core: 6.1.5 + react-refresh: 0.14.2 + regenerator-runtime: 0.13.11 + scheduler: 0.25.0 + semver: 7.7.4 + stacktrace-parser: 0.1.11 + whatwg-fetch: 3.6.20 + ws: 6.2.3 + yargs: 17.7.2 + optionalDependencies: + '@types/react': 19.0.14 + transitivePeerDependencies: + - '@babel/core' + - '@react-native-community/cli' + - bufferutil + - supports-color + - utf-8-validate + + react-promise-suspense@0.3.4: + dependencies: + fast-deep-equal: 2.0.1 + + react-refresh@0.14.2: {} + + react@19.0.0: {} + + read-cache@1.0.0: + dependencies: + pify: 2.3.0 + + readable-stream@2.3.8: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + + readable-stream@4.7.0: + dependencies: + abort-controller: 3.0.0 + buffer: 6.0.3 + events: 3.3.0 + process: 0.11.10 + string_decoder: 1.3.0 + + readdir-glob@1.1.3: + dependencies: + minimatch: 5.1.9 + + readdirp@3.6.0: + dependencies: + picomatch: 2.3.2 + + readdirp@5.0.0: {} + + real-require@0.2.0: {} + + redis-errors@1.2.0: {} + + redis-parser@3.0.0: + dependencies: + redis-errors: 1.2.0 + + regenerate-unicode-properties@10.2.2: + dependencies: + regenerate: 1.4.2 + + regenerate@1.4.2: {} + + regenerator-runtime@0.13.11: {} + + regexpu-core@6.4.0: + dependencies: + regenerate: 1.4.2 + regenerate-unicode-properties: 10.2.2 + regjsgen: 0.8.0 + regjsparser: 0.13.1 + unicode-match-property-ecmascript: 2.0.0 + unicode-match-property-value-ecmascript: 2.2.1 + + regjsgen@0.8.0: {} + + regjsparser@0.13.1: + dependencies: + jsesc: 3.1.0 + + remeda@2.33.4: {} + + require-directory@2.1.1: {} + + require-from-string@2.0.2: {} + + requireg@0.2.2: + dependencies: + nested-error-stacks: 2.0.1 + rc: 1.2.8 + resolve: 1.7.1 + + resend@4.8.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0): + dependencies: + '@react-email/render': 1.1.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + transitivePeerDependencies: + - react + - react-dom + + resolve-from@3.0.0: {} + + resolve-from@5.0.0: {} + + resolve-workspace-root@2.0.1: {} + + resolve.exports@2.0.3: {} + + resolve@1.22.12: + dependencies: + es-errors: 1.3.0 + is-core-module: 2.16.2 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + resolve@1.7.1: + dependencies: + path-parse: 1.0.7 + + restore-cursor@2.0.0: + dependencies: + onetime: 2.0.1 + signal-exit: 3.0.7 + + retry@0.12.0: {} + + reusify@1.1.0: {} + + rimraf@3.0.2: + dependencies: + glob: 7.2.3 + + rive-react-native@9.8.3(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0): + dependencies: + react: 19.0.0 + react-native: 0.79.6(@babel/core@7.29.0)(@types/react@19.0.14)(react@19.0.0) + + rollup-plugin-visualizer@7.0.1(rollup@4.60.3): + dependencies: + open: 11.0.0 + picomatch: 4.0.4 + source-map: 0.7.6 + yargs: 18.0.0 + optionalDependencies: + rollup: 4.60.3 + + rollup@4.60.3: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.60.3 + '@rollup/rollup-android-arm64': 4.60.3 + '@rollup/rollup-darwin-arm64': 4.60.3 + '@rollup/rollup-darwin-x64': 4.60.3 + '@rollup/rollup-freebsd-arm64': 4.60.3 + '@rollup/rollup-freebsd-x64': 4.60.3 + '@rollup/rollup-linux-arm-gnueabihf': 4.60.3 + '@rollup/rollup-linux-arm-musleabihf': 4.60.3 + '@rollup/rollup-linux-arm64-gnu': 4.60.3 + '@rollup/rollup-linux-arm64-musl': 4.60.3 + '@rollup/rollup-linux-loong64-gnu': 4.60.3 + '@rollup/rollup-linux-loong64-musl': 4.60.3 + '@rollup/rollup-linux-ppc64-gnu': 4.60.3 + '@rollup/rollup-linux-ppc64-musl': 4.60.3 + '@rollup/rollup-linux-riscv64-gnu': 4.60.3 + '@rollup/rollup-linux-riscv64-musl': 4.60.3 + '@rollup/rollup-linux-s390x-gnu': 4.60.3 + '@rollup/rollup-linux-x64-gnu': 4.60.3 + '@rollup/rollup-linux-x64-musl': 4.60.3 + '@rollup/rollup-openbsd-x64': 4.60.3 + '@rollup/rollup-openharmony-arm64': 4.60.3 + '@rollup/rollup-win32-arm64-msvc': 4.60.3 + '@rollup/rollup-win32-ia32-msvc': 4.60.3 + '@rollup/rollup-win32-x64-gnu': 4.60.3 + '@rollup/rollup-win32-x64-msvc': 4.60.3 + fsevents: 2.3.3 + + rtl-detect@1.1.2: {} + + run-applescript@7.1.0: {} + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + safe-buffer@5.1.2: {} + + safe-buffer@5.2.1: {} + + safe-regex-test@1.1.0: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-regex: 1.2.1 + + safe-stable-stringify@2.5.0: {} + + safer-buffer@2.1.2: {} + + sax@1.6.0: {} + + scheduler@0.25.0: {} + + scule@1.3.0: {} + + selderee@0.11.0: + dependencies: + parseley: 0.12.1 + + semver@6.3.1: {} + + semver@7.6.3: {} + + semver@7.7.2: {} + + semver@7.7.4: {} + + send@0.19.2: + dependencies: + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 0.5.2 + http-errors: 2.0.1 + mime: 1.6.0 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + send@1.2.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + seq-queue@0.0.5: {} + + serialize-error@2.1.0: {} + + serialize-javascript@7.0.5: {} + + serve-placeholder@2.0.2: + dependencies: + defu: 6.1.7 + + serve-static@1.16.3: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 0.19.2 + transitivePeerDependencies: + - supports-color + + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + + server-only@0.0.1: {} + + set-function-length@1.2.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + + setprototypeof@1.2.0: {} + + sf-symbols-typescript@2.2.0: {} + + shallowequal@1.1.0: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + shell-quote@1.8.3: {} + + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + signal-exit@3.0.7: {} + + signal-exit@4.1.0: {} + + simple-plist@1.3.1: + dependencies: + bplist-creator: 0.1.0 + bplist-parser: 0.3.1 + plist: 3.1.1 + + simple-swizzle@0.2.4: + dependencies: + is-arrayish: 0.3.4 + + sisteransi@1.0.5: {} + + slash@3.0.0: {} + + slash@5.1.0: {} + + slugify@1.6.9: {} + + smart-buffer@4.2.0: {} + + smob@1.6.1: {} + + socks@2.8.8: + dependencies: + ip-address: 10.2.0 + smart-buffer: 4.2.0 + + sonic-boom@4.2.1: + dependencies: + atomic-sleep: 1.0.0 + + source-map-js@1.2.1: {} + + source-map-support@0.5.21: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + source-map@0.5.7: {} + + source-map@0.6.1: {} + + source-map@0.7.6: {} + + split-on-first@1.1.0: {} + + split2@4.2.0: {} + + sprintf-js@1.0.3: {} + + sqlstring@2.3.3: {} + + stack-utils@2.0.6: + dependencies: + escape-string-regexp: 2.0.0 + + stackframe@1.3.4: {} + + stacktrace-parser@0.1.11: + dependencies: + type-fest: 0.7.1 + + standard-as-callback@2.1.0: {} + + statuses@1.5.0: {} + + statuses@2.0.2: {} + + std-env@3.10.0: {} + + std-env@4.1.0: {} + + stream-buffers@2.2.0: {} + + streamx@2.25.0: + dependencies: + events-universal: 1.0.1 + fast-fifo: 1.3.2 + text-decoder: 1.2.7 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + strict-uri-encode@2.0.0: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.2.0 + + string-width@7.2.0: + dependencies: + emoji-regex: 10.6.0 + get-east-asian-width: 1.5.0 + strip-ansi: 7.2.0 + + string_decoder@1.1.1: + dependencies: + safe-buffer: 5.1.2 + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + + strip-ansi@5.2.0: + dependencies: + ansi-regex: 4.1.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.2.2 + + strip-json-comments@2.0.1: {} + + strip-literal@3.1.0: + dependencies: + js-tokens: 9.0.1 + + stripe@17.7.0: + dependencies: + '@types/node': 22.19.17 + qs: 6.15.1 + + structured-headers@0.4.1: {} + + sucrase@3.35.0: + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + commander: 4.1.1 + glob: 10.5.0 + lines-and-columns: 1.2.4 + mz: 2.7.0 + pirates: 4.0.7 + ts-interface-checker: 0.1.13 + + sucrase@3.35.1: + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + commander: 4.1.1 + lines-and-columns: 1.2.4 + mz: 2.7.0 + pirates: 4.0.7 + tinyglobby: 0.2.16 + ts-interface-checker: 0.1.13 + + supports-color@10.2.2: {} + + supports-color@5.5.0: + dependencies: + has-flag: 3.0.0 + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-color@8.1.1: + dependencies: + has-flag: 4.0.0 + + supports-hyperlinks@2.3.0: + dependencies: + has-flag: 4.0.0 + supports-color: 7.2.0 + + supports-preserve-symlinks-flag@1.0.0: {} + + tagged-tag@1.0.0: {} + + tailwindcss@3.4.19: + dependencies: + '@alloc/quick-lru': 5.2.0 + arg: 5.0.2 + chokidar: 3.6.0 + didyoumean: 1.2.2 + dlv: 1.1.3 + fast-glob: 3.3.3 + glob-parent: 6.0.2 + is-glob: 4.0.3 + jiti: 1.21.7 + lilconfig: 3.1.3 + micromatch: 4.0.8 + normalize-path: 3.0.0 + object-hash: 3.0.0 + picocolors: 1.1.1 + postcss: 8.5.14 + postcss-import: 15.1.0(postcss@8.5.14) + postcss-js: 4.1.0(postcss@8.5.14) + postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.14) + postcss-nested: 6.2.0(postcss@8.5.14) + postcss-selector-parser: 6.1.2 + resolve: 1.22.12 + sucrase: 3.35.1 + transitivePeerDependencies: + - tsx + - yaml + + tar-stream@3.2.0: + dependencies: + b4a: 1.8.1 + bare-fs: 4.7.1 + fast-fifo: 1.3.2 + streamx: 2.25.0 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + + tar@7.5.14: + dependencies: + '@isaacs/fs-minipass': 4.0.1 + chownr: 3.0.0 + minipass: 7.1.3 + minizlib: 3.1.0 + yallist: 5.0.0 + + teex@1.0.1: + dependencies: + streamx: 2.25.0 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + temp-dir@2.0.0: {} + + terminal-link@2.1.1: + dependencies: + ansi-escapes: 4.3.2 + supports-hyperlinks: 2.3.0 + + terser@5.46.2: + dependencies: + '@jridgewell/source-map': 0.3.11 + acorn: 8.16.0 + commander: 2.20.3 + source-map-support: 0.5.21 + + test-exclude@6.0.0: + dependencies: + '@istanbuljs/schema': 0.1.6 + glob: 7.2.3 + minimatch: 3.1.5 + + text-decoder@1.2.7: + dependencies: + b4a: 1.8.1 + transitivePeerDependencies: + - react-native-b4a + + thenify-all@1.6.0: + dependencies: + thenify: 3.3.1 + + thenify@3.3.1: + dependencies: + any-promise: 1.3.0 + + thread-stream@4.0.0: + dependencies: + real-require: 0.2.0 + + throat@5.0.0: {} + + tinyclip@0.1.12: {} + + tinyglobby@0.2.16: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tmpl@1.0.5: {} + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + toidentifier@1.0.1: {} + + tr46@0.0.3: {} + + ts-interface-checker@0.1.13: {} + + tslib@2.8.1: {} + + type-detect@4.0.8: {} + + type-fest@0.21.3: {} + + type-fest@0.7.1: {} + + type-fest@5.6.0: + dependencies: + tagged-tag: 1.0.0 + + typescript@5.8.3: {} + + typescript@5.9.3: {} + + ufo@1.6.4: {} + + ultrahtml@1.6.0: {} + + uncrypto@0.1.3: {} + + unctx@2.5.0: + dependencies: + acorn: 8.16.0 + estree-walker: 3.0.3 + magic-string: 0.30.21 + unplugin: 2.3.11 + + undici-types@5.26.5: {} + + undici-types@6.21.0: {} + + undici@6.25.0: {} + + undici@7.25.0: {} + + unenv@2.0.0-rc.24: + dependencies: + pathe: 2.0.3 + + unicode-canonical-property-names-ecmascript@2.0.1: {} + + unicode-match-property-ecmascript@2.0.0: + dependencies: + unicode-canonical-property-names-ecmascript: 2.0.1 + unicode-property-aliases-ecmascript: 2.2.0 + + unicode-match-property-value-ecmascript@2.2.1: {} + + unicode-property-aliases-ecmascript@2.2.0: {} + + unicorn-magic@0.4.0: {} + + unimport@6.2.0: + dependencies: + acorn: 8.16.0 + escape-string-regexp: 5.0.0 + estree-walker: 3.0.3 + local-pkg: 1.1.2 + magic-string: 0.30.21 + mlly: 1.8.2 + pathe: 2.0.3 + picomatch: 4.0.4 + pkg-types: 2.3.1 + scule: 1.3.0 + strip-literal: 3.1.0 + tinyglobby: 0.2.16 + unplugin: 3.0.0 + unplugin-utils: 0.3.1 + + unique-string@2.0.0: + dependencies: + crypto-random-string: 2.0.0 + + unpipe@1.0.0: {} + + unplugin-utils@0.3.1: + dependencies: + pathe: 2.0.3 + picomatch: 4.0.4 + + unplugin@2.3.11: + dependencies: + '@jridgewell/remapping': 2.3.5 + acorn: 8.16.0 + picomatch: 4.0.4 + webpack-virtual-modules: 0.6.2 + + unplugin@3.0.0: + dependencies: + '@jridgewell/remapping': 2.3.5 + picomatch: 4.0.4 + webpack-virtual-modules: 0.6.2 + + unstorage@1.17.5(db0@0.3.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3))(ioredis@5.10.1): + dependencies: + anymatch: 3.1.3 + chokidar: 5.0.0 + destr: 2.0.5 + h3: 1.15.11 + lru-cache: 11.3.6 + node-fetch-native: 1.6.7 + ofetch: 1.5.1 + ufo: 1.6.4 + optionalDependencies: + db0: 0.3.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3) + ioredis: 5.10.1 + + untun@0.1.3: + dependencies: + citty: 0.1.6 + consola: 3.4.2 + pathe: 1.1.2 + + untyped@2.0.0: + dependencies: + citty: 0.1.6 + defu: 6.1.7 + jiti: 2.7.0 + knitwork: 1.3.0 + scule: 1.3.0 + + unwasm@0.5.3: + dependencies: + exsolve: 1.0.8 + knitwork: 1.3.0 + magic-string: 0.30.21 + mlly: 1.8.2 + pathe: 2.0.3 + pkg-types: 2.3.1 + + update-browserslist-db@1.2.3(browserslist@4.28.2): + dependencies: + browserslist: 4.28.2 + escalade: 3.2.0 + picocolors: 1.1.1 + + uqr@0.1.3: {} + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + use-latest-callback@0.2.6(react@19.0.0): + dependencies: + react: 19.0.0 + + use-sync-external-store@1.6.0(react@19.0.0): + dependencies: + react: 19.0.0 + + util-deprecate@1.0.2: {} + + util@0.12.5: + dependencies: + inherits: 2.0.4 + is-arguments: 1.2.0 + is-generator-function: 1.1.2 + is-typed-array: 1.1.15 + which-typed-array: 1.1.20 + + utils-merge@1.0.1: {} + + uuid@7.0.3: {} + + valibot@1.2.0(typescript@5.9.3): + optionalDependencies: + typescript: 5.9.3 + + valibot@1.4.0(typescript@5.8.3): + optionalDependencies: + typescript: 5.8.3 + + validate-npm-package-name@5.0.1: {} + + vary@1.1.2: {} + + vlq@1.0.1: {} + + void-elements@3.1.0: {} + + walker@1.0.8: + dependencies: + makeerror: 1.0.12 + + warn-once@0.1.1: {} + + wcwidth@1.0.1: + dependencies: + defaults: 1.0.4 + + web-streams-polyfill@4.0.0-beta.3: {} + + webidl-conversions@3.0.1: {} + + webidl-conversions@5.0.0: {} + + webpack-virtual-modules@0.6.2: {} + + whatwg-fetch@3.6.20: {} + + whatwg-url-without-unicode@8.0.0-3: + dependencies: + buffer: 5.7.1 + punycode: 2.3.1 + webidl-conversions: 5.0.0 + + whatwg-url@5.0.0: + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 + + which-typed-array@1.1.20: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.9 + call-bound: 1.0.4 + for-each: 0.3.5 + get-proto: 1.0.1 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + wonka@6.3.6: {} + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 5.1.2 + strip-ansi: 7.2.0 + + wrap-ansi@9.0.2: + dependencies: + ansi-styles: 6.2.3 + string-width: 7.2.0 + strip-ansi: 7.2.0 + + wrappy@1.0.2: {} + + write-file-atomic@4.0.2: + dependencies: + imurmurhash: 0.1.4 + signal-exit: 3.0.7 + + ws@6.2.3: + dependencies: + async-limiter: 1.0.1 + + ws@7.5.10: {} + + ws@8.20.0: {} + + wsl-utils@0.3.1: + dependencies: + is-wsl: 3.1.1 + powershell-utils: 0.1.0 + + xcode@3.0.1: + dependencies: + simple-plist: 1.3.1 + uuid: 7.0.3 + + xml2js@0.6.0: + dependencies: + sax: 1.6.0 + xmlbuilder: 11.0.1 + + xmlbuilder@11.0.1: {} + + xmlbuilder@15.1.1: {} + + xtend@4.0.2: {} + + y18n@5.0.8: {} + + yallist@3.1.1: {} + + yallist@5.0.0: {} + + yargs-parser@21.1.1: {} + + yargs-parser@22.0.0: {} + + yargs@17.7.2: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + + yargs@18.0.0: + dependencies: + cliui: 9.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + string-width: 7.2.0 + y18n: 5.0.8 + yargs-parser: 22.0.0 + + yocto-queue@0.1.0: {} + + youch-core@0.3.3: + dependencies: + '@poppinss/exception': 1.2.3 + error-stack-parser-es: 1.0.5 + + youch@4.1.1: + dependencies: + '@poppinss/colors': 4.1.6 + '@poppinss/dumper': 0.7.0 + '@speed-highlight/core': 1.2.15 + cookie-es: 3.1.1 + youch-core: 0.3.3 + + zeptomatch@2.1.0: + dependencies: + grammex: 3.1.12 + graphmatch: 1.1.1 + + zip-stream@6.0.1: + dependencies: + archiver-utils: 5.0.2 + compress-commons: 6.0.2 + readable-stream: 4.7.0 + + zod@3.25.76: {} + + zustand@5.0.13(@types/react@19.0.14)(react@19.0.0)(use-sync-external-store@1.6.0(react@19.0.0)): + optionalDependencies: + '@types/react': 19.0.14 + react: 19.0.0 + use-sync-external-store: 1.6.0(react@19.0.0) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..bd86d72 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +packages: + - "apps/*" + - "backend"