diff --git a/apps/marketing/app/assets/css/main.css b/apps/marketing/app/assets/css/main.css new file mode 100644 index 0000000..84e05bc --- /dev/null +++ b/apps/marketing/app/assets/css/main.css @@ -0,0 +1,53 @@ +@import "tailwindcss"; +@import "@nuxt/ui"; + +@theme static { + --font-sans: 'Nunito', sans-serif; +} + +/* Smooth Scrolling */ +html { + scroll-behavior: smooth; +} + +/* iOS Zoom-Fix: 16px verhindert Auto-Zoom bei Input-Fokus */ +input, +select, +textarea { + font-size: 16px !important; +} + +/* Verhindert Double-Tap-Zoom auf Buttons und interaktiven Elementen */ +button, a, [role="button"] { + touch-action: manipulation; +} + +/* ═══════════════════════════════════════════════════════════ + PAGE TRANSITIONS – iOS-style slide + ═══════════════════════════════════════════════════════════ */ + +.slide-left-enter-active, +.slide-left-leave-active, +.slide-right-enter-active, +.slide-right-leave-active { + transition: opacity 200ms ease, transform 200ms cubic-bezier(0.25, 0.46, 0.45, 0.94); + will-change: transform, opacity; +} + +.slide-left-enter-from { + opacity: 0; + transform: translateX(20px); +} +.slide-left-leave-to { + opacity: 0; + transform: translateX(-20px); +} + +.slide-right-enter-from { + opacity: 0; + transform: translateX(-20px); +} +.slide-right-leave-to { + opacity: 0; + transform: translateX(20px); +} diff --git a/apps/marketing/app/components/AnimatedCounter.vue b/apps/marketing/app/components/AnimatedCounter.vue new file mode 100644 index 0000000..93d009c --- /dev/null +++ b/apps/marketing/app/components/AnimatedCounter.vue @@ -0,0 +1,57 @@ + + + diff --git a/apps/marketing/app/components/FeatureCard.vue b/apps/marketing/app/components/FeatureCard.vue new file mode 100644 index 0000000..0371e70 --- /dev/null +++ b/apps/marketing/app/components/FeatureCard.vue @@ -0,0 +1,26 @@ + + + diff --git a/apps/marketing/app/components/charts/BlocklistGrowth.vue b/apps/marketing/app/components/charts/BlocklistGrowth.vue new file mode 100644 index 0000000..91dca1c --- /dev/null +++ b/apps/marketing/app/components/charts/BlocklistGrowth.vue @@ -0,0 +1,111 @@ + + + diff --git a/apps/marketing/app/composables/useViewportHeight.ts b/apps/marketing/app/composables/useViewportHeight.ts new file mode 100644 index 0000000..23a736c --- /dev/null +++ b/apps/marketing/app/composables/useViewportHeight.ts @@ -0,0 +1,18 @@ +/** + * Reactive viewport height. + * Vereinfachte Version für Marketing-Site (kein Capacitor/WKWebView-Keyboard-Handling nötig). + */ +export function useViewportHeight() { + const height = ref(globalThis.innerHeight || 800); + + onMounted(() => { + const update = () => { + height.value = window.innerHeight; + }; + window.addEventListener("resize", update); + update(); + onUnmounted(() => window.removeEventListener("resize", update)); + }); + + return { height }; +} diff --git a/apps/marketing/app/layouts/default.vue b/apps/marketing/app/layouts/default.vue new file mode 100644 index 0000000..6832c1c --- /dev/null +++ b/apps/marketing/app/layouts/default.vue @@ -0,0 +1,95 @@ + + + diff --git a/apps/marketing/app/locales/de.json b/apps/marketing/app/locales/de.json new file mode 100644 index 0000000..a6886f1 --- /dev/null +++ b/apps/marketing/app/locales/de.json @@ -0,0 +1,230 @@ +{ + "nav": { + "pricing": "Preise", + "resources": "Hilfe", + "login": "Einloggen", + "download_app": "App laden" + }, + "landing": { + "hero_badge": "Gemeinsam gegen die Gambling-Industrie", + "hero_title": "Millionen kämpfen still.", + "hero_subtitle": "Du musst das nicht allein tun!", + "hero_text": "Gemeinsam sind wir Stark!", + "cta_start": "Jetzt kostenlos starten", + "stat_affected": "Menschen in DE betroffen", + "stat_blocked": "Domains geblockt", + "stat_free": "Zum Starten", + "more_info": "Mehr erfahren", + "blocker_badge": "Gambling Blocker", + "blocker_title_domains": "Domains.", + "blocker_title_activated": "Einmal aktiviert.", + "blocker_desc": "Die umfangreichste Gambling-Blocklist. Täglich aktualisiert. Für alle Plattformen. Ein Cooldown verhindert schwache Momente.", + "blocker_feat_platforms": "Für macOS, iOS, Android & Pi-hole", + "blocker_feat_updated": "Täglich aktualisierte Liste", + "blocker_feat_custom": "Eigene Domains hinzufügen", + "blocker_feat_cooldown": "Cooldown-Schutz vor Rückfällen", + "oasis_badge": "Warum OASIS allein nicht reicht", + "oasis_title": "Täglich neue Casinos –", + "oasis_subtitle": "ohne Lizenz, ohne Sperre.", + "oasis_desc": "Der OASIS-Selbstausschluss sperrt dich nur bei lizenzierten Anbietern. Doch täglich gehen neue Casino-Seiten online – viele ohne Lizenz, viele offshore. Diese Seiten kennen OASIS nicht. ReBreak schützt dich auch dort: mit einer täglich aktualisierten Datenbank von über 208.000 Domains.", + "oasis_new_domains": "neue Gambling-Domains täglich", + "oasis_offshore": "Casinos ohne Lizenz umgehen OASIS komplett", + "oasis_updated": "Domains täglich aktualisiert durch ReBreak", + "streak_badge": "Streak & Ersparnisse", + "streak_title": "Jeden Tag zählt.", + "streak_subtitle": "Sichtbarer Fortschritt.", + "streak_desc": "Sieh wie viele Tage du gewonnen hast – und wie viel Geld du nicht verloren hast. Meilenstein-Badges motivieren weiter.", + "streak_days_free": "Tage frei", + "streak_saved": "gespart", + "crisis_badge": "Krisenmomente meistern", + "crisis_title": "Der Drang kommt.", + "crisis_subtitle": "Du bist vorbereitet.", + "sos_title": "SOS – Sofort-Hilfe", + "sos_subtitle": "Ein Klick. Sofort.", + "sos_desc": "Der Drang dauert im Schnitt nur 15–20 Minuten. ReBreak führt dich Schritt für Schritt durch diesen Moment – bis er vorüber ist.", + "sos_angry": "Wütend", + "sos_sad": "Niedergedrückt", + "sos_stressed": "Gestresst", + "sos_empty": "Leer", + "breathing_title": "4-7-8 Atemübung", + "breathing_subtitle": "Puls senken in 60 Sekunden", + "breathing_desc": "Wissenschaftlich belegt: 4 Sekunden einatmen, 7 halten, 8 ausatmen – der Körper schaltet automatisch in den Ruhemodus.", + "breathing_breathe": "Atme", + "breathing_inhale": "4s einatmen", + "breathing_hold": "7s halten", + "breathing_exhale": "8s ausatmen", + "coach_badge": "Wenn SOS nicht reicht", + "coach_title": "Coach & Community.", + "coach_subtitle": "Immer auf Abruf.", + "coach_desc": "Ein KI-Coach, der dich wirklich kennt – personalisiert, CBT-basiert, ohne Urteil. Und eine echte Community aus Menschen, die verstehen was du durchmachst.", + "coach_label": "KI-Coach", + "founding_badge": "Gründungsmitglied", + "founding_desc": "Die ersten {count} Mitglieder bekommen 1 Monat Standard gratis – automatisch, kein Code nötig.", + "founding_slots": "{current} / {total} Plätze", + "founding_cta": "Jetzt Platz sichern – kostenlos", + "mail_badge": "Mail-Bereinigung", + "mail_title": "Bonus-Mails?", + "mail_subtitle": "Nie gesehen.", + "mail_desc": "Casinos bombardieren dich täglich mit Angeboten und Rabatten. ReBreak verbindet sich mit deinem Postfach und verschiebt diese Mails in den Papierkorb – bevor du sie siehst.", + "mail_feat_providers": "Gmail, GMX, Outlook – alle großen Anbieter", + "mail_feat_intervals": "Echtzeit, stündlich oder alle 4 Stunden", + "mail_feat_privacy": "Keine Mail wird gelesen – nur analysiert", + "mail_mock_blocked": "Blockiert", + "mail_mock_scanned": "Gescannt", + "mail_mock_rate": "Treffer", + "mail_mock_accounts": "Verbundene Konten", + "mail_mock_rhythm": "Automatischer Scan-Rhythmus", + "final_title": "Fang jetzt an.", + "final_desc": "Du bist nicht kaputt. Das System ist manipulativ. Wir helfen dir zurück.", + "final_cta": "Jetzt starten – kostenlos & anonym", + "chat_msg_1": "Ich spüre den Drang wieder stark...", + "chat_msg_2": "Ich verstehe. Was triggert dich gerade? Lass uns das durchgehen.", + "chat_msg_3": "Stress bei der Arbeit.", + "chat_msg_4": "Das ist ein bekanntes Muster. Probier erst die 4-7-8 Übung." + }, + "blocked": { + "lyra": "Lyra", + "title": "Diese Seite ist blockiert", + "message": "ReBreak hat diese Seite für dich gesperrt. Du hast dich entschieden, stark zu sein – und das hier ist der Beweis.", + "day": "Tag", + "days": "Tage", + "clean": "clean", + "streak_running": "Dein Streak läuft. Gib ihn nicht auf.", + "talk_lyra": "Mit Lyra reden", + "start_breathing": "Atemübung starten", + "back_to_app": "Zurück zur App", + "quote_1": "Jede blockierte Seite ist ein Beweis deiner Stärke.", + "quote_2": "Der Drang geht vorbei. Dein Fortschritt bleibt.", + "quote_3": "Du hast diese Seite nicht gebraucht – und du brauchst sie nicht.", + "quote_4": "Stark sein bedeutet, in diesem Moment Nein zu sagen.", + "quote_5": "Das hier ist dein Schutzwall. Du hast ihn aufgebaut." + }, + "resources": { + "blocklist_title": "Community-Blocklist", + "blocklist_desc": "Wächst täglich – von der Community, für die Community. Aktuell {count} Domains blockiert.", + "chart_label": "Blockierte Domains – letzten 12 Monate", + "hotlines_title": "Sofort-Hilfe & Hotlines", + "hotlines_desc": "Kostenlos, anonym, rund um die Uhr erreichbar.", + "tips_title": "Was jetzt hilft", + "tips_desc": "Bewährte Strategien aus der kognitiven Verhaltenstherapie (CBT).", + "not_weak_title": "Du bist nicht schwach", + "not_weak_desc": "Das System ist darauf ausgelegt. Hier ist warum.", + "cta_title": "Bereit für den ersten Schritt?", + "cta_button": "App herunterladen", + "hotline_de": "Deutschland", + "hotline_at": "Österreich", + "hotline_ch": "Schweiz", + "tip_breathing": "4-7-8 Atemübung bei akutem Drang", + "tip_breathing_desc": "4 Sek. einatmen, 7 halten, 8 ausatmen. Aktiviert das parasympathische Nervensystem und bricht den Impulsdrang.", + "tip_15min": "Die 15-Minuten-Regel", + "tip_15min_desc": "Warte 15 Minuten bevor du eine Entscheidung triffst. Gambling-Drang ist eine Welle – sie kommt und geht.", + "tip_move": "Raus und bewegen", + "tip_move_desc": "Ein 10-minütiger Spaziergang setzt Endorphine frei und unterbricht automatisch den Drang-Kreislauf.", + "tip_triggers": "Trigger kennen", + "tip_triggers_desc": "Stress, Langeweile, Abend allein? Wer seine Muster kennt, kann gegensteuern bevor der Drang überwältigt.", + "fact1_title": "Variable Belohnungen aktivieren denselben Kreislauf wie Drogen", + "fact1_text": "Das Nicht-Wissen, ob man gewinnt, schüttet mehr Dopamin aus als ein sicherer Gewinn. Design, kein Zufall.", + "fact2_title": "Online-Casinos sind 24/7 verfügbar – kein natürlicher Stopper", + "fact2_text": "Früher war das Casino physisch. Heute ist es das Handy. Kein Schließtag, keine Scham durch andere.", + "fact3_title": "Virtuelle Währungen verschleiern echten Geldverlust", + "fact3_text": "Chips, Coins, Credits – das Gehirn verarbeitet diese nicht wie Bargeld. Das ist kein Fehler im System.", + "fact4_title": "Die Quote gewinnt immer – mathematisch", + "fact4_text": "Jedes legale Casino hat eingebaute Marge. Langfristig verlieren 100 % der Spieler Geld. Keine Pechsträhne." + }, + "pricing": { + "founding_banner": "Founding Member – Die ersten 100 bekommen 3 Monate Legend gratis", + "title": "Dein Weg, dein Tempo", + "subtitle_start": "Jetzt starten –", + "subtitle_end": "wähle deinen Plan.", + "pro_meaning_title": "Was bedeutet Pro wirklich?", + "pro_meaning_desc": "Mit Pro trägst du aktiv dazu bei, dass die ReBreak Blocklist für alle wächst. Du kannst Domains direkt hinzufügen und Einreichungen anderer Nutzer prüfen. Du leitest Gruppen, hast keinen KI-Gedächtnisverlust – und stehst an der Spitze für alle, die noch kämpfen.", + "comparison_title": "Was ist inklusive?", + "comparison_subtitle": "Vollständiger Vergleich aller Pläne", + "feature": "Feature", + "free": "Kostenlos", + "quotes_title": "Gedanken die helfen", + "quotes_subtitle": "Von Psychologen und Denkern über Selbstschutz und Veränderung", + "faq_title": "Häufige Fragen", + "cta_title": "Bereit anzufangen?", + "cta_desc": "Kostenlos starten, jederzeit upgraden.", + "cta_button": "App herunterladen", + "footer_home": "Home", + "footer_pricing": "Preise", + "footer_resources": "Ressourcen", + "footer_login": "Anmelden", + "billing_monthly": "Monatlich", + "billing_yearly": "Jährlich", + "billing_save_pct": "Spare 39%", + "billing_forever": "für immer", + "billing_per_month": "/ Monat", + "billing_per_year": "/ Monat, jährlich", + "plan_free_title": "Kostenlos", + "plan_free_desc": "Einstieg ohne Risiko – für immer gratis.", + "plan_free_btn": "App herunterladen", + "plan_pro_title": "Pro", + "plan_pro_desc": "Vollständiger Schutz und alle Tools für deinen Alltag.", + "plan_pro_btn": "Pro starten", + "plan_legend_title": "Legend", + "plan_legend_desc": "Für die, die stark genug sind – um anderen den Weg zu ebnen.", + "plan_legend_btn": "Legend starten", + "plan_loading": "Wird geladen...", + "plan_recommended": "Empfohlen", + "feat_free_domains": "5 eigene Domains", + "feat_free_mail": "1 Mail-Agent (Scan alle 4h)", + "feat_coach_basic": "KI-Coach Basis", + "feat_streak": "Streak & Ersparnisse Tracker", + "feat_urge": "Urge Tracker + Atemübung", + "feat_sos": "SOS-Button (Sofort-Hilfe)", + "feat_community": "Gemeinschaft erleben", + "feat_all_free": "Alles aus Kostenlos", + "feat_blocklist": "ReBreak Blocklist (208k+ Domains)", + "feat_pro_domains": "5 eigene Domains (rückfüllbar)", + "feat_pro_mail": "3 Mail-Agenten (Intervall: 1h / 4h / 8h)", + "feat_community_post": "Community posten", + "feat_buddy": "Buddy System", + "feat_coach_pro": "KI-Coach (besser)", + "feat_urge_stats": "Urge-Statistiken & Muster", + "feat_all_pro": "Alles aus Pro", + "feat_legend_domains": "Unbegrenzte eigene Domains (rückfüllbar)", + "feat_legend_mail": "Unbegrenzte Mail-Agenten (Echtzeit)", + "feat_legend_add": "Domains direkt zur ReBreak Blocklist hinzufügen", + "feat_legend_validate": "Community-Domains validieren", + "feat_legend_groups": "Gruppen gründen & leiten", + "feat_coach_legend": "Top KI-Coach mit Gedächtnis", + "comp_domains": "Eigene Domains", + "comp_mail": "Mail-Agent", + "comp_coach": "KI-Coach", + "comp_streak": "Streak & Ersparnisse Tracker", + "comp_urge": "Urge Tracker + Atemübung", + "comp_sos": "SOS-Button (Sofort-Hilfe)", + "comp_community": "Gemeinschaft erleben", + "comp_blocklist": "ReBreak Blocklist (208k+ Domains)", + "comp_post": "Community posten", + "comp_buddy": "Buddy System", + "comp_urge_stats": "Urge-Statistiken & Muster", + "comp_add_domain": "Domains zur Blocklist hinzufügen", + "comp_validate": "Community-Domains validieren", + "comp_groups": "Gruppen gründen & leiten", + "comp_free_domains": "5", + "comp_pro_domains": "5 (rückfüllbar)", + "comp_legend_domains": "Unbegrenzt (rückfüllbar)", + "comp_free_mail_val": "1 (4h)", + "comp_pro_mail_val": "3 (1h / 4h / 8h)", + "comp_legend_mail_val": "Echtzeit", + "comp_free_coach_val": "Basis", + "comp_pro_coach_val": "Besser", + "comp_legend_coach_val": "Top + Gedächtnis", + "faq1_q": "Muss ich eine E-Mail-Adresse angeben?", + "faq1_a": "Ja, für die Registrierung wird eine E-Mail-Adresse benötigt. Deine Daten werden ausschließlich auf deutschen Servern gespeichert und verarbeitet – vollständig anonym, nach strengen DSGVO-Standards. Kein Name, kein Standort, kein Nutzungsverhalten wird an Dritte weitergegeben.", + "faq2_q": "Was ist der Unterschied zwischen Pro und Legend?", + "faq2_a": "Pro gibt dir vollständigen Schutz: ReBreak Blocklist (208k+ Domains), 3 Mail-Agenten, KI-Coach und Community. Legend geht weiter: unbegrenzte Domains und Agenten, direktes Hinzufügen zur Blocklist, Validierung von Community-Domains, Gruppen leiten und Top KI-Coach mit Gedächtnis.", + "faq3_q": "Welche Zahlungszyklen gibt es?", + "faq3_a": "Monatlich (voller Preis) oder jährlich (Spare 39%). Du kannst jederzeit wechseln.", + "faq4_q": "Kann ich jederzeit kündigen?", + "faq4_a": "Ja, du kannst dein Abo jederzeit kündigen. Du behältst den Zugang bis zum Ende der bezahlten Periode.", + "faq5_q": "Was passiert mit meinen Daten wenn ich kündige?", + "faq5_a": "Dein Account und alle Daten bleiben erhalten. Dein Streak und alle Tracker gehören dir – für immer.", + "faq6_q": "Ist ReBreak ein Ersatz für professionelle Hilfe?", + "faq6_a": "Nein. ReBreak ist ein Selbsthilfe-Tool. Bei Krisen: BZgA (0800 1372700) oder Arzt aufsuchen." + } +} diff --git a/apps/marketing/app/locales/en.json b/apps/marketing/app/locales/en.json new file mode 100644 index 0000000..db87ff6 --- /dev/null +++ b/apps/marketing/app/locales/en.json @@ -0,0 +1,230 @@ +{ + "nav": { + "pricing": "Pricing", + "resources": "Help", + "login": "Login", + "download_app": "Get the App" + }, + "landing": { + "hero_badge": "Together against the gambling industry", + "hero_title": "Millions fight in silence.", + "hero_subtitle": "You don't have to do it alone!", + "hero_text": "Together we are strong!", + "cta_start": "Start free now", + "stat_affected": "People in DE affected", + "stat_blocked": "Domains blocked", + "stat_free": "To start", + "more_info": "Learn more", + "blocker_badge": "Gambling Blocker", + "blocker_title_domains": "Domains.", + "blocker_title_activated": "Once activated.", + "blocker_desc": "The most comprehensive gambling blocklist. Updated daily. For all platforms. A cooldown prevents weak moments.", + "blocker_feat_platforms": "For macOS, iOS, Android & Pi-hole", + "blocker_feat_updated": "Daily updated list", + "blocker_feat_custom": "Add custom domains", + "blocker_feat_cooldown": "Cooldown protection against relapses", + "oasis_badge": "Why OASIS alone isn't enough", + "oasis_title": "New casinos daily –", + "oasis_subtitle": "without license, without ban.", + "oasis_desc": "The OASIS self-exclusion only blocks you at licensed providers. But new casino sites go online daily – many without a license, many offshore. These sites don't know OASIS. ReBreak protects you there too: with a daily updated database of over 208,000 domains.", + "oasis_new_domains": "new gambling domains daily", + "oasis_offshore": "Casinos without license bypass OASIS completely", + "oasis_updated": "Domains updated daily by ReBreak", + "streak_badge": "Streak & Savings", + "streak_title": "Every day counts.", + "streak_subtitle": "Visible progress.", + "streak_desc": "See how many days you've won – and how much money you haven't lost. Milestone badges keep you motivated.", + "streak_days_free": "Days free", + "streak_saved": "saved", + "crisis_badge": "Mastering crisis moments", + "crisis_title": "The urge comes.", + "crisis_subtitle": "You are prepared.", + "sos_title": "SOS – Instant Help", + "sos_subtitle": "One click. Instant.", + "sos_desc": "The urge lasts on average only 15–20 minutes. ReBreak guides you step by step through this moment – until it passes.", + "sos_angry": "Angry", + "sos_sad": "Depressed", + "sos_stressed": "Stressed", + "sos_empty": "Empty", + "breathing_title": "4-7-8 Breathing Exercise", + "breathing_subtitle": "Lower pulse in 60 seconds", + "breathing_desc": "Scientifically proven: breathe in for 4 seconds, hold for 7, breathe out for 8 – the body automatically switches to rest mode.", + "breathing_breathe": "Breathe", + "breathing_inhale": "4s inhale", + "breathing_hold": "7s hold", + "breathing_exhale": "8s exhale", + "coach_badge": "When SOS isn't enough", + "coach_title": "Coach & Community.", + "coach_subtitle": "Always on call.", + "coach_desc": "An AI coach that truly knows you – personalized, CBT-based, without judgment. And a real community of people who understand what you're going through.", + "coach_label": "AI Coach", + "founding_badge": "Founding Member", + "founding_desc": "The first {count} members get 1 month Standard free – automatically, no code needed.", + "founding_slots": "{current} / {total} Spots", + "founding_cta": "Secure your spot – free", + "mail_badge": "Mail Cleanup", + "mail_title": "Bonus emails?", + "mail_subtitle": "Never seen.", + "mail_desc": "Casinos bombard you daily with offers and discounts. ReBreak connects to your inbox and moves these emails to trash – before you see them.", + "mail_feat_providers": "Gmail, GMX, Outlook – all major providers", + "mail_feat_intervals": "Real-time, hourly or every 4 hours", + "mail_feat_privacy": "No email is read – only analyzed", + "mail_mock_blocked": "Blocked", + "mail_mock_scanned": "Scanned", + "mail_mock_rate": "Hit rate", + "mail_mock_accounts": "Connected accounts", + "mail_mock_rhythm": "Automatic scan rhythm", + "final_title": "Start now.", + "final_desc": "You're not broken. The system is manipulative. We help you back.", + "final_cta": "Start now – free & anonymous", + "chat_msg_1": "I feel the urge strongly again...", + "chat_msg_2": "I understand. What's triggering you right now? Let's go through this.", + "chat_msg_3": "Stress at work.", + "chat_msg_4": "That's a known pattern. Try the 4-7-8 exercise first." + }, + "blocked": { + "lyra": "Lyra", + "title": "This site is blocked", + "message": "ReBreak blocked this site for you. You chose to be strong – and this is the proof.", + "day": "Day", + "days": "Days", + "clean": "clean", + "streak_running": "Your streak is running. Don't give it up.", + "talk_lyra": "Talk to Lyra", + "start_breathing": "Start breathing exercise", + "back_to_app": "Back to app", + "quote_1": "Every blocked site is proof of your strength.", + "quote_2": "The urge passes. Your progress stays.", + "quote_3": "You didn't need this site – and you don't need it.", + "quote_4": "Being strong means saying no in this moment.", + "quote_5": "This is your wall of protection. You built it." + }, + "resources": { + "blocklist_title": "Community Blocklist", + "blocklist_desc": "Growing daily – by the community, for the community. Currently {count} domains blocked.", + "chart_label": "Blocked domains – last 12 months", + "hotlines_title": "Instant Help & Hotlines", + "hotlines_desc": "Free, anonymous, available 24/7.", + "tips_title": "What helps now", + "tips_desc": "Proven strategies from cognitive behavioral therapy (CBT).", + "not_weak_title": "You are not weak", + "not_weak_desc": "The system is designed this way. Here's why.", + "cta_title": "Ready for the first step?", + "cta_button": "Download the App", + "hotline_de": "Germany", + "hotline_at": "Austria", + "hotline_ch": "Switzerland", + "tip_breathing": "4-7-8 breathing exercise for acute urges", + "tip_breathing_desc": "Inhale 4 sec, hold 7, exhale 8. Activates the parasympathetic nervous system and breaks the impulse.", + "tip_15min": "The 15-minute rule", + "tip_15min_desc": "Wait 15 minutes before making a decision. Gambling urge is a wave – it comes and goes.", + "tip_move": "Get out and move", + "tip_move_desc": "A 10-minute walk releases endorphins and automatically interrupts the urge cycle.", + "tip_triggers": "Know your triggers", + "tip_triggers_desc": "Stress, boredom, evening alone? Those who know their patterns can counteract before the urge overwhelms.", + "fact1_title": "Variable rewards activate the same circuit as drugs", + "fact1_text": "Not knowing if you'll win releases more dopamine than a certain win. Design, not accident.", + "fact2_title": "Online casinos are available 24/7 – no natural stopper", + "fact2_text": "The casino used to be physical. Today it's your phone. No closing day, no shame from others.", + "fact3_title": "Virtual currencies obscure real money loss", + "fact3_text": "Chips, coins, credits – the brain doesn't process these like cash. That's not a bug in the system.", + "fact4_title": "The house always wins – mathematically", + "fact4_text": "Every legal casino has a built-in margin. Long-term, 100% of players lose money. No bad luck streak." + }, + "pricing": { + "founding_banner": "Founding Member – First 100 get 3 months Legend free", + "title": "Your path, your pace", + "subtitle_start": "Start now –", + "subtitle_end": "choose your plan.", + "pro_meaning_title": "What does Pro really mean?", + "pro_meaning_desc": "With Pro you actively contribute to growing the ReBreak blocklist for everyone. You can add domains directly and review submissions from other users. You lead groups, have no AI memory loss – and stand at the forefront for everyone still fighting.", + "comparison_title": "What's included?", + "comparison_subtitle": "Complete comparison of all plans", + "feature": "Feature", + "free": "Free", + "quotes_title": "Thoughts that help", + "quotes_subtitle": "From psychologists and thinkers on self-protection and change", + "faq_title": "Frequently Asked Questions", + "cta_title": "Ready to start?", + "cta_desc": "Start free, upgrade anytime.", + "cta_button": "Download the App", + "footer_home": "Home", + "footer_pricing": "Pricing", + "footer_resources": "Resources", + "footer_login": "Login", + "billing_monthly": "Monthly", + "billing_yearly": "Yearly", + "billing_save_pct": "Save 39%", + "billing_forever": "forever", + "billing_per_month": "/ month", + "billing_per_year": "/ month, billed yearly", + "plan_free_title": "Free", + "plan_free_desc": "Get started with no risk – free forever.", + "plan_free_btn": "Download App", + "plan_pro_title": "Pro", + "plan_pro_desc": "Full protection and all tools for your daily life.", + "plan_pro_btn": "Start Pro", + "plan_legend_title": "Legend", + "plan_legend_desc": "For those strong enough to light the way for others.", + "plan_legend_btn": "Start Legend", + "plan_loading": "Loading...", + "plan_recommended": "Recommended", + "feat_free_domains": "5 custom domains", + "feat_free_mail": "1 mail agent (scan every 4h)", + "feat_coach_basic": "AI Coach Basic", + "feat_streak": "Streak & Savings Tracker", + "feat_urge": "Urge Tracker + Breathing Exercise", + "feat_sos": "SOS Button (Instant Help)", + "feat_community": "Experience the community", + "feat_all_free": "Everything in Free", + "feat_blocklist": "ReBreak Blocklist (208k+ domains)", + "feat_pro_domains": "5 custom domains (refillable)", + "feat_pro_mail": "3 mail agents (interval: 1h / 4h / 8h)", + "feat_community_post": "Post in community", + "feat_buddy": "Buddy System", + "feat_coach_pro": "AI Coach (Better)", + "feat_urge_stats": "Urge statistics & patterns", + "feat_all_pro": "Everything in Pro", + "feat_legend_domains": "Unlimited custom domains (refillable)", + "feat_legend_mail": "Unlimited mail agents (real-time)", + "feat_legend_add": "Add domains directly to the ReBreak Blocklist", + "feat_legend_validate": "Validate community domains", + "feat_legend_groups": "Create & lead groups", + "feat_coach_legend": "Top AI Coach with memory", + "comp_domains": "Custom Domains", + "comp_mail": "Mail Agent", + "comp_coach": "AI Coach", + "comp_streak": "Streak & Savings Tracker", + "comp_urge": "Urge Tracker + Breathing", + "comp_sos": "SOS Button (Instant Help)", + "comp_community": "Experience community", + "comp_blocklist": "ReBreak Blocklist (208k+ domains)", + "comp_post": "Post in community", + "comp_buddy": "Buddy System", + "comp_urge_stats": "Urge statistics & patterns", + "comp_add_domain": "Add domains to blocklist", + "comp_validate": "Validate community domains", + "comp_groups": "Create & lead groups", + "comp_free_domains": "5", + "comp_pro_domains": "5 (refillable)", + "comp_legend_domains": "Unlimited (refillable)", + "comp_free_mail_val": "1 (4h)", + "comp_pro_mail_val": "3 (1h / 4h / 8h)", + "comp_legend_mail_val": "Real-time", + "comp_free_coach_val": "Basic", + "comp_pro_coach_val": "Better", + "comp_legend_coach_val": "Top + Memory", + "faq1_q": "Do I need to provide an email address?", + "faq1_a": "Yes, an email address is required for registration. Your data is stored and processed exclusively on German servers – fully anonymously, according to strict GDPR standards.", + "faq2_q": "What's the difference between Pro and Legend?", + "faq2_a": "Pro gives you full protection: ReBreak Blocklist (208k+ domains), 3 mail agents, AI Coach and community. Legend goes further: unlimited domains, direct blocklist additions, domain validation, group leadership and top AI Coach with memory.", + "faq3_q": "What billing cycles are available?", + "faq3_a": "Monthly (full price) or yearly (save 39%). You can switch at any time.", + "faq4_q": "Can I cancel at any time?", + "faq4_a": "Yes, you can cancel your subscription at any time. You keep access until the end of the paid period.", + "faq5_q": "What happens to my data when I cancel?", + "faq5_a": "Your account and all data remain intact. Your streak and all trackers belong to you – forever.", + "faq6_q": "Is ReBreak a substitute for professional help?", + "faq6_a": "No. ReBreak is a self-help tool. In crises: contact a professional or call a helpline." + } +} diff --git a/apps/marketing/app/pages/account-loeschen.vue b/apps/marketing/app/pages/account-loeschen.vue new file mode 100644 index 0000000..dee8bdc --- /dev/null +++ b/apps/marketing/app/pages/account-loeschen.vue @@ -0,0 +1,120 @@ + + + diff --git a/apps/marketing/app/pages/blocked.vue b/apps/marketing/app/pages/blocked.vue new file mode 100644 index 0000000..ad4dbc8 --- /dev/null +++ b/apps/marketing/app/pages/blocked.vue @@ -0,0 +1,81 @@ + + + diff --git a/apps/marketing/app/pages/datenschutz.vue b/apps/marketing/app/pages/datenschutz.vue new file mode 100644 index 0000000..798c8bf --- /dev/null +++ b/apps/marketing/app/pages/datenschutz.vue @@ -0,0 +1,397 @@ + + + diff --git a/apps/marketing/app/pages/download/android.vue b/apps/marketing/app/pages/download/android.vue new file mode 100644 index 0000000..2361918 --- /dev/null +++ b/apps/marketing/app/pages/download/android.vue @@ -0,0 +1,104 @@ + + + diff --git a/apps/marketing/app/pages/impressum.vue b/apps/marketing/app/pages/impressum.vue new file mode 100644 index 0000000..5d9b99a --- /dev/null +++ b/apps/marketing/app/pages/impressum.vue @@ -0,0 +1,131 @@ + + + diff --git a/apps/marketing/app/pages/index.vue b/apps/marketing/app/pages/index.vue new file mode 100644 index 0000000..2e632c2 --- /dev/null +++ b/apps/marketing/app/pages/index.vue @@ -0,0 +1,449 @@ + + + diff --git a/apps/marketing/app/pages/nutzungsbedingungen.vue b/apps/marketing/app/pages/nutzungsbedingungen.vue new file mode 100644 index 0000000..3e1c4fc --- /dev/null +++ b/apps/marketing/app/pages/nutzungsbedingungen.vue @@ -0,0 +1,251 @@ + + + diff --git a/apps/marketing/app/pages/pricing.vue b/apps/marketing/app/pages/pricing.vue new file mode 100644 index 0000000..a8b4863 --- /dev/null +++ b/apps/marketing/app/pages/pricing.vue @@ -0,0 +1,312 @@ + + + diff --git a/apps/marketing/app/pages/resources.vue b/apps/marketing/app/pages/resources.vue new file mode 100644 index 0000000..70746ea --- /dev/null +++ b/apps/marketing/app/pages/resources.vue @@ -0,0 +1,212 @@ + + + diff --git a/apps/marketing/dist b/apps/marketing/dist new file mode 120000 index 0000000..567bb91 --- /dev/null +++ b/apps/marketing/dist @@ -0,0 +1 @@ +/Users/chahinebrini/mono/rebreak-monorepo/apps/marketing/.output/public \ No newline at end of file diff --git a/apps/marketing/nuxt.config.ts b/apps/marketing/nuxt.config.ts new file mode 100644 index 0000000..4447701 --- /dev/null +++ b/apps/marketing/nuxt.config.ts @@ -0,0 +1,72 @@ +export default defineNuxtConfig({ + compatibilityDate: "2025-07-15", + devtools: { enabled: false }, + + // SPA-mode: statisch servierbar via nginx try_files /index.html + ssr: false, + + app: { + htmlAttrs: { lang: "de" }, + head: { + meta: [ + { + name: "viewport", + content: "width=device-width, initial-scale=1", + }, + ], + }, + }, + + modules: [ + "@nuxt/ui", + "@nuxt/image", + "@nuxt/fonts", + "@nuxt/icon", + "@nuxtjs/i18n", + "@vueuse/motion/nuxt", + "@vueuse/nuxt", + ], + + fonts: { + families: [{ name: "Nunito", provider: "google" }], + }, + + i18n: { + locales: [ + { code: "de", name: "Deutsch", dir: "ltr", file: "de.json" }, + { code: "en", name: "English", dir: "ltr", file: "en.json" }, + ], + defaultLocale: "de", + strategy: "no_prefix", + // restructureDir:false verhindert dass i18n v9 den Nuxt-4-Default-Prefix + // "i18n/" vor langDir stellt. Ohne das würde es unter {rootDir}/i18n/locales/ suchen. + restructureDir: false, + langDir: "locales", + detectBrowserLanguage: { + useCookie: true, + cookieKey: "rebreak_lang", + cookieSecure: false, + fallbackLocale: "de", + redirectOn: "root", + }, + }, + + colorMode: { + preference: "dark", + fallback: "dark", + }, + + css: ["~/assets/css/main.css"], + + devServer: { + port: 3020, + }, + + runtimeConfig: { + public: { + // Backend-API für public endpoints (Blocklist-Count etc.) + // Staging: api.staging.rebreak.org | Prod: api.rebreak.org + apiBase: process.env.NUXT_PUBLIC_API_BASE ?? "https://api.staging.rebreak.org", + }, + }, +}); diff --git a/apps/marketing/package.json b/apps/marketing/package.json new file mode 100644 index 0000000..eee70ac --- /dev/null +++ b/apps/marketing/package.json @@ -0,0 +1,33 @@ +{ + "name": "@rebreak/marketing", + "type": "module", + "private": true, + "version": "0.1.0", + "scripts": { + "dev": "nuxt dev --port 3020", + "build": "nuxt build", + "generate": "nuxt generate", + "preview": "nuxt preview", + "postinstall": "nuxt prepare" + }, + "dependencies": { + "@iconify-json/heroicons": "^1.2.3", + "@nuxt/fonts": "^0.11.4", + "@nuxt/icon": "^1.10.0", + "@nuxt/image": "^1.11.0", + "@nuxt/ui": "^4.5.1", + "@nuxtjs/i18n": "^9.5.6", + "@vueuse/motion": "^3.0.3", + "@vueuse/nuxt": "^14.2.1", + "chart.js": "^4.5.1", + "nuxt": "4.1.3", + "tailwindcss": "^4.1.18", + "vue": "^3.5.22", + "vue-chartjs": "^5.3.3", + "vue-router": "^4.5.1" + }, + "devDependencies": { + "@nuxt/devtools": "latest", + "typescript": "^5.9.3" + } +} diff --git a/apps/marketing/public/alert.svg b/apps/marketing/public/alert.svg new file mode 100644 index 0000000..75e4a8d --- /dev/null +++ b/apps/marketing/public/alert.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + diff --git a/apps/marketing/public/astronaut.svg b/apps/marketing/public/astronaut.svg new file mode 100644 index 0000000..f05a4cc --- /dev/null +++ b/apps/marketing/public/astronaut.svg @@ -0,0 +1 @@ + diff --git a/apps/marketing/public/brain.svg b/apps/marketing/public/brain.svg new file mode 100644 index 0000000..8c43b21 --- /dev/null +++ b/apps/marketing/public/brain.svg @@ -0,0 +1,54 @@ + + + + + + + + + + diff --git a/apps/marketing/public/determination.svg b/apps/marketing/public/determination.svg new file mode 100644 index 0000000..ac5055e --- /dev/null +++ b/apps/marketing/public/determination.svg @@ -0,0 +1 @@ + diff --git a/apps/marketing/public/diary.svg b/apps/marketing/public/diary.svg new file mode 100644 index 0000000..957ad9c --- /dev/null +++ b/apps/marketing/public/diary.svg @@ -0,0 +1 @@ + diff --git a/apps/marketing/public/disruption.svg b/apps/marketing/public/disruption.svg new file mode 100644 index 0000000..47f1dc9 --- /dev/null +++ b/apps/marketing/public/disruption.svg @@ -0,0 +1,2 @@ + + diff --git a/apps/marketing/public/encrypted.svg b/apps/marketing/public/encrypted.svg new file mode 100644 index 0000000..30423b3 --- /dev/null +++ b/apps/marketing/public/encrypted.svg @@ -0,0 +1 @@ + diff --git a/apps/marketing/public/graph.svg b/apps/marketing/public/graph.svg new file mode 100644 index 0000000..fd6143e --- /dev/null +++ b/apps/marketing/public/graph.svg @@ -0,0 +1 @@ + diff --git a/apps/marketing/public/kidneys.svg b/apps/marketing/public/kidneys.svg new file mode 100644 index 0000000..668f801 --- /dev/null +++ b/apps/marketing/public/kidneys.svg @@ -0,0 +1,56 @@ + + + + + + + + diff --git a/apps/marketing/public/logo.svg b/apps/marketing/public/logo.svg new file mode 100644 index 0000000..4516f5d --- /dev/null +++ b/apps/marketing/public/logo.svg @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/marketing/public/phone-call.svg b/apps/marketing/public/phone-call.svg new file mode 100644 index 0000000..bf4379a --- /dev/null +++ b/apps/marketing/public/phone-call.svg @@ -0,0 +1,32 @@ + + + + + + + + + + + + + diff --git a/apps/marketing/public/snowflake.svg b/apps/marketing/public/snowflake.svg new file mode 100644 index 0000000..920fd76 --- /dev/null +++ b/apps/marketing/public/snowflake.svg @@ -0,0 +1 @@ + diff --git a/apps/marketing/public/walk.svg b/apps/marketing/public/walk.svg new file mode 100644 index 0000000..7d5506c --- /dev/null +++ b/apps/marketing/public/walk.svg @@ -0,0 +1 @@ + diff --git a/apps/rebreak-native/app/_layout.tsx b/apps/rebreak-native/app/_layout.tsx index a52d4df..ba758ef 100644 --- a/apps/rebreak-native/app/_layout.tsx +++ b/apps/rebreak-native/app/_layout.tsx @@ -3,6 +3,7 @@ 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 { KeyboardProvider } from 'react-native-keyboard-controller'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { SafeAreaProvider } from 'react-native-safe-area-context'; import { ActionSheetProvider } from '@expo/react-native-action-sheet'; @@ -158,13 +159,15 @@ function RootLayoutInner() { export default function RootLayout() { return ( - - - - - - - + + + + + + + + + ); } diff --git a/apps/rebreak-native/app/dm.tsx b/apps/rebreak-native/app/dm.tsx index 67131fc..3fee143 100644 --- a/apps/rebreak-native/app/dm.tsx +++ b/apps/rebreak-native/app/dm.tsx @@ -18,6 +18,7 @@ import { useTranslation } from 'react-i18next'; import { apiFetch } from '../lib/api'; import { supabase } from '../lib/supabase'; import { ChatBubble, type ChatMsg } from '../components/chat/ChatBubble'; +import { resolveAvatar } from '../lib/resolveAvatar'; import { ChatInput, type SendPayload } from '../components/chat/ChatInput'; import { useDmRealtime } from '../hooks/useChatRealtime'; import { useColors } from '../lib/theme'; @@ -241,7 +242,7 @@ export default function DmScreen() { {partner?.avatar ? ( - + ) : ( {(partner?.nickname ?? '?').slice(0, 2).toUpperCase()} diff --git a/apps/rebreak-native/app/games.tsx b/apps/rebreak-native/app/games.tsx index 8759025..c9a6d36 100644 --- a/apps/rebreak-native/app/games.tsx +++ b/apps/rebreak-native/app/games.tsx @@ -103,9 +103,9 @@ export default function GamesScreen() { - - {t(GAME_META.find((g) => g.id === active)!.titleKey)} - + {/* Title bewusst entfernt — der Game-Picker hat das Spiel schon ausgewählt, + Wiederholung im Header lenkt nur ab. Spacer balanciert den Back-Button. */} + diff --git a/apps/rebreak-native/app/urge.tsx b/apps/rebreak-native/app/urge.tsx index 1f18b41..f8910de 100644 --- a/apps/rebreak-native/app/urge.tsx +++ b/apps/rebreak-native/app/urge.tsx @@ -240,8 +240,8 @@ export default function SOSScreen() { const session = (await supabase.auth.getSession()).data.session; if (controller.signal.aborted) return null; const apiBase = Constants.expoConfig?.extra?.apiUrl as string; - const endpoint = endpointForProvider(currentProvider()); - const isGoogleCloud = endpoint.endsWith('/speak-google'); + const endpoint = '/api/coach/speak'; + const isGoogleCloud = false; const ttsRes = await fetch(`${apiBase}${endpoint}`, { method: 'POST', headers: { @@ -444,7 +444,7 @@ export default function SOSScreen() { apiBase, accessToken: session.access_token, locale: i18n.language, - endpoint: endpointForProvider(currentProvider()), + endpoint: '/api/coach/speak', onStart: () => { setIsSpeaking(true); setIsTtsLoading(false); }, onIdle: () => { setIsSpeaking(false); setIsTtsLoading(false); scheduleEmotionReset(0); bench.print(); }, onError: (err, sentence) => { @@ -631,7 +631,7 @@ export default function SOSScreen() { apiBase, accessToken: session.access_token, locale: i18n.language, - endpoint: endpointForProvider(currentProvider()), + endpoint: '/api/coach/speak', onStart: () => { setIsSpeaking(true); setIsTtsLoading(false); }, onIdle: () => { setIsSpeaking(false); setIsTtsLoading(false); scheduleEmotionReset(0); greetingBench.print(); }, onError: (err, sentence) => { @@ -1151,10 +1151,10 @@ export default function SOSScreen() { - {playingGame === 'memory' && } - {playingGame === 'tictactoe' && } - {playingGame === 'snake' && } - {playingGame === 'tetris' && } + {playingGame === 'memory' && } + {playingGame === 'tictactoe' && } + {playingGame === 'snake' && } + {playingGame === 'tetris' && } ) : ( diff --git a/apps/rebreak-native/components/DeviceLimitReachedSheet.tsx b/apps/rebreak-native/components/DeviceLimitReachedSheet.tsx index 1d57e12..29f8375 100644 --- a/apps/rebreak-native/components/DeviceLimitReachedSheet.tsx +++ b/apps/rebreak-native/components/DeviceLimitReachedSheet.tsx @@ -192,7 +192,7 @@ export function DeviceLimitReachedSheet() { lineHeight: 20, }} > - {t('device_limit.subtitle', { max, plan: plan.toUpperCase() })} + {t('device_limit.subtitle', { count: devices.length, max, plan: plan.toUpperCase() })} + * + * {form content} + * + * + * ``` + * + * Siehe `EditMailAccountSheet.tsx` für vollständiges Sheet-Pattern. + * + * Anti-Pattern: KeyboardAvoidingView mit `behavior="padding"` greift bei + * Vollbild-Layouts mit `paddingTop: insets.top` nicht — siehe + * `docs/internal/RECOVERY_LOG_2026-05-10.md` §7.2. + */ +export interface KeyboardAdjustedViewProps { + children: ReactNode; + /** Style für den ScrollView (outer container). */ + style?: StyleProp; + /** Style für den ScrollView-Inhalt (Padding gehört hier rein, nicht in `style`). */ + contentContainerStyle?: StyleProp; + /** Extra Padding bottom on top of keyboard height (z.B. wenn fixed CTA-Bar drüber sitzt). */ + extraBottomOffset?: number; + /** Default 'handled' — Tap auf nicht-Input-Bereich schließt Keyboard. */ + keyboardShouldPersistTaps?: 'always' | 'never' | 'handled'; +} + +export function KeyboardAdjustedView({ + children, + style, + contentContainerStyle, + extraBottomOffset = 0, + keyboardShouldPersistTaps = 'handled', +}: KeyboardAdjustedViewProps) { + const keyboardHeight = useKeyboardHeight(); + const bottomPad = keyboardHeight > 0 ? keyboardHeight + extraBottomOffset : 0; + + return ( + + {children} + + ); +} diff --git a/apps/rebreak-native/components/KeyboardAwareSheet.tsx b/apps/rebreak-native/components/KeyboardAwareSheet.tsx new file mode 100644 index 0000000..85d33e1 --- /dev/null +++ b/apps/rebreak-native/components/KeyboardAwareSheet.tsx @@ -0,0 +1,222 @@ +import { ReactNode, useEffect, useRef, useState } from 'react'; +import { + Animated, + Easing, + Keyboard, + Modal, + Platform, + Pressable, + StyleProp, + View, + ViewStyle, +} from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { useColors } from '../lib/theme'; + +/** + * Universal-Bottom-Sheet für Forms mit TextInput. + * + * Pattern (verifiziert auf PostCommentsSheet + EditMailAccountSheet): + * + * 1. Outer-Animated.View hat animated `height` (JS-driver) — Sheet WÄCHST + * bei Tastatur-Open um genau die Tastatur-Höhe. + * 2. Inner-Animated.View hat `transform: translateY` (Native-driver) — + * Slide-In/Out smooth. Driver-Mix-Trennung verhindert + * "Style property 'height' is not supported by native animated module"-Crash. + * 3. iOS: `paddingBottom: keyboardHeight` shifted Form innerhalb des + * gewachsenen Sheets über die Tastatur. Android: `windowSoftInputMode=adjustResize` + * im Manifest schrumpft das Window selbst. + * 4. Flex-Spacer drückt `children` (Form) automatisch an den Sheet-Bottom-Edge — + * sitzt direkt über der Tastatur ohne Gap. + * + * Anti-Pattern (siehe `docs/internal/RECOVERY_LOG_2026-05-10.md` §7.2): + * - `useKeyboardAnimation()` aus `react-native-keyboard-controller` liefert + * in iOS-Modals keine Höhe (separate UIWindow). Hier: plain RN + * `Keyboard.addListener` für die Höhe. + * - `Animated.subtract`/`marginBottom: keyboardHeight` mischen JS+Native-Driver + * auf demselben View → Bouncing oder Crash. + * + * Usage: + * ```tsx + * } + * > + * + * + * + * + * + * ``` + */ +export interface KeyboardAwareSheetProps { + visible: boolean; + onClose: () => void; + /** Sheet-Höhe wenn Tastatur zu. Eng auf Inhalt zuschneiden — typisch 220-340px. */ + collapsedHeight: number; + /** Optionaler Header (Cancel/Title-Row). Rendert direkt unter dem Drag-Handle. */ + header?: ReactNode; + /** Form-Inhalt. Wird per Flex-Spacer an den Sheet-Bottom gedrückt — sitzt + * damit direkt über der Tastatur sobald die offen ist. */ + children: ReactNode; + /** Default true — Tap auf Backdrop schließt das Sheet. */ + dismissOnBackdrop?: boolean; + /** Default true — kleiner Drag-Handle ganz oben am Sheet. */ + showDragHandle?: boolean; + /** Default true — fügt unten eine Safe-Area-Spacer-Höhe ein (insets.bottom). */ + showSafeAreaSpacer?: boolean; + /** Default true — interner Flex-Spacer drückt children zum Sheet-Bottom. + * Auf false setzen wenn der Inhalt seine eigene Scroll-/Flex-Logik hat + * (z.B. ScrollView mit Provider-Grid, Listen). */ + pushChildrenToBottom?: boolean; + /** Border-Radius oben. Default 20. */ + topRadius?: number; + /** Optional zusätzlicher Style für den Sheet-Container. */ + containerStyle?: StyleProp; +} + +export function KeyboardAwareSheet({ + visible, + onClose, + collapsedHeight, + header, + children, + dismissOnBackdrop = true, + showDragHandle = true, + showSafeAreaSpacer = true, + pushChildrenToBottom = true, + topRadius = 20, + containerStyle, +}: KeyboardAwareSheetProps) { + const colors = useColors(); + const insets = useSafeAreaInsets(); + + const slideY = useRef(new Animated.Value(collapsedHeight)).current; + const backdropOpacity = useRef(new Animated.Value(0)).current; + const sheetHeight = useRef(new Animated.Value(collapsedHeight)).current; + const [keyboardHeight, setKeyboardHeight] = useState(0); + + // Slide-In + Backdrop-Fade bei `visible=true` + useEffect(() => { + if (visible) { + slideY.setValue(collapsedHeight); + backdropOpacity.setValue(0); + Animated.parallel([ + Animated.timing(slideY, { + toValue: 0, + duration: 280, + easing: Easing.out(Easing.cubic), + useNativeDriver: true, + }), + Animated.timing(backdropOpacity, { + toValue: 1, + duration: 220, + useNativeDriver: true, + }), + ]).start(); + } + }, [visible, slideY, backdropOpacity, collapsedHeight]); + + // Sheet-Höhe wächst/schrumpft mit Tastatur + useEffect(() => { + const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow'; + const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide'; + const showSub = Keyboard.addListener(showEvent, (e) => { + const h = e.endCoordinates.height; + setKeyboardHeight(h); + Animated.timing(sheetHeight, { + toValue: collapsedHeight + h, + duration: Platform.OS === 'ios' ? (e.duration ?? 250) : 220, + easing: Easing.out(Easing.cubic), + useNativeDriver: false, + }).start(); + }); + const hideSub = Keyboard.addListener(hideEvent, (e) => { + setKeyboardHeight(0); + Animated.timing(sheetHeight, { + toValue: collapsedHeight, + duration: Platform.OS === 'ios' ? (e?.duration ?? 250) : 220, + easing: Easing.out(Easing.cubic), + useNativeDriver: false, + }).start(); + }); + return () => { + showSub.remove(); + hideSub.remove(); + }; + }, [sheetHeight, collapsedHeight]); + + return ( + + {/* Backdrop */} + + {dismissOnBackdrop && } + + + {/* Outer: animated height (JS-driver) */} + + {/* Inner: animated transform (Native-driver). Driver-Mix vermeiden + durch zwei verschachtelte Animated.Views. */} + + + {showDragHandle && ( + + + + )} + {header} + {pushChildrenToBottom ? ( + <> + {/* Flex-Spacer drückt children an den Sheet-Bottom */} + + {children} + + ) : ( + {children} + )} + {showSafeAreaSpacer && } + + + + + ); +} diff --git a/apps/rebreak-native/components/blocker/AddDomainSheet.tsx b/apps/rebreak-native/components/blocker/AddDomainSheet.tsx index eacf8ef..25eb27c 100644 --- a/apps/rebreak-native/components/blocker/AddDomainSheet.tsx +++ b/apps/rebreak-native/components/blocker/AddDomainSheet.tsx @@ -1,17 +1,11 @@ -import { useState, useEffect, useRef } from 'react'; +import { useState } 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'; @@ -22,9 +16,9 @@ import { type Tier, } from '../../hooks/useCustomDomains'; import { useColors } from '../../lib/theme'; +import { KeyboardAwareSheet } from '../KeyboardAwareSheet'; -const SCREEN_HEIGHT = Dimensions.get('window').height; -const SHEET_HEIGHT = SCREEN_HEIGHT * 0.65; // wie bei PostCommentsSheet — 65% der Screen-Höhe +const COLLAPSED_HEIGHT = 600; type Props = { visible: boolean; @@ -45,30 +39,6 @@ export function AddDomainSheet({ visible, tier, onClose, onAdd }: Props) { 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); @@ -98,260 +68,226 @@ export function AddDomainSheet({ visible, tier, onClose, onAdd }: Props) { ? t('blocker.add_sheet_warning_free') : t('blocker.add_sheet_warning_pro'); + const header = ( + + + + {t('common.cancel')} + + + + {t('blocker.add_sheet_title')} + + + + ); + return ( - - {/* Backdrop — Tap-outside schließt */} - - - + + + {/* Input */} + + + {t('blocker.add_sheet_label')} + + { + setInput(v); + setError(null); + }} + placeholder={t('blocker.add_sheet_placeholder')} + placeholderTextColor={colors.textMuted} + autoCapitalize="none" + autoCorrect={false} + autoFocus + keyboardType="url" + returnKeyType="done" + onSubmitEditing={handleAdd} + style={{ + backgroundColor: colors.surfaceElevated, + borderRadius: 12, + paddingHorizontal: 14, + paddingVertical: 12, + fontSize: 15, + fontFamily: 'Nunito_400Regular', + color: colors.text, + }} + /> + {input && !valid && ( + + {t('blocker.add_sheet_invalid')} + + )} + - {/* Sheet — slide-up von unten, 65% der Screen-Höhe */} - - - {/* Drag-handle */} - - - - - {/* Header */} + {/* Preview */} + {valid && ( - - - {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={colors.textMuted} - autoCapitalize="none" - autoCorrect={false} - autoFocus - keyboardType="url" - returnKeyType="done" - onSubmitEditing={handleAdd} - style={{ - backgroundColor: colors.surfaceElevated, - borderRadius: 12, - paddingHorizontal: 14, - paddingVertical: 12, - fontSize: 15, - fontFamily: 'Nunito_400Regular', - color: colors.text, - }} - /> - {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 */} - ({ - opacity: pressed ? 0.85 : 1, - marginBottom: insets.bottom > 0 ? 8 : 12, - })} + + - - {adding ? ( - - ) : ( - - {t('blocker.add_sheet_title')} - - )} - - + {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 */} + ({ + opacity: pressed ? 0.85 : 1, + marginBottom: insets.bottom > 0 ? 8 : 12, + })} + > + + {adding ? ( + + ) : ( + + {t('blocker.add_sheet_title')} + + )} + + + + ); } diff --git a/apps/rebreak-native/components/chat/CreateRoomSheet.tsx b/apps/rebreak-native/components/chat/CreateRoomSheet.tsx index 956e058..4744cfa 100644 --- a/apps/rebreak-native/components/chat/CreateRoomSheet.tsx +++ b/apps/rebreak-native/components/chat/CreateRoomSheet.tsx @@ -1,18 +1,18 @@ 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'; import { useColors } from '../../lib/theme'; +import { KeyboardAwareSheet } from '../KeyboardAwareSheet'; + +const COLLAPSED_HEIGHT = 480; type Props = { visible: boolean; @@ -37,6 +37,11 @@ export function CreateRoomSheet({ visible, onClose, onCreated }: Props) { setJoinMode('approval'); } + function handleClose() { + reset(); + onClose(); + } + async function create() { const trimmed = name.trim(); if (!trimmed || creating) return; @@ -62,114 +67,93 @@ export function CreateRoomSheet({ visible, onClose, onCreated }: Props) { } return ( - - - {}}> - - {t('chat.create_group')} + + + {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')} - )} - + {/* 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')} + )} + + + + ); } function makeStyles(colors: ReturnType) { return StyleSheet.create({ - backdrop: { - flex: 1, - backgroundColor: 'rgba(0,0,0,0.5)', - justifyContent: 'flex-end', - }, - sheet: { - backgroundColor: colors.bg, - borderTopLeftRadius: 22, - borderTopRightRadius: 22, - padding: 18, - paddingBottom: Platform.OS === 'ios' ? 32 : 18, - }, - grabber: { - width: 36, - height: 4, - borderRadius: 2, - backgroundColor: colors.border, - alignSelf: 'center', - marginBottom: 12, - }, title: { fontSize: 17, fontFamily: 'Nunito_700Bold', @@ -255,7 +239,8 @@ function makeStyles(colors: ReturnType) { }, actions: { flexDirection: 'row', - marginTop: 20, + marginTop: 4, + marginBottom: 10, }, cancelBtn: { flex: 1, diff --git a/apps/rebreak-native/components/games/GameOverScreen.tsx b/apps/rebreak-native/components/games/GameOverScreen.tsx index 5206131..cc7c9d8 100644 --- a/apps/rebreak-native/components/games/GameOverScreen.tsx +++ b/apps/rebreak-native/components/games/GameOverScreen.tsx @@ -2,7 +2,10 @@ import { useEffect, useRef, useState } from 'react'; import { ActivityIndicator, Animated, + Easing, + Keyboard, Modal, + Platform, ScrollView, Text, TextInput, @@ -56,7 +59,36 @@ export function GameOverScreen({ const colors = useColors(); const insets = useSafeAreaInsets(); + // Slide-In Spring für den Sheet-Auftritt (eigene Bouncy-Animation behalten) const slideAnim = useRef(new Animated.Value(500)).current; + // Keyboard-Lift via plain RN Keyboard.addListener (funktioniert in Modals, + // anders als react-native-keyboard-controller's useKeyboardAnimation). + const keyboardLift = useRef(new Animated.Value(0)).current; + + useEffect(() => { + const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow'; + const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide'; + const showSub = Keyboard.addListener(showEvent, (e) => { + Animated.timing(keyboardLift, { + toValue: e.endCoordinates.height, + duration: Platform.OS === 'ios' ? (e.duration ?? 250) : 220, + easing: Easing.out(Easing.cubic), + useNativeDriver: true, + }).start(); + }); + const hideSub = Keyboard.addListener(hideEvent, (e) => { + Animated.timing(keyboardLift, { + toValue: 0, + duration: Platform.OS === 'ios' ? (e?.duration ?? 250) : 220, + easing: Easing.out(Easing.cubic), + useNativeDriver: true, + }).start(); + }); + return () => { + showSub.remove(); + hideSub.remove(); + }; + }, [keyboardLift]); const [rating, setRating] = useState(0); const [feedback, setFeedback] = useState(''); @@ -70,7 +102,6 @@ export function GameOverScreen({ const [posted, setPosted] = useState(false); const [postError, setPostError] = useState(false); - console.log('[GameOver] colors:', colors); const emotion = isNewBest || score >= goodScore ? 'happy' : 'empathy'; const msg = lyraMsg(gameName, score, goodScore, isNewBest, t); const displayScore = score; @@ -87,6 +118,9 @@ export function GameOverScreen({ }).start(); }, []); + // Negativer Lift — translateY -keyboardHeight schiebt Sheet nach oben. + const keyboardLiftY = Animated.multiply(keyboardLift, -1); + function handleExit() { Animated.timing(slideAnim, { toValue: 500, @@ -172,7 +206,10 @@ export function GameOverScreen({ - + {displayBest} - + {isNewBest ? t('gameOver.newBest') : t('gameOver.best')} @@ -251,7 +288,7 @@ export function GameOverScreen({ value={rating} size="lg" interactive={!saved} - filledColor="#f59e0b" + filledColor="#007AFF" onChange={(v) => { if (!saved) setRating(v); }} /> {saved ? ( @@ -287,9 +324,9 @@ export function GameOverScreen({ disabled={saving} activeOpacity={0.7} style={{ - backgroundColor: '#f59e0b', - borderRadius: 14, - minHeight: 50, + backgroundColor: '#007AFF', + borderRadius: 12, + minHeight: 40, paddingVertical: 14, paddingHorizontal: 20, alignItems: 'center', @@ -314,11 +351,11 @@ export function GameOverScreen({ activeOpacity={0.85} style={{ flex: 1, - backgroundColor: '#f59e0b', - borderRadius: 14, - minHeight: 50, - paddingVertical: 14, - paddingHorizontal: 20, + backgroundColor: '#007AFF', + borderRadius: 12, + minHeight: 40, + paddingVertical: 10, + paddingHorizontal: 16, alignItems: 'center', justifyContent: 'center', }} @@ -337,8 +374,8 @@ export function GameOverScreen({ style={{ flex: 1, backgroundColor: '#e5e7eb', - borderRadius: 14, - minHeight: 50, + borderRadius: 12, + minHeight: 40, paddingVertical: 14, paddingHorizontal: 20, alignItems: 'center', @@ -391,7 +428,7 @@ export function GameOverScreen({ numberOfLines={4} style={{ backgroundColor: colors.surfaceElevated, - borderRadius: 14, + borderRadius: 12, padding: 14, fontSize: 14, fontFamily: 'Nunito_400Regular', @@ -415,8 +452,8 @@ export function GameOverScreen({ style={{ flex: 1, backgroundColor: '#e5e7eb', - borderRadius: 14, - minHeight: 50, + borderRadius: 12, + minHeight: 40, paddingVertical: 14, paddingHorizontal: 20, alignItems: 'center', @@ -436,9 +473,9 @@ export function GameOverScreen({ activeOpacity={0.85} style={{ flex: 1, - backgroundColor: '#f59e0b', - borderRadius: 14, - minHeight: 50, + backgroundColor: '#007AFF', + borderRadius: 12, + minHeight: 40, paddingVertical: 14, paddingHorizontal: 20, alignItems: 'center', diff --git a/apps/rebreak-native/components/games/ScoreProgressBar.tsx b/apps/rebreak-native/components/games/ScoreProgressBar.tsx new file mode 100644 index 0000000..fc57aee --- /dev/null +++ b/apps/rebreak-native/components/games/ScoreProgressBar.tsx @@ -0,0 +1,107 @@ +import { useEffect, useRef } from 'react'; +import { Animated, View, Text } from 'react-native'; + +/** + * Animierter Progress-Bar: aktueller Score vs. persönlicher Rekord. + * + * - Bar-Breite animiert zu `min(score / max(best, 1), 1) * 100%` + * - Bei `isNewBest=true`: Celebration-Animation (Gold-Pulse + Scale-Bounce + 🏆-Label) + * - Position direkt unter `` im Game-Layout + * + * Reusable für Snake / Tetris / Memory — pro Spiel den passenden `score`/`best` + * reinreichen. Optional `boardWidth` damit die Bar exakt das Board-Edge matcht. + */ +export interface ScoreProgressBarProps { + score: number; + best: number; + isNewBest: boolean; + boardWidth: number; +} + +export function ScoreProgressBar({ score, best, isNewBest, boardWidth }: ScoreProgressBarProps) { + const widthAnim = useRef(new Animated.Value(0)).current; + const celebrationAnim = useRef(new Animated.Value(0)).current; + + // Bar-Breite zum aktuellen Score-Verhältnis + useEffect(() => { + const target = best > 0 ? Math.min(score / best, 1) : score > 0 ? 1 : 0; + Animated.timing(widthAnim, { + toValue: target, + duration: 280, + useNativeDriver: false, + }).start(); + }, [score, best, widthAnim]); + + // Celebration-Pulse bei neuem Rekord + useEffect(() => { + if (!isNewBest) { + celebrationAnim.setValue(0); + return; + } + Animated.sequence([ + Animated.timing(celebrationAnim, { toValue: 1, duration: 280, useNativeDriver: false }), + Animated.timing(celebrationAnim, { toValue: 0, duration: 600, useNativeDriver: false }), + ]).start(); + }, [isNewBest, celebrationAnim]); + + const widthInterp = widthAnim.interpolate({ + inputRange: [0, 1], + outputRange: ['0%', '100%'], + }); + + // Bar-Color: idle blau, beim Celebration-Pulse → gold + const barColor = celebrationAnim.interpolate({ + inputRange: [0, 1], + outputRange: ['#007AFF', '#FFD60A'], + }); + + // Container leicht hochskalieren bei Celebration + const containerScale = celebrationAnim.interpolate({ + inputRange: [0, 1], + outputRange: [1, 1.04], + }); + + return ( + + + + {isNewBest ? '🏆 NEW RECORD' : 'PROGRESS'} + + + {score} / {Math.max(best, score)} + + + + + + + ); +} diff --git a/apps/rebreak-native/components/icons/LanguageIcon.tsx b/apps/rebreak-native/components/icons/LanguageIcon.tsx new file mode 100644 index 0000000..cf91075 --- /dev/null +++ b/apps/rebreak-native/components/icons/LanguageIcon.tsx @@ -0,0 +1,30 @@ +/** + * LanguageIcon — custom SVG für Sprache-Setting (statt Ionicons language-outline). + * + * SVG-Source: User-provided (24×24 viewBox, currentColor stroke). + * Pattern: A-glyph + speech-bubble + Aa-letters → Translation/Language-Picker affordance. + */ +import { Svg, G, Path } from 'react-native-svg'; + +type Props = { + size?: number; + color?: string; +}; + +export function LanguageIcon({ size = 24, color = 'currentColor' }: Props) { + return ( + + + + + + + + ); +} diff --git a/apps/rebreak-native/components/mail/ConnectMailSheet.tsx b/apps/rebreak-native/components/mail/ConnectMailSheet.tsx index be7405b..ecb0004 100644 --- a/apps/rebreak-native/components/mail/ConnectMailSheet.tsx +++ b/apps/rebreak-native/components/mail/ConnectMailSheet.tsx @@ -1,13 +1,7 @@ -import { useEffect, useRef, useState } from 'react'; +import { useState } from 'react'; import { ActivityIndicator, - Animated, - Dimensions, - Easing, - KeyboardAvoidingView, Linking, - Modal, - Platform, Pressable, ScrollView, Text, @@ -19,9 +13,9 @@ import { Ionicons } from '@expo/vector-icons'; import { useTranslation } from 'react-i18next'; import { useMailConnect, detectProvider, type MailProvider } from '../../hooks/useMailConnect'; import { useColors } from '../../lib/theme'; +import { KeyboardAwareSheet } from '../KeyboardAwareSheet'; -const SCREEN_HEIGHT = Dimensions.get('window').height; -const SHEET_HEIGHT = SCREEN_HEIGHT * 0.65; +const COLLAPSED_HEIGHT = 600; type Props = { visible: boolean; @@ -109,29 +103,6 @@ export function ConnectMailSheet({ visible, onClose, onSuccess }: Props) { 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); @@ -180,105 +151,69 @@ export function ConnectMailSheet({ visible, onClose, onSuccess }: Props) { const detectedProvider = email.includes('@') ? detectProvider(email) : null; const currentProvider = selectedProvider ?? null; + const header = ( + + {view === 'form' ? ( + + + {t('common.back')} + + + ) : ( + + + {t('common.cancel')} + + + )} + + {view === 'form' && currentProvider + ? t(currentProvider.labelKey) + : t('mail.connect_sheet_title')} + + + + ); + 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} - /> - )} - - - + + {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} + /> + )} + ); } diff --git a/apps/rebreak-native/components/mail/EditMailAccountSheet.tsx b/apps/rebreak-native/components/mail/EditMailAccountSheet.tsx index 8402dcb..55e3c84 100644 --- a/apps/rebreak-native/components/mail/EditMailAccountSheet.tsx +++ b/apps/rebreak-native/components/mail/EditMailAccountSheet.tsx @@ -1,25 +1,13 @@ -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 { useState } from 'react'; +import { ActivityIndicator, Pressable, Text, TextInput, View } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { useTranslation } from 'react-i18next'; import { useMailConnect } from '../../hooks/useMailConnect'; import { useColors } from '../../lib/theme'; +import { humanizeMailError } from '../../lib/mailErrors'; +import { KeyboardAwareSheet } from '../KeyboardAwareSheet'; -const SCREEN_HEIGHT = Dimensions.get('window').height; -const SHEET_HEIGHT = SCREEN_HEIGHT * 0.5; +const COLLAPSED_HEIGHT = 280; type Props = { visible: boolean; @@ -35,38 +23,18 @@ type Props = { export function EditMailAccountSheet({ visible, email, onClose, onSuccess }: Props) { const { t } = useTranslation(); const colors = useColors(); - 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]); + function handleClose() { + setPassword(''); + setPasswordVisible(false); + setFormError(null); + onClose(); + } async function handleSave() { if (!password.trim()) { @@ -76,179 +44,149 @@ export function EditMailAccountSheet({ visible, email, onClose, onSuccess }: Pro setFormError(null); const result = await connect({ email, password }); if (result.ok) { - onClose(); + handleClose(); onSuccess(); } else { setFormError(result.error ?? t('mail.connect_failed')); } } + const header = ( + + + + {t('mail.edit_account_cancel')} + + + + {t('mail.edit_account_title')} + + + + ); + return ( - - - - - - - + + - {/* Drag-Handle */} - - - + {t('mail.edit_account_subtitle', { email })} + - {/* Header */} + + + { + setPassword(v); + setFormError(null); + }} + placeholder={t('mail.app_password_placeholder')} + placeholderTextColor={colors.textMuted} + secureTextEntry={!passwordVisible} + autoCapitalize="none" + autoCorrect={false} + style={{ + flex: 1, + paddingVertical: 14, + fontSize: 15, + fontFamily: 'Nunito_400Regular', + color: colors.text, + }} + /> + setPasswordVisible((p) => !p)} hitSlop={8}> + + + + + {(formError ?? connectError) && ( - - - {t('common.cancel')} - - - - {t('mail.edit_account_title')} - - - - - + - {t('mail.edit_account_subtitle', { email })} + {formError + ? formError + : t(humanizeMailError(connectError))} - - - - { - setPassword(v); - setFormError(null); - }} - placeholder={t('mail.app_password_placeholder')} - placeholderTextColor={colors.textMuted} - secureTextEntry={!passwordVisible} - autoCapitalize="none" - autoCorrect={false} - style={{ - flex: 1, - paddingVertical: 14, - fontSize: 15, - fontFamily: 'Nunito_400Regular', - color: colors.text, - }} - /> - setPasswordVisible((p) => !p)} hitSlop={8}> - - - - - {(formError ?? connectError) && ( - - - - {formError ?? connectError} - - - )} - - ({ - marginTop: 4, - opacity: pressed ? 0.85 : 1, - })} - > - - {connecting ? ( - - ) : ( - - {t('mail.edit_account_save')} - - )} - - - - - - - + )} + + ({ + marginTop: 4, + 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 index c856aea..c6ebbe4 100644 --- a/apps/rebreak-native/components/mail/MailAccountCard.tsx +++ b/apps/rebreak-native/components/mail/MailAccountCard.tsx @@ -45,15 +45,186 @@ function resolveProviderIcon(provider: string): { 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 STALE_THRESHOLD_MS = 5 * 60 * 1_000; +const IDLE_HEARTBEAT_STALE_MS = STALE_THRESHOLD_MS; +const NO_NEW_MAIL_THRESHOLD_MS = 60 * 60_000; + +function formatRelativeAbsolute(ts: Date): string { + const min = Math.floor((Date.now() - ts.getTime()) / 60_000); + const todayStr = new Date().toDateString(); + const yesterdayStr = new Date(Date.now() - 86_400_000).toDateString(); + + const hh = ts.getHours().toString().padStart(2, '0'); + const mm = ts.getMinutes().toString().padStart(2, '0'); + + let dayLabel: string; + if (ts.toDateString() === todayStr) dayLabel = 'heute'; + else if (ts.toDateString() === yesterdayStr) dayLabel = 'gestern'; + else dayLabel = ts.toLocaleDateString('de', { day: '2-digit', month: '2-digit' }); + + let rel: string; + if (min < 1) rel = 'gerade eben'; + else if (min < 60) rel = `vor ${min} min`; + else if (min < 1440) rel = `vor ${Math.floor(min / 60)}h`; + else rel = `vor ${Math.floor(min / 1440)}d`; + + return `${rel} (${dayLabel} ${hh}:${mm})`; +} + +function idleHeartbeatAlive(lastIdleHeartbeatAt: string | null | undefined): boolean { + if (!lastIdleHeartbeatAt) return false; + return Date.now() - new Date(lastIdleHeartbeatAt).getTime() < IDLE_HEARTBEAT_STALE_MS; +} + +function StatusBadgeRow({ + account, + isLegend, + t, +}: { + account: MailAccount; + isLegend: boolean; + t: (k: string, opts?: Record) => string; +}) { + // Priority 1 — auth / connect error + if (account.lastConnectError) { + const isAuthError = + account.lastConnectError.toLowerCase().includes('invalid credentials') || + account.lastConnectError.toLowerCase().includes('authentication failed'); + const errorLabel = isAuthError ? t('mail.status_auth_error') : t('mail.status_connect_error'); + const since = account.lastConnectErrorAt + ? formatRelativeAbsolute(new Date(account.lastConnectErrorAt)) + : null; + return ( + + + + + {errorLabel} + + + · {t('mail.status_error_tap_hint')} + + + {since ? ( + + {since} + + ) : null} + + ); + } + + // Priority 5 — never connected + if (!account.lastScannedAt) { + return ( + + + + {t('mail.status_waiting_first_connect')} + + + ); + } + + const heartbeatAlive = idleHeartbeatAlive(account.lastIdleHeartbeatAt); + const lastScannedTs = new Date(account.lastScannedAt); + const scannedAgo = Date.now() - lastScannedTs.getTime(); + const scannedRelAbs = formatRelativeAbsolute(lastScannedTs); + + // Priority 4 — stale: heartbeat missing/expired AND scan is old + if (!heartbeatAlive && scannedAgo > STALE_THRESHOLD_MS) { + return ( + + + + + {t('mail.status_stale')} + + + + {t('mail.status_stale_last_scan', { rel: scannedRelAbs })} + + + ); + } + + // Priority 2 + 3 — heartbeat alive (or scan recent enough for pre-migration backend) + if (heartbeatAlive) { + const heartbeatTs = new Date(account.lastIdleHeartbeatAt!); + const heartbeatMin = Math.floor((Date.now() - heartbeatTs.getTime()) / 60_000); + const idleSince = heartbeatMin < 1 ? 'gerade eben' : `${heartbeatMin} min`; + + if (scannedAgo > NO_NEW_MAIL_THRESHOLD_MS) { + // Priority 3 — connected but no new mail for >1h + return ( + + + + + {isLegend ? t('mail.live') : t('mail.account_active')} + + + + {t('mail.status_live_no_new_mail', { rel: scannedRelAbs })} + + + ); + } + + // Priority 2 — live + heartbeat recent + scan recent + return ( + + + + + {isLegend ? t('mail.live') : t('mail.account_active')} + + + + {t('mail.status_live_idle', { rel: idleSince })} + + + ); + } + + // Fallback — scan recent, backend without heartbeat field + return ( + + + + + {isLegend ? t('mail.live') : t('mail.account_active')} + + + + {scannedRelAbs} + + + ); } const INTERVAL_OPTIONS_BY_PLAN: Record<'free' | 'pro' | 'legend', number[]> = { @@ -62,7 +233,6 @@ const INTERVAL_OPTIONS_BY_PLAN: Record<'free' | 'pro' | 'legend', number[]> = { 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, @@ -99,6 +269,10 @@ export function MailAccountCard({ const intervalOptions = INTERVAL_OPTIONS_BY_PLAN[plan]; function handleToggle() { + if (account.lastConnectError) { + setEditVisible(true); + return; + } LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut); onToggle(); } @@ -115,11 +289,11 @@ export function MailAccountCard({ backgroundColor: '#fff', borderRadius: 16, borderWidth: 1, - borderColor: '#e5e5e5', + borderColor: account.lastConnectError ? '#fecaca' : '#e5e5e5', overflow: 'hidden', }} > - {/* ── Header ── */} + {/* Header */} {account.email} - - - - {account.isActive - ? isLegend - ? t('mail.live') - : t('mail.account_active') - : t('mail.account_inactive')} - - - · {formatRelativeTime(account.lastScannedAt, t)} - - + - {/* ── Body ── */} + {/* Body */} {expanded && ( - {/* Big stat: Blocked */} - + {t('mail.account_stat_blocked')} - + {t('mail.account_of_scanned', { scanned: account.totalScanned.toLocaleString(), })} - {/* Scan Mode */} {isLegend ? ( )} - {/* Action Row */} setEditVisible(true)} diff --git a/apps/rebreak-native/components/mail/MailWeeklyChart.tsx b/apps/rebreak-native/components/mail/MailWeeklyChart.tsx new file mode 100644 index 0000000..a183aff --- /dev/null +++ b/apps/rebreak-native/components/mail/MailWeeklyChart.tsx @@ -0,0 +1,159 @@ +import { useState } from 'react'; +import { Pressable, Text, View } from 'react-native'; +import Svg, { Rect, Text as SvgText } from 'react-native-svg'; +import { useTranslation } from 'react-i18next'; +import { useColors } from '../../lib/theme'; +import type { DailyStat } from '../../hooks/useMailStatus'; + +type Props = { + dailyStats: DailyStat[]; + totalBlocked: number; +}; + +const CHART_HEIGHT = 72; +const BAR_RADIUS = 4; +const LABEL_HEIGHT = 16; +const SVG_HEIGHT = CHART_HEIGHT + LABEL_HEIGHT; + +export function MailWeeklyChart({ dailyStats, totalBlocked }: Props) { + const { t } = useTranslation(); + const colors = useColors(); + const [activeIdx, setActiveIdx] = useState(null); + + const chartMax = Math.max(...dailyStats.map((d) => d.count), 1); + + const weekTotal = dailyStats.reduce((s, d) => s + d.count, 0); + + return ( + + {/* Header */} + + + {t('mail.chart_title')} + + + {t('mail.chart_week_total', { count: weekTotal })} + + + + {/* Tooltip */} + {activeIdx !== null && dailyStats[activeIdx] !== undefined && ( + + + {dailyStats[activeIdx].label}: {dailyStats[activeIdx].count} + + + )} + + {/* SVG Bar Chart */} + + + {dailyStats.map((day, i) => { + const barH = day.count > 0 + ? Math.max(6, Math.round((day.count / chartMax) * CHART_HEIGHT)) + : 4; + const x = i * 40 + 4; + const barW = 32; + const y = CHART_HEIGHT - barH; + const isActive = activeIdx === i; + const fill = day.count > 0 + ? isActive ? '#b91c1c' : '#ef4444' + : colors.border; + + return ( + + ); + })} + {dailyStats.map((day, i) => ( + + {day.label} + + ))} + + + {/* Invisible tap targets per bar */} + + {dailyStats.map((day, i) => ( + setActiveIdx((prev) => (prev === i ? null : i))} + accessibilityLabel={`${day.label}: ${day.count}`} + /> + ))} + + + + ); +} diff --git a/apps/rebreak-native/components/urge/UrgeGames.tsx b/apps/rebreak-native/components/urge/UrgeGames.tsx index f1bf0e3..3d29167 100644 --- a/apps/rebreak-native/components/urge/UrgeGames.tsx +++ b/apps/rebreak-native/components/urge/UrgeGames.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useRef, useState, useCallback } from 'react'; -import { View, Text, Pressable, Dimensions, PanResponder, Platform } from 'react-native'; +import { View, Text, Pressable, TouchableWithoutFeedback, Dimensions, PanResponder, Platform } from 'react-native'; import Svg, { Defs, Pattern, Path, Rect, Polyline, Circle, Line } from 'react-native-svg'; import { SvgXml } from 'react-native-svg'; import { Ionicons } from '@expo/vector-icons'; @@ -10,7 +10,9 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { memorySvg, snakeSvg, tetrisSvg, tictactoeSvg } from './gameSvgs'; import { useColors } from '../../lib/theme'; import { GameOverScreen } from '../games/GameOverScreen'; +import { ScoreProgressBar } from '../games/ScoreProgressBar'; import { getBestScore, saveBestScore } from '../../lib/gameScores'; +import { useSnakeSounds } from '../../hooks/useSnakeSounds'; // Haptic helper — fire-and-forget, swallow errors on platforms without taptic engine function tapHaptic() { @@ -106,9 +108,13 @@ const OPPOSITES: Record = { up: 'down', down: 'up', left: 'right', rig export function SnakeGame({ onComplete, onAbandon, + mode = 'standalone', }: { onComplete: (score: number) => void; onAbandon: () => void; + /** 'sos' = no GameOverScreen, fire onComplete(score) immediately when game ends. + * 'standalone' = render GameOverScreen with retry/exit/share. */ + mode?: 'sos' | 'standalone'; }) { const insets = useSafeAreaInsets(); // Cell size aus Bildschirmgröße — Header(80) + Drawer-Padding(40) + DPad(220) + Spacing(40) + Home-Indicator @@ -138,7 +144,29 @@ export function SnakeGame({ const [gameOver, setGameOver] = useState(false); const [isNewBest, setIsNewBest] = useState(false); const [activeDPad, setActiveDPad] = useState('right'); + const [elapsed, setElapsed] = useState(0); const intervalRef = useRef | null>(null); + const timerRef = useRef | null>(null); + const sounds = useSnakeSounds(true); + const newRecordFiredRef = useRef(false); + + useEffect(() => { + if (gameOver) { + if (timerRef.current) clearInterval(timerRef.current); + return; + } + timerRef.current = setInterval(() => setElapsed((s) => s + 1), 1000); + return () => { + if (timerRef.current) clearInterval(timerRef.current); + }; + }, [gameOver]); + + useEffect(() => { + if (gameOver && mode === 'sos') { + onComplete(score); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [gameOver, mode]); useEffect(() => { getBestScore('snake').then(setHighScore); @@ -168,6 +196,9 @@ export function SnakeGame({ setIsNewBest(true); setHighScore(finalScore); saveBestScore('snake', finalScore).catch(() => {}); + sounds.playNewRecord(); + } else { + sounds.playGameOver(); } } @@ -183,6 +214,8 @@ export function SnakeGame({ setGameOver(false); setIsNewBest(false); setActiveDPad('right'); + setElapsed(0); + newRecordFiredRef.current = false; } // Game tick loop — single setInterval, side-effects driven via refs (NOT inside reducers). @@ -218,7 +251,18 @@ export function SnakeGame({ const newFood = randomFood(newSnake); foodRef.current = newFood; setFood(newFood); - setScore((s) => s + 1); + setScore((s) => { + const next = s + 1; + // Record-Pulse genau im Moment des Überschreitens (einmal pro Run) + if (highScore > 0 && next > highScore && !newRecordFiredRef.current) { + newRecordFiredRef.current = true; + setIsNewBest(true); + sounds.playNewRecord(); + } else { + sounds.playEat(); + } + return next; + }); } }, SNAKE_TICK_MS); return () => { @@ -297,13 +341,19 @@ export function SnakeGame({ {!gameOver && ( <> - {/* Lyra hint */} - - {lyraMessage} - - - {/* Digital score dashboard */} - + + )} @@ -341,7 +391,7 @@ export function SnakeGame({ onDPad('up')} /> onDPad('left')} /> - + onDPad('right')} /> @@ -350,7 +400,7 @@ export function SnakeGame({ )} - {gameOver && ( + {gameOver && mode === 'standalone' && ( = { up: 'chevron-up', down: 'chevron-down', left: 'chevron-back', right: 'chevron-forward', }; - const isIOS = Platform.OS === 'ios'; const tint = '#007aff'; + // Hard rule (siehe docs/internal/RECOVERY_LOG_2026-05-10.md §7.2): + // KEINE Pressable mit style-Funktion {({pressed}) => ...} — RN-Quirk schluckt + // Background-Properties manchmal. Stattdessen: TouchableWithoutFeedback + View + // mit static style. Visual-Active-State über `active`-Prop (nicht press-state). return ( - { tapHaptic(); onPress(); }} - hitSlop={12} - android_ripple={{ color: 'rgba(0,122,255,0.22)', borderless: true, radius: 32 }} - style={({ pressed }) => { - const bgIdle = 'rgba(0,122,255,0.10)'; - const bgPressed = 'rgba(0,122,255,0.22)'; - const bgActive = 'rgba(0,122,255,0.22)'; - const bg = active ? bgActive : pressed ? bgPressed : bgIdle; - return { - width: 60, height: 60, borderRadius: 30, - backgroundColor: bg, - borderWidth: 1.5, - borderColor: active ? tint : 'rgba(0,122,255,0.30)', - alignItems: 'center', justifyContent: 'center', - transform: [{ scale: pressed ? 0.96 : active ? 1.04 : 1 }], - }; - }} + hitSlop={{ top: 12, bottom: 12, left: 12, right: 12 }} > - - + + + + ); } // 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, label, onPress, }: { icon: 'sync' | 'arrow-down'; label: string; onPress: () => void; - accent?: string; + accent?: string; // ignored — vereinheitlicht auf iOS-blau }) { - const accentColor = accent || '#1f2937'; + // Hard rule (siehe RECOVERY_LOG §7.2): kein Pressable mit style-Funktion. + const tint = '#007AFF'; 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 }], - })} + hitSlop={{ top: 12, bottom: 12, left: 12, right: 12 }} > - {({ pressed }) => ( - <> - - {label} - - )} - + + + {label} + + ); } @@ -452,9 +488,11 @@ const MEMORY_EMOJIS = ['🛡️', '💪', '🌟', '🧠', '🌊', '🎯', '🌱' export function MemoryGame({ onComplete, onAbandon, + mode = 'standalone', }: { onComplete: (score: number) => void; onAbandon: () => void; + mode?: 'sos' | 'standalone'; }) { type Card = { id: number; emoji: string; matched: boolean; revealed: boolean }; const [cards, setCards] = useState([]); @@ -468,6 +506,14 @@ export function MemoryGame({ useEffect(() => { getBestScore('memory').then(setBestMoves); }, []); + // SOS-Mode: kein GameOverScreen, sofort onComplete(score) feuern + useEffect(() => { + if (showGameOver && mode === 'sos') { + onComplete(moveCount); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [showGameOver, mode]); + function init() { const pairs = shuffle([...MEMORY_EMOJIS, ...MEMORY_EMOJIS]); setCards(pairs.map((emoji, id) => ({ id, emoji, matched: false, revealed: false }))); @@ -579,7 +625,7 @@ export function MemoryGame({ ); })} - {showGameOver && ( + {showGameOver && mode === 'standalone' && ( void; onAbandon: () => void; + mode?: 'sos' | 'standalone'; }) { const [board, setBoard] = useState(Array(9).fill(null)); const [gameOver, setGameOver] = useState(false); @@ -805,9 +853,11 @@ function tetrisRotate(shape: number[][]) { export function TetrisGame({ onComplete, onAbandon, + mode = 'standalone', }: { onComplete: (score: number) => void; onAbandon: () => void; + mode?: 'sos' | 'standalone'; }) { const insets = useSafeAreaInsets(); // CELL aus Bildschirmgröße — Header(80) + Padding(40) + Speed-Stepper(50) + Controls(110) + Spacing(20) + Home-Indicator @@ -832,6 +882,14 @@ export function TetrisGame({ const [speedLevel, setSpeedLevel] = useState(3); const tickTimerRef = useRef | null>(null); + // SOS-Mode: kein GameOverScreen, sofort onComplete(score) feuern + useEffect(() => { + if (gameOver && mode === 'sos') { + onComplete(score); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [gameOver, mode]); + const boardRef = useRef(board); const currentRef = useRef(current); useEffect(() => { boardRef.current = board; }, [board]); @@ -1017,15 +1075,7 @@ export function TetrisGame({ return ( {!gameOver && ( - <> - {/* Lyra hint */} - - {lyraMessage} - - - {/* Digital score dashboard */} - - + )} {/* Board */} @@ -1085,28 +1135,18 @@ export function TetrisGame({ /> - {/* Controls — aligned to board width, centered on screen */} + {/* Controls — alle 4 Buttons zentriert in einer Reihe (besser thumb-reachable + als links/rechts gespalten am Board-Rand). */} - - {/* Move Pad */} - - - - - {/* Action Pad */} - - - - + + + + + - {gameOver && ( + {gameOver && mode === 'standalone' && ( String(n).padStart(digits, '0'); + const extraDisplay = typeof extra === 'string' ? extra : extra !== undefined ? fmt(extra, 2) : ''; return ( - + )} diff --git a/apps/rebreak-native/docs/internal/PLAY_STORE_LISTING.md b/apps/rebreak-native/docs/internal/PLAY_STORE_LISTING.md new file mode 100644 index 0000000..2bac515 --- /dev/null +++ b/apps/rebreak-native/docs/internal/PLAY_STORE_LISTING.md @@ -0,0 +1,125 @@ +# Play Store Listing — ReBreak + +Status: DRAFT — Version 0.1.0 (Internal Testing) +Letzte Aktualisierung: 2026-05-09 + +--- + +## Kurztitel (30 Zeichen max) + +ReBreak + +## Untertitel / Tagline (80 Zeichen max, Play-Card) + +Rückfallprävention mit KI-Begleitung und digitalem Schutz. + +--- + +## Kurzbeschreibung (80 Zeichen max, Play-Card) + +KI-Begleiterin Lyra, Streak-Tracking und aktiver Digitalschutz gegen Rückfälle. + +--- + +## Lange Beschreibung (max 4000 Zeichen) + +ReBreak ist eine Rückfallpräventions-App für Menschen, die ihre Abhängigkeit von Glücksspiel oder anderen impulsiven Verhaltensweisen überwinden wollen. + +**Lyra — deine KI-Begleiterin** +Lyra ist ein empathischer KI-Coach, der dir rund um die Uhr zur Seite steht. In akuten Krisenmomenten (SOS-Modus) gibt Lyra strukturierte Gesprächsbegleitung, Atemübungen und kognitive Umstrukturierungshilfen — ohne Wartezeiten, ohne Termin. + +**Streak & Fortschritt** +Dein täglicher Streak zeigt dir deinen abstinenten Weg. Jeder Tag ohne Rückfall wird sichtbar gemacht — als Motivation, nicht als Kontrolle. + +**Aktiver Digitalschutz** +ReBreak kann Zugang zu Glücksspiel-Websites und -Apps auf deinem Gerät blockieren. Der Schutz läuft lokal auf deinem Gerät — keine Daten verlassen dein Telefon. + +**Anonymität** +Kein Klarname. Kein öffentliches Profil. Du bist nur mit deinem selbst gewählten Nickname sichtbar. + +**Datenschutz** +ReBreak erfüllt die Anforderungen der deutschen Datenschutz-Grundverordnung (DSGVO). Alle Gespräche mit Lyra bleiben privat. Demografische Gesundheitsdaten (optional, für DiGA-Nachweisbarkeit) werden strukturiert und getrennt von Gesprächen gespeichert. + +**Für wen ist ReBreak?** +- Menschen mit Glücksspielstörung (F63.0 ICD-10), die ambulante Unterstützung suchen +- Angehörige, die einen sicheren Kanal zur Begleitung suchen +- Personen, die eine digitale Ergänzung zu Therapie oder Selbsthilfegruppen wünschen + +ReBreak ist kein Ersatz für professionelle Behandlung. Bei akuter Krise: Telefonseelsorge 0800 111 0 111 (kostenlos, 24/7). + +--- + +## Berechtigungen — Begründung für Review-Team + +### BIND_ACCESSIBILITY_SERVICE + +**Warum benötigt:** +ReBreak nutzt den Android Accessibility Service ausschließlich dazu, Glücksspiel-Apps zu erkennen, wenn sie in den Vordergrund gebracht werden, und diese sofort mit einem Sicherheitsbildschirm zu überblenden. + +Der Service liest keine Texte, keine Passwörter, keine persönlichen Eingaben. Er reagiert ausschließlich auf `TYPE_WINDOW_STATE_CHANGED`-Events und prüft den Paketnamen der aktiven App gegen eine lokal gespeicherte Blockliste. + +**Es findet keine Datenübertragung statt.** Kein Keylogging. Kein Screen-Recording. Kein Remote-Access. + +Dies ist die einzige technisch verlässliche Methode, um auf Android einen App-Blocker zu implementieren, der nicht durch minimieren/wechseln umgangen werden kann. + +Vergleichbare Apps mit gleicher Begründung: BlockSite, StayFree, AppBlock. + +### BIND_VPN_SERVICE + +**Warum benötigt:** +ReBreak nutzt den VPN-Service ausschließlich als lokales DNS-Filter — keine Verbindung zu externen VPN-Servern. + +Alle DNS-Anfragen werden lokal auf dem Gerät abgefangen. Anfragen an bekannte Glücksspiel-Domains werden auf `0.0.0.0` umgeleitet (blockiert). Alle anderen DNS-Anfragen werden unverändert an den Standard-DNS-Resolver des Geräts weitergegeben. + +**Kein Traffic verlässt das Gerät über diesen Service.** Kein Logging von Webseitenbesuchen außerhalb der Blockliste. Kein Remote-Server involviert. + +Technische Alternative (um VPN zu vermeiden) existiert auf Android nicht: `hosts`-Datei-Modifikation erfordert Root-Zugriff; Private-DNS-Override erfordert Android 9+ und schützt nicht gegen App-basierte Anfragen. + +### FOREGROUND_SERVICE + +Wird benötigt, damit der Schutz-Service (DNS-Filter + App-Blocker) auch dann aktiv bleibt, wenn ReBreak selbst in den Hintergrund tritt. Ohne Foreground-Service würde Android den Service nach wenigen Minuten beenden — der Schutz wäre damit wirkungslos. + +### POST_NOTIFICATIONS + +Für Recovery-Erinnerungen, Streak-Meilensteine und Lyra-Nachrichten. Alle Notification-Typen sind in den App-Einstellungen einzeln deaktivierbar. + +### RECORD_AUDIO + +Für die Sprach-Eingabe im Lyra-Chat (SOS-Modus). Mikrofon wird ausschließlich aktiviert, wenn der User manuell die Spracheingabe startet. Kein Hintergrund-Recording. + +--- + +## Screenshots — Shotlist (8 Frames) + +Alle Screenshots auf echtem iPhone Air / Pixel-Gerät (kein Simulator). +Format: 1290x2796 px (iPhone 15 Pro Max) + 1080x1920 px (Android). + +| # | Screen | Beschreibung | +|---|--------|--------------| +| 1 | Hero / Homescreen | Streak-Anzeige, Tag-Zähler, Lyra-Avatar prominent. Tageslicht-Theme. | +| 2 | SOS-Modus | Lyra-Chat aktiv, Eingabefeld, Atemübungs-Card. Zeigt: "Du bist nicht allein." | +| 3 | Streak-Kalender | Monatsansicht mit Streak-Markierungen. | +| 4 | Blocker aktiv | Overlay wenn Glücksspiel-App erkannt: "ReBreak schützt dich." + Entsperr-Button. | +| 5 | Mail-Schutz | Postfach-Blocking-Screen: Werbemails werden abgeschirmt. | +| 6 | Lyra-Sprachmode | Mikrofon-Button aktiv, Sprechblase mit transkribierter Antwort. | +| 7 | Profil-Seite | Nickname, Streak-Stats, optional: Fortschritts-Ringe. Kein Klarname sichtbar. | +| 8 | Blocker-Einstellungen | Liste blockierter Apps + Domains. Toggle pro Kategorie. | + +--- + +## App-Kategorie (Play Console) + +Primary: Health & Fitness +Secondary: Medical + +## Content-Rating + +USK: 12 (Thema Glücksspiel-Prävention; kein Glücksspiel-Inhalt selbst) +Play-IARC-Fragebogen: keine Gewalt, kein Glücksspiel in der App, kein User-Generated-Content (kein öffentliches Forum) + +## Datenschutzerklärung-URL (PFLICHT) + +https://rebreak.org/privacy-policy + +STATUS: 401 — URL nicht erreichbar (2026-05-09). Muss vor Submission live sein. +Zustaendig: Hans-Müller (DSB), rebreak-ops fuer Deployment. diff --git a/apps/rebreak-native/eas.json b/apps/rebreak-native/eas.json new file mode 100644 index 0000000..dd7ecd5 --- /dev/null +++ b/apps/rebreak-native/eas.json @@ -0,0 +1,54 @@ +{ + "cli": { + "version": ">= 5.0.0", + "appVersionSource": "remote" + }, + "build": { + "development": { + "developmentClient": true, + "distribution": "internal", + "ios": { + "simulator": false + }, + "android": { + "buildType": "apk" + } + }, + "preview": { + "distribution": "internal", + "ios": { + "resourceClass": "m-medium", + "autoIncrement": true + }, + "android": { + "buildType": "apk", + "autoIncrement": true + }, + "channel": "preview" + }, + "production": { + "ios": { + "resourceClass": "m-medium", + "autoIncrement": true + }, + "android": { + "buildType": "app-bundle", + "autoIncrement": true + }, + "channel": "production" + } + }, + "submit": { + "production": { + "ios": { + "appleId": "tunisie@hotmail.de", + "ascAppId": "6762027467", + "appleTeamId": "84BQ7MTFYK" + }, + "android": { + "serviceAccountKeyPath": "", + "track": "internal" + } + } + } +} diff --git a/apps/rebreak-native/hooks/useKeyboardHeight.ts b/apps/rebreak-native/hooks/useKeyboardHeight.ts new file mode 100644 index 0000000..80dd4aa --- /dev/null +++ b/apps/rebreak-native/hooks/useKeyboardHeight.ts @@ -0,0 +1,33 @@ +import { useEffect, useState } from 'react'; +import { Keyboard, Platform } from 'react-native'; + +/** + * Liefert die aktuelle Keyboard-Höhe in px (0 wenn versteckt). + * + * Pattern aus components/PostCommentsSheet.tsx — iOS nutzt `keyboardWillShow` + * für glatte Animation, Android `keyboardDidShow` weil iOS-Will-Events dort nicht feuern. + * + * Für Standard-Forms reicht `` (das nutzt diesen Hook intern). + * Direkten Hook-Zugriff nur wenn man die Höhe selbst irgendwo einrechnen muss + * (z.B. SOS-Chat mit FlatList + Input-Bar im selben Layout). + */ +export function useKeyboardHeight(): number { + const [keyboardHeight, setKeyboardHeight] = useState(0); + + 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(); + }; + }, []); + + return keyboardHeight; +} diff --git a/apps/rebreak-native/hooks/useMailStatus.ts b/apps/rebreak-native/hooks/useMailStatus.ts index 509a03f..2536986 100644 --- a/apps/rebreak-native/hooks/useMailStatus.ts +++ b/apps/rebreak-native/hooks/useMailStatus.ts @@ -13,6 +13,9 @@ export type MailAccount = { totalScanned: number; scanInterval: number; blockRate: number; + lastConnectError?: string | null; + lastConnectErrorAt?: string | null; + lastIdleHeartbeatAt?: string | null; }; export type DailyStat = { diff --git a/apps/rebreak-native/hooks/useMe.ts b/apps/rebreak-native/hooks/useMe.ts index c04b3c2..c67d29c 100644 --- a/apps/rebreak-native/hooks/useMe.ts +++ b/apps/rebreak-native/hooks/useMe.ts @@ -8,6 +8,10 @@ export type Plan = 'free' | 'pro' | 'legend'; * `auth.users` mit `rebreak.profiles` server-side — wir bekommen alles in * einem Request: plan, avatar, nickname, streak. * + * Live-Update-Pattern (siehe RECOVERY_LOG): nach Profile-Edit (PATCH /api/auth/me) + * MUSS `invalidateMe()` aufgerufen werden — alle useMe-Konsumenten (AppHeader, + * PostCard, ComposeCard, etc.) re-fetchen automatisch via Listener-Subscribe. + * * 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 @@ -25,15 +29,40 @@ export type Me = { }; let cachedMe: Me | null = null; +const listeners = new Set<() => void>(); + +/** + * Lädt /api/auth/me neu und benachrichtigt ALLE useMe-Konsumenten in der App. + * Nach jedem PATCH /api/auth/me aufrufen — sonst sehen Konsumenten alten Cache. + */ +export function invalidateMe(): void { + cachedMe = null; + for (const cb of listeners) cb(); +} 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); + // Auf globale Invalidierung lauschen (Avatar-/Nickname-Update aus Profile-Edit) + useEffect(() => { + const cb = () => setVersion((v) => v + 1); + listeners.add(cb); + return () => { + listeners.delete(cb); + }; + }, []); + useEffect(() => { let cancelled = false; (async () => { + // Falls cache schon frisch ist (von anderem Konsumenten gerade geladen): nutzen + if (cachedMe !== null) { + setMe(cachedMe); + setLoading(false); + return; + } try { const res = await apiFetch('/api/auth/me'); if (cancelled) return; @@ -54,9 +83,7 @@ export function useMe(): { me: Me | null; loading: boolean; reload: () => void } me, loading, reload: () => { - cachedMe = null; - setLoading(true); - setVersion((v) => v + 1); + invalidateMe(); }, }; } diff --git a/apps/rebreak-native/hooks/useSheetKeyboardLift.ts b/apps/rebreak-native/hooks/useSheetKeyboardLift.ts new file mode 100644 index 0000000..06b2a16 --- /dev/null +++ b/apps/rebreak-native/hooks/useSheetKeyboardLift.ts @@ -0,0 +1,101 @@ +import { useEffect, useRef } from 'react'; +import { Animated, Easing, Platform } from 'react-native'; +import { useKeyboardHeight } from './useKeyboardHeight'; + +/** + * App-weite Composable für Sheets/Modals mit TextInput. + * + * Liefert ein **kombiniertes Animated.Value** für `transform: [{ translateY }]`, + * das gleichzeitig: + * - die Slide-In/Out-Animation des Sheets ausführt (von unten reinkommend) + * - das Sheet automatisch über die Tastatur lifted wenn TextInput fokussiert + * + * Beide Animationen laufen im **native driver** (Performance + smoother als + * height-Animationen). Kein Driver-Mix, kein Bouncing. + * + * Pattern (verifiziert auf EditMailAccountSheet + GameOverScreen): + * ```tsx + * const sheetH = SCREEN_HEIGHT * 0.5; + * const lift = useSheetKeyboardLift({ visible, offscreenY: sheetH }); + * + * + * + * {form content with TextInput} + * + * + * ``` + * + * Anti-Pattern (was schief ging): `height: animatedValue` + `transform: animatedValue` + * auf demselben Animated.View → native-animated-module-Crash. Stattdessen feste + * height + nur translateY animieren. + * + * Anti-Pattern 2: `marginBottom: keyboardHeight` als JS-style + native transform + * im selben View → Bouncing weil zwei verschiedene Threads layouten. + * + * Für FlatList-basierte Sheets (PostCommentsSheet) ist das Pattern anders: + * dort wächst die Sheet-Höhe selbst weil eine variable Liste drin ist. Diese + * Composable ist für FIXED-HEIGHT-Form-Sheets gedacht. + */ +export interface SheetKeyboardLiftOptions { + /** Ob das Sheet aktuell sichtbar ist. Nur dann läuft Slide-In an. */ + visible: boolean; + /** Y-Offset des Sheets im verborgenen Zustand (typischerweise = SHEET_HEIGHT). */ + offscreenY: number; + /** Slide-Dauer in ms. Default 280. */ + slideDurationMs?: number; +} + +export function useSheetKeyboardLift({ + visible, + offscreenY, + slideDurationMs = 280, +}: SheetKeyboardLiftOptions) { + const keyboardHeight = useKeyboardHeight(); + const slideY = useRef(new Animated.Value(offscreenY)).current; + const keyboardLift = useRef(new Animated.Value(0)).current; + + // Slide-In bei visible-Wechsel + useEffect(() => { + if (visible) { + slideY.setValue(offscreenY); + Animated.timing(slideY, { + toValue: 0, + duration: slideDurationMs, + easing: Easing.out(Easing.cubic), + useNativeDriver: true, + }).start(); + } + }, [visible, offscreenY, slideDurationMs, slideY]); + + // Keyboard-Lift (iOS only — Android adjustResize macht das im Manifest) + useEffect(() => { + if (Platform.OS !== 'ios') return; + Animated.timing(keyboardLift, { + toValue: keyboardHeight, + duration: 220, + easing: Easing.out(Easing.cubic), + useNativeDriver: true, + }).start(); + }, [keyboardHeight, keyboardLift]); + + return { + /** Direkt in `transform: [{ translateY }]` einsetzen. */ + translateY: Animated.subtract(slideY, keyboardLift), + /** Manuelle Slide-Out-Animation (z.B. beim Close-Tap statt nur visible=false). */ + slideOut: (cb?: () => void) => + Animated.timing(slideY, { + toValue: offscreenY, + duration: 220, + useNativeDriver: true, + }).start(() => cb?.()), + /** Live keyboard-Höhe für extra Layout-Berechnungen wenn nötig. */ + keyboardHeight, + }; +} diff --git a/apps/rebreak-native/hooks/useSnakeSounds.ts b/apps/rebreak-native/hooks/useSnakeSounds.ts new file mode 100644 index 0000000..4555f3c --- /dev/null +++ b/apps/rebreak-native/hooks/useSnakeSounds.ts @@ -0,0 +1,78 @@ +import { useEffect, useRef } from 'react'; +import * as Haptics from 'expo-haptics'; + +/** + * Snake-Sound + Haptic-Helper. + * + * Aktuell: nur Haptics (Apple Taptic-Engine, Android-Vibrator-Falls-Available). + * Funktioniert SOFORT ohne weitere Setup-Schritte. + * + * UPGRADE-PFAD zu echtem 8-Bit-Retro-Sound: + * + * 1. 4 kurze Audio-Files in `apps/rebreak-native/assets/sounds/` ablegen: + * - `snake-eat.mp3` — Apple-Pickup, ~80ms, tonale "blip"-Töne (chiptune) + * - `snake-move.mp3` — Optional, Tick-Sound bei jeder Bewegung, ~30ms, dezent + * - `snake-gameover.mp3` — Death, ~400ms, abfallende Töne + * - `snake-record.mp3` — New-Record, ~600ms, aufsteigender Chime + * + * Free-Quellen (CC0): freesound.org, opengameart.org/content/8-bit-sound-pack, + * sfxr.me (in-browser-Generator für klassische 8-Bit-Sounds). + * + * 2. `expo-av` (oder `expo-audio` nach SDK-54-Migration) installieren falls nicht da: + * `pnpm add expo-av` (im rebreak-native-Workspace) + * + * 3. In dieser Datei oben einfügen: + * ```ts + * import { Audio } from 'expo-av'; + * const eatSrc = require('../assets/sounds/snake-eat.mp3'); + * const moveSrc = require('../assets/sounds/snake-move.mp3'); + * const gameoverSrc = require('../assets/sounds/snake-gameover.mp3'); + * const recordSrc = require('../assets/sounds/snake-record.mp3'); + * ``` + * + * 4. Im Hook-useEffect die Sounds preloaden: + * ```ts + * Audio.Sound.createAsync(eatSrc, { volume: 0.5 }).then((r) => (eatRef.current = r.sound)); + * // … analog für alle drei + * ``` + * + * 5. In den `play*`-Funktionen `await ref.current?.replayAsync()` aufrufen. + * + * Wenn die Files fehlen aber expo-av da ist: keine Crashes — die createAsync-Calls + * fangen den Error und der Hook läuft im Haptic-only-Mode weiter. + */ +export function useSnakeSounds(enabled: boolean = true) { + const enabledRef = useRef(enabled); + useEffect(() => { + enabledRef.current = enabled; + }, [enabled]); + + useEffect(() => { + return () => { + // Cleanup: bei späterer Audio-Integration unloadAsync() für alle Sounds. + }; + }, []); + + return { + playEat: () => { + if (!enabledRef.current) return; + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch(() => {}); + // TODO Audio: eatRef.current?.replayAsync().catch(() => {}); + }, + playMove: () => { + // Bewusst leer — sonst zu viel Vibration bei jedem Tick. + // Nur via Audio (subtiler als Haptic). + // TODO Audio: moveRef.current?.replayAsync().catch(() => {}); + }, + playGameOver: () => { + if (!enabledRef.current) return; + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error).catch(() => {}); + // TODO Audio: gameoverRef.current?.replayAsync().catch(() => {}); + }, + playNewRecord: () => { + if (!enabledRef.current) return; + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch(() => {}); + // TODO Audio: recordRef.current?.replayAsync().catch(() => {}); + }, + }; +} diff --git a/apps/rebreak-native/lib/deviceId.ts b/apps/rebreak-native/lib/deviceId.ts index cddd020..4be7e24 100644 --- a/apps/rebreak-native/lib/deviceId.ts +++ b/apps/rebreak-native/lib/deviceId.ts @@ -1,6 +1,7 @@ import { Platform } from 'react-native'; import AsyncStorage from '@react-native-async-storage/async-storage'; import * as Application from 'expo-application'; +import Constants from 'expo-constants'; const STORAGE_KEY = 'rebreak_device_id'; @@ -48,3 +49,36 @@ export function getPlatformName(): string { if (Platform.OS === 'android') return 'android'; return 'web'; } + +export interface DeviceInfo { + deviceId: string; + platform: string; + name: string; + model: string; + osVersion: string; + appVersion: string; +} + +export async function getDeviceInfo(): Promise { + const deviceId = await getDeviceId(); + const platform = getPlatformName(); + + const name = + (Constants as any).deviceName || + Constants.platform?.ios?.model || + platform; + + const model = + Constants.platform?.ios?.model || + Constants.platform?.android?.versionCode?.toString() || + platform; + + const osVersion = + Constants.platform?.ios?.systemVersion?.toString() || + (Platform.Version as string | number)?.toString() || + ''; + + const appVersion = Application.nativeApplicationVersion || ''; + + return { deviceId, platform, name, model, osVersion, appVersion }; +} diff --git a/apps/rebreak-native/lib/deviceModel.ts b/apps/rebreak-native/lib/deviceModel.ts new file mode 100644 index 0000000..10e27bc --- /dev/null +++ b/apps/rebreak-native/lib/deviceModel.ts @@ -0,0 +1,79 @@ +const IPHONE_MAP: Record = { + 'iPhone10,1': 'iPhone 8', + 'iPhone10,4': 'iPhone 8', + 'iPhone10,2': 'iPhone 8 Plus', + 'iPhone10,5': 'iPhone 8 Plus', + 'iPhone10,3': 'iPhone X', + 'iPhone10,6': 'iPhone X', + 'iPhone11,2': 'iPhone XS', + 'iPhone11,4': 'iPhone XS Max', + 'iPhone11,6': 'iPhone XS Max', + 'iPhone11,8': 'iPhone XR', + 'iPhone12,1': 'iPhone 11', + 'iPhone12,3': 'iPhone 11 Pro', + 'iPhone12,5': 'iPhone 11 Pro Max', + 'iPhone12,8': 'iPhone SE (2. Gen.)', + 'iPhone13,1': 'iPhone 12 mini', + 'iPhone13,2': 'iPhone 12', + 'iPhone13,3': 'iPhone 12 Pro', + 'iPhone13,4': 'iPhone 12 Pro Max', + 'iPhone14,2': 'iPhone 13 Pro', + 'iPhone14,3': 'iPhone 13 Pro Max', + 'iPhone14,4': 'iPhone 13 mini', + 'iPhone14,5': 'iPhone 13', + 'iPhone14,6': 'iPhone SE (3. Gen.)', + 'iPhone14,7': 'iPhone 14', + 'iPhone14,8': 'iPhone 14 Plus', + 'iPhone15,2': 'iPhone 14 Pro', + 'iPhone15,3': 'iPhone 14 Pro Max', + 'iPhone15,4': 'iPhone 15', + 'iPhone15,5': 'iPhone 15 Plus', + 'iPhone16,1': 'iPhone 15 Pro', + 'iPhone16,2': 'iPhone 15 Pro Max', + 'iPhone17,1': 'iPhone 16 Pro', + 'iPhone17,2': 'iPhone 16 Pro Max', + 'iPhone17,3': 'iPhone 16', + 'iPhone17,4': 'iPhone 16 Plus', + 'iPhone17,5': 'iPhone 16e', + 'iPhone18,1': 'iPhone 17 Pro', + 'iPhone18,2': 'iPhone 17 Pro Max', + 'iPhone18,3': 'iPhone 17', + 'iPhone18,4': 'iPhone Air', +}; + +const IPAD_MAP: Record = { + 'iPad13,4': 'iPad Pro 11" (M1)', + 'iPad13,5': 'iPad Pro 11" (M1)', + 'iPad13,6': 'iPad Pro 11" (M1)', + 'iPad13,7': 'iPad Pro 11" (M1)', + 'iPad13,8': 'iPad Pro 12.9" (M1)', + 'iPad13,9': 'iPad Pro 12.9" (M1)', + 'iPad13,10': 'iPad Pro 12.9" (M1)', + 'iPad13,11': 'iPad Pro 12.9" (M1)', + 'iPad13,16': 'iPad Air (5. Gen.)', + 'iPad13,17': 'iPad Air (5. Gen.)', + 'iPad13,18': 'iPad (10. Gen.)', + 'iPad13,19': 'iPad (10. Gen.)', + 'iPad14,1': 'iPad mini (6. Gen.)', + 'iPad14,2': 'iPad mini (6. Gen.)', + 'iPad14,3': 'iPad Pro 11" (M2)', + 'iPad14,4': 'iPad Pro 11" (M2)', + 'iPad14,5': 'iPad Pro 12.9" (M2)', + 'iPad14,6': 'iPad Pro 12.9" (M2)', + 'iPad14,8': 'iPad Air 11" (M2)', + 'iPad14,9': 'iPad Air 11" (M2)', + 'iPad14,10': 'iPad Air 13" (M2)', + 'iPad14,11': 'iPad Air 13" (M2)', + 'iPad16,1': 'iPad mini (A17 Pro)', + 'iPad16,2': 'iPad mini (A17 Pro)', + 'iPad16,3': 'iPad Pro 11" (M4)', + 'iPad16,4': 'iPad Pro 11" (M4)', + 'iPad16,5': 'iPad Pro 13" (M4)', + 'iPad16,6': 'iPad Pro 13" (M4)', +}; + +export function decodeAppleModel(modelCode: string | null | undefined): string { + if (!modelCode) return ''; + const trimmed = modelCode.trim(); + return IPHONE_MAP[trimmed] ?? IPAD_MAP[trimmed] ?? trimmed; +} diff --git a/apps/rebreak-native/lib/mailErrors.ts b/apps/rebreak-native/lib/mailErrors.ts new file mode 100644 index 0000000..602f346 --- /dev/null +++ b/apps/rebreak-native/lib/mailErrors.ts @@ -0,0 +1,79 @@ +/** + * Übersetzt rohe Backend/IMAP-Fehlermeldungen in benutzerfreundliche Sätze. + * + * Backend liefert oft IMAP-Server-Antworten 1:1 durch (z.B. + * `[AUTHENTICATIONFAILED] Invalid credentials (Failure)`). Die zeigen wir + * dem User NICHT — stattdessen humane Übersetzung mit Hinweis was zu tun ist. + */ +export type MailErrorReason = + | 'auth_failed' + | 'app_password_required' + | 'connection_failed' + | 'host_unreachable' + | 'tls_error' + | 'rate_limited' + | 'unknown'; + +export function classifyMailError(raw: string | null | undefined): MailErrorReason { + if (!raw) return 'unknown'; + const s = raw.toLowerCase(); + + if ( + s.includes('authenticationfailed') || + s.includes('invalid credentials') || + s.includes('authentication failed') || + s.includes('login failed') || + s.includes('auth failed') || + s.includes('bad password') || + s.includes('wrong password') + ) { + return 'auth_failed'; + } + + if ( + s.includes('application-specific password') || + s.includes('app password required') || + s.includes('weblogin_required') || + s.includes('two-factor') + ) { + return 'app_password_required'; + } + + if ( + s.includes('etimedout') || + s.includes('econnrefused') || + s.includes('connection timeout') || + s.includes('socket timeout') || + s.includes('connection reset') + ) { + return 'connection_failed'; + } + + if ( + s.includes('enotfound') || + s.includes('host not found') || + s.includes('getaddrinfo') || + s.includes('dns') + ) { + return 'host_unreachable'; + } + + if (s.includes('tls') || s.includes('ssl') || s.includes('certificate')) { + return 'tls_error'; + } + + if (s.includes('rate limit') || s.includes('too many') || s.includes('throttl')) { + return 'rate_limited'; + } + + return 'unknown'; +} + +/** + * Liefert den i18n-Schlüssel für die humane Variante eines Mail-Errors. + * Caller ruft `t(humanizeMailError(rawError))` für den finalen Text. + */ +export function humanizeMailError(raw: string | null | undefined): string { + const reason = classifyMailError(raw); + return `mail.errors.${reason}`; +} diff --git a/apps/rebreak-native/lib/sosTtsQueue.ts b/apps/rebreak-native/lib/sosTtsQueue.ts index b489e83..0e954c5 100644 --- a/apps/rebreak-native/lib/sosTtsQueue.ts +++ b/apps/rebreak-native/lib/sosTtsQueue.ts @@ -197,7 +197,7 @@ export class SosTtsQueue { signal: AbortSignal, metric?: BenchOnMetric, ): Promise<{ uri: string } | null> { - const endpoint = this.opts.endpoint ?? '/api/coach/speak-openai'; + const endpoint = this.opts.endpoint ?? '/api/coach/speak'; const isGoogleCloud = endpoint.endsWith('/speak-google'); metric?.('tts-fetch-start', { endpoint }); const res = await fetch(`${this.opts.apiBase}${endpoint}`, { diff --git a/apps/rebreak-native/locales/de.json b/apps/rebreak-native/locales/de.json index 34ed3b5..c68c6bd 100644 --- a/apps/rebreak-native/locales/de.json +++ b/apps/rebreak-native/locales/de.json @@ -117,7 +117,7 @@ "title": "ReBreak Games", "subtitle": "Casual spielen ohne SOS — Memory, Snake, Tetris und Tic-Tac-Toe.", "back_to_picker": "Spiele", - "last_score": "Score: {{score}}", + "last_score": "Score: %{score}", "skeleton_footer": "Skeleton — Highscore-Leaderboard kommt in Phase C" }, "home": { @@ -172,7 +172,6 @@ "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", @@ -182,9 +181,7 @@ "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", @@ -202,7 +199,6 @@ "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", @@ -226,10 +222,8 @@ "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", @@ -244,7 +238,6 @@ "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", @@ -253,7 +246,6 @@ "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", @@ -272,7 +264,6 @@ "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", @@ -280,7 +271,6 @@ "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", @@ -296,7 +286,6 @@ "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.", @@ -306,7 +295,6 @@ "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": { @@ -326,16 +314,13 @@ "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.", @@ -344,7 +329,6 @@ "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", @@ -353,14 +337,11 @@ "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}", @@ -372,7 +353,6 @@ "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", @@ -382,10 +362,8 @@ "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", @@ -393,24 +371,42 @@ "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." + "upgrade_alert_desc": "Upgrade auf Pro für bis zu 3 Postfächer, auf Legend für unbegrenzte Postfächer.", + "chart_title": "Letzte 7 Tage", + "chart_week_total": "%{count} diese Woche", + "status_auth_error": "Auth-Fehler", + "status_connect_error": "Verbindungsfehler", + "status_error_tap_hint": "Tippen zum Beheben", + "status_stale": "Stale", + "status_stale_last_scan": "letzter scan %{rel}", + "status_live_idle": "IDLE aktiv seit %{rel}", + "status_live_no_new_mail": "verbunden · keine neue mail seit %{rel}", + "status_waiting_first_connect": "Wartet auf erste Verbindung", + "auth_error_title": "App-Password ungültig", + "auth_error_subtitle": "Das App-Password für %{email} ist abgelaufen oder falsch. Bitte erneuer es und trag es hier ein.", + "auth_error_renew_link": "Neues App-Password erstellen", + "errors": { + "auth_failed": "Das App-Passwort ist nicht korrekt. Bitte erneuere es bei deinem Mail-Anbieter und trage es hier ein.", + "app_password_required": "Dein Mail-Anbieter verlangt ein App-spezifisches Passwort. Erstelle eines in den Account-Einstellungen.", + "connection_failed": "Verbindung zum Mail-Server fehlgeschlagen. Bitte später erneut versuchen.", + "host_unreachable": "Mail-Server ist gerade nicht erreichbar. Internet-Verbindung prüfen oder später erneut versuchen.", + "tls_error": "Sichere Verbindung zum Mail-Server konnte nicht hergestellt werden. Provider kontaktieren.", + "rate_limited": "Zu viele Verbindungsversuche. Bitte ein paar Minuten warten und erneut versuchen.", + "unknown": "Unbekannter Fehler beim Verbinden. Bitte App-Passwort prüfen oder erneut versuchen." + } }, "settings": { "title": "Einstellungen", @@ -474,13 +470,13 @@ "debug_tts_desc": "Cartesia / ElevenLabs / Gemini (DEV)", "devices_page_title": "Registrierte Geräte", "devices_slots": "Geräte-Slots", - "devices_slots_desc": "Dein {{plan}}-Plan erlaubt diese Anzahl gleichzeitiger Geräte.", + "devices_slots_desc": "Dein %{plan}-Plan erlaubt diese Anzahl gleichzeitiger Geräte.", "devices_this_device": "Dieses Gerät", "devices_since": "seit", "devices_just_now": "gerade aktiv", - "devices_mins_ago": "vor {{count}}m", - "devices_hours_ago": "vor {{count}}h", - "devices_days_ago": "vor {{count}}d", + "devices_mins_ago": "vor %{count}m", + "devices_hours_ago": "vor %{count}h", + "devices_days_ago": "vor %{count}d", "devices_empty": "Keine Geräte registriert", "devices_hint": "Geräte, die du entfernst, werden beim nächsten Login wieder registriert. Dieses Gerät kann nicht entfernt werden, solange du eingeloggt bist.", "devices_remove_title": "Gerät entfernen", @@ -489,7 +485,7 @@ }, "device_limit": { "title": "Geräte-Limit erreicht", - "subtitle": "{{max}} von {{max}} Geräten belegt ({{plan}}) — entferne ein Gerät um weiterzumachen", + "subtitle": "%{count} von %{max} Geräten belegt (%{plan}) — entferne ein Gerät um weiterzumachen", "hint": "Entfernte Geräte können sich beim nächsten Login wieder registrieren.", "remove_cta": "Gerät entfernen" }, @@ -731,6 +727,24 @@ "motivational_1": "Jede Minute Fokus ist eine Minute für dich.", "motivational_2": "Konzentration trainieren — genau das bist du gerade.", "motivational_3": "Gut gespielt. Und gut, dass du hier bist.", - "motivational_4": "Kleine Pausen, große Wirkung." + "motivational_4": "Kleine Pausen, große Wirkung.", + "lyra_title_record": "Neuer Rekord!", + "lyra_body_record": "Du hast dich selbst übertroffen. Stark.", + "lyra_title_good": "Klasse!", + "lyra_body_good": "Du bist voll im Flow — der Impuls hatte keine Chance.", + "lyra_title_ok": "Weiter so", + "lyra_body_ok": "Jede Runde bringt dich weiter. Bleib dabei.", + "lyra_title_low": "Nächstes Mal", + "lyra_body_low": "Aufzutauchen zählt schon. Du schaffst das.", + "rating_saved": "Bewertung gespeichert", + "save_rating": "Bewertung speichern", + "feedback_placeholder": "Was hat dir gefallen oder gefehlt?", + "share_result": "In Community teilen", + "share_to_community": "Ergebnis teilen", + "share_challenge": "Kannst du das schlagen?", + "share_loading": "Lyra formuliert...", + "post_to_community": "Posten", + "posted": "Im Community-Feed gepostet", + "post_error": "Posten fehlgeschlagen, nochmal versuchen" } } diff --git a/apps/rebreak-native/locales/en.json b/apps/rebreak-native/locales/en.json index 1626069..ce94eb9 100644 --- a/apps/rebreak-native/locales/en.json +++ b/apps/rebreak-native/locales/en.json @@ -117,7 +117,7 @@ "title": "ReBreak Games", "subtitle": "Casual play outside SOS — Memory, Snake, Tetris and Tic-Tac-Toe.", "back_to_picker": "Games", - "last_score": "Score: {{score}}", + "last_score": "Score: %{score}", "skeleton_footer": "Skeleton — Highscore leaderboard coming in Phase C" }, "home": { @@ -172,7 +172,6 @@ "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", @@ -182,9 +181,7 @@ "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", @@ -202,7 +199,6 @@ "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", @@ -226,10 +222,8 @@ "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", @@ -244,7 +238,6 @@ "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", @@ -253,7 +246,6 @@ "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", @@ -272,7 +264,6 @@ "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", @@ -280,7 +271,6 @@ "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", @@ -296,7 +286,6 @@ "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.", @@ -306,7 +295,6 @@ "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": { @@ -326,16 +314,13 @@ "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.", @@ -344,7 +329,6 @@ "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", @@ -353,14 +337,11 @@ "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", @@ -372,7 +353,6 @@ "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", @@ -382,13 +362,10 @@ "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", @@ -396,21 +373,40 @@ "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)" + "activity_no_subject": "(no subject)", + "chart_title": "Last 7 days", + "chart_week_total": "%{count} this week", + "status_auth_error": "Auth Error", + "status_connect_error": "Connection Error", + "status_error_tap_hint": "Tap to fix", + "status_stale": "Stale", + "status_stale_last_scan": "last scan %{rel}", + "status_live_idle": "IDLE active since %{rel}", + "status_live_no_new_mail": "connected · no new mail since %{rel}", + "status_waiting_first_connect": "Waiting for first connection", + "auth_error_title": "App Password invalid", + "auth_error_subtitle": "The app password for %{email} has expired or is incorrect. Please renew it and enter it below.", + "auth_error_renew_link": "Create new app password", + "errors": { + "auth_failed": "The app password is incorrect. Please regenerate it at your mail provider and enter it here.", + "app_password_required": "Your mail provider requires an app-specific password. Create one in your account settings.", + "connection_failed": "Could not connect to the mail server. Please try again later.", + "host_unreachable": "Mail server is currently unreachable. Check your internet connection or try again later.", + "tls_error": "Secure connection to the mail server failed. Please contact your provider.", + "rate_limited": "Too many connection attempts. Please wait a few minutes and try again.", + "unknown": "Unknown error while connecting. Please check the app password and try again." + } }, "settings": { "title": "Settings", @@ -474,13 +470,13 @@ "debug_tts_desc": "Cartesia / ElevenLabs / Gemini (DEV)", "devices_page_title": "Registered devices", "devices_slots": "Device slots", - "devices_slots_desc": "Your {{plan}} plan allows this many simultaneous devices.", + "devices_slots_desc": "Your %{plan} plan allows this many simultaneous devices.", "devices_this_device": "This device", "devices_since": "since", "devices_just_now": "just active", - "devices_mins_ago": "{{count}}m ago", - "devices_hours_ago": "{{count}}h ago", - "devices_days_ago": "{{count}}d ago", + "devices_mins_ago": "%{count}m ago", + "devices_hours_ago": "%{count}h ago", + "devices_days_ago": "%{count}d ago", "devices_empty": "No devices registered", "devices_hint": "Devices you remove will re-register on next sign-in. This device cannot be removed while you are signed in.", "devices_remove_title": "Remove device", @@ -489,7 +485,7 @@ }, "device_limit": { "title": "Device limit reached", - "subtitle": "{{max}} of {{max}} device slots used ({{plan}}) — remove a device to continue", + "subtitle": "%{count} of %{max} device slots used (%{plan}) — remove a device to continue", "hint": "Removed devices can re-register on next sign-in.", "remove_cta": "Remove device" }, @@ -731,6 +727,24 @@ "motivational_1": "Every minute of focus is a minute for you.", "motivational_2": "Training your attention — that's exactly what you just did.", "motivational_3": "Well played. And good that you're here.", - "motivational_4": "Small pauses, big impact." + "motivational_4": "Small pauses, big impact.", + "lyra_title_record": "New record!", + "lyra_body_record": "You surpassed yourself. Impressive.", + "lyra_title_good": "Excellent!", + "lyra_body_good": "You were fully in the zone — the urge had no chance.", + "lyra_title_ok": "Keep going", + "lyra_body_ok": "Every round moves you forward. Stay with it.", + "lyra_title_low": "Next time", + "lyra_body_low": "Showing up already counts. You've got this.", + "rating_saved": "Rating saved", + "save_rating": "Save rating", + "feedback_placeholder": "What did you like or miss?", + "share_result": "Share to community", + "share_to_community": "Share your result", + "share_challenge": "Can you beat this?", + "share_loading": "Lyra is writing...", + "post_to_community": "Post", + "posted": "Posted to the community feed", + "post_error": "Posting failed, please try again" } } diff --git a/apps/rebreak-native/package.json b/apps/rebreak-native/package.json index 3607a31..6f2fd0f 100644 --- a/apps/rebreak-native/package.json +++ b/apps/rebreak-native/package.json @@ -55,6 +55,7 @@ "react-native": "0.81.5", "react-native-bottom-tabs": "^1.2.0", "react-native-gesture-handler": "~2.28.0", + "react-native-keyboard-controller": "^1.21.7", "react-native-mmkv": "^3.1.0", "react-native-reanimated": "~4.1.7", "react-native-safe-area-context": "5.6.2", diff --git a/apps/rebreak-native/stores/devices.ts b/apps/rebreak-native/stores/devices.ts index bcba99a..e230568 100644 --- a/apps/rebreak-native/stores/devices.ts +++ b/apps/rebreak-native/stores/devices.ts @@ -1,6 +1,6 @@ import { create } from 'zustand'; import { apiFetch } from '../lib/api'; -import { getDeviceId, getPlatformName } from '../lib/deviceId'; +import { getDeviceInfo } from '../lib/deviceId'; export interface UserDevice { id: string; @@ -18,45 +18,42 @@ type DevicesState = { maxDevices: number; plan: string; loading: boolean; - registered: boolean; ensureRegistered: () => Promise; loadDevices: () => Promise; removeDevice: (id: string) => Promise; }; -export const useDevicesStore = create((set, get) => ({ +export const useDevicesStore = create((set) => ({ devices: [], maxDevices: 1, plan: 'free', loading: false, - registered: false, ensureRegistered: async () => { - if (get().registered) return; - - const deviceId = await getDeviceId().catch(() => null); - if (!deviceId) return; - - const platform = getPlatformName(); + const info = await getDeviceInfo().catch(() => null); + if (!info) return; await apiFetch('/api/devices/register', { method: 'POST', skipDeviceHeader: true, - body: { deviceId, platform }, + body: { + deviceId: info.deviceId, + platform: info.platform, + name: info.name, + model: info.model, + osVersion: info.osVersion, + appVersion: info.appVersion, + }, }).then((res: any) => { - set({ registered: true, maxDevices: res.max ?? 1 }); - }).catch(() => { - // Limit reached or transient — App continues; limit UI is handled at auth level - }); + set({ maxDevices: res.max ?? 1 }); + }).catch(() => {}); }, loadDevices: async () => { set({ loading: true }); try { - if (!get().registered) { - await get().ensureRegistered(); - } + await useDevicesStore.getState().ensureRegistered(); const res = await apiFetch<{ devices: UserDevice[]; max: number; plan: string }>( '/api/devices' ); diff --git a/backend/prisma/migrations/20260509_add_mail_connection_status_fields/migration.sql b/backend/prisma/migrations/20260509_add_mail_connection_status_fields/migration.sql new file mode 100644 index 0000000..6cbb40f --- /dev/null +++ b/backend/prisma/migrations/20260509_add_mail_connection_status_fields/migration.sql @@ -0,0 +1,8 @@ +-- Migration: add_mail_connection_status_fields +-- Adds error-tracking + IDLE heartbeat timestamp to mail_connections. +-- Deploy: pnpm prisma migrate deploy (on server) + +ALTER TABLE "rebreak"."mail_connections" + ADD COLUMN "last_connect_error" TEXT, + ADD COLUMN "last_connect_error_at" TIMESTAMP(3), + ADD COLUMN "last_idle_heartbeat_at" TIMESTAMP(3); diff --git a/backend/prisma/migrations/20260509_add_post_game_name/migration.sql b/backend/prisma/migrations/20260509_add_post_game_name/migration.sql new file mode 100644 index 0000000..761a80e --- /dev/null +++ b/backend/prisma/migrations/20260509_add_post_game_name/migration.sql @@ -0,0 +1,3 @@ +-- Migration: add game_name column to community_posts +-- Generated: 2026-05-09 +ALTER TABLE "rebreak"."community_posts" ADD COLUMN "game_name" TEXT; diff --git a/docs/RIVE_ANIMATOR_BRIEF.md b/docs/RIVE_ANIMATOR_BRIEF.md new file mode 100644 index 0000000..662eaac --- /dev/null +++ b/docs/RIVE_ANIMATOR_BRIEF.md @@ -0,0 +1,158 @@ +# Rive Animator Brief — Lyra Avatar Emotion States + +**Ready-to-publish — copy section between the dividers below into your job-post on Dribbble / Fiverr / Twitter / Rive Discord.** + +--- + +## 🎯 Project: Rebreak — Lyra Avatar Animation + +I'm hiring a Rive-animator to extend **Lyra**, the AI-companion of **Rebreak** — a recovery app for people working through gambling addiction (German market, planned DiGA-listing in healthcare). + +**Critical context:** This is a sensitive recovery-app, NOT a playful game. Lyra is a warm, present companion — never cute-mascot, never childish, never dramatic. Subtle > exaggerated. + +**Budget: $100 USD, ~1 week target.** Self-qualify before applying. + +## What I have (current `.riv`) + +- File: `lyra-avatar.riv` (264 KB) — will be shared after engagement-confirmation +- **Artboard name:** `Artboard` +- **State machine name:** `State Machine 1`, default state `idle` +- **Existing animation timelines** (extracted via `strings`): + - `Idle Loop` — wired as `idle` + - `idle to Pose 1` + `Pose 1 loop` — wired as `happy` (2-phase: app manually switches via 900ms JS-timer) + - `01 Wave 1` — wired as `empathy` + - `01 Wave 2` — exists, NOT wired (orphan) + - `WALK` — placeholder, currently aliased as `thinking` (please replace with proper thinking-pose animation) + - `Kedip` (Indonesian "blink") — orphan from template +- **Runtime:** `rive-react-native ^9.0.1` (export against this compatible Rive editor version) +- **Avatar usage:** appears at 40px (small list), 112px (chat-header), 160px (onboarding hero) — **must read clearly even at 40px** + +## ⚠️ Critical naming contract — do NOT rename existing names + +The React code calls timelines **directly by name** (not via state-machine inputs). If you rename a timeline, the app silently breaks (no animation plays, no error). This means: + +- **Keep:** `Artboard` artboard, `State Machine 1` SM-name, `idle` default-state, `Idle Loop` timeline name +- **New emotion-states must use exactly these timeline names** (snake_case): `sad`, `joy`, `confusion`, `calm`, `surprise`, `listening`, `thinking` (replacing `WALK`) +- **For multi-phase emotions** (e.g. dramatic intro → loop): use ` intro` + ` loop` pattern, just like existing `idle to Pose 1` + `Pose 1 loop` +- **Bonus** (+$X if you propose): expose state-machine inputs (`SetEmotion` enum-trigger) so we can transition imperatively. Optional — named-timelines remain the primary contract. + +## Deliverables — emotion states + +You'll add these to the existing state-machine. Total: **6 new states + 1 placeholder-replacement (`thinking`)**. + +### Tier 1 — must-have (4 states, baseline budget) + +| Internal name (timeline) | Trigger in app | Eyes | Brow | Mouth | Body | +|---|---|---|---|---|---| +| **`thinking`** (REPLACE `WALK`) | LLM is generating response | look up-and-to-side, slow blink | one slightly raised | gently closed, slight pursed | finger-near-temple pose if possible, head tilt ~5° | +| **`listening`** | User mid-typing OR voice-recording | open, attentive, natural blink | neutral relaxed | gently closed, micro-Mona-Lisa upturn | subtle head nod every ~3s | +| **`calm`** | Breathing exercise / meditation active | half-closed | neutral relaxed | slight serene smile | very slow up/down breathing loop (~4s cycle to match 4-7-8 breathing pace) | +| **`sad`** | User describes loss/relapse/shame | softened, slight downcast, lower lid raised | inner-up, slight angle | closed, neutral or micro-downturn | tiny head tilt down (~5°), slow breathing | + +### Tier 2 — nice-to-have (2-3 more states, push budget if you can) + +| Internal name (timeline) | Trigger in app | Visual direction | +|---|---|---| +| **`joy`** | Streak milestone, big celebration | bigger smile than `happy` (warmer not goofy), small head bounce on intro then settle | +| **`confusion`** | Lyra needs clarification | one brow raised, slight squint, head tilt ~10° to one side | +| **`surprise`** | Unexpected user input | wide-open eyes (brief), both brows raised, small "o" mouth, quick head pull-back micro on intro then settle | + +### Skip these (anti-patterns for recovery use-case) + +- ❌ `angry` / `frustrated` — never from coach +- ❌ `shocked` / `horror` — too dramatic for trauma-context +- ❌ Cute-mascot expressions (winks, tongue-out, hearts in eyes) +- ❌ Heavy bone-rigs / particles — runtime cost too high + +## Visual style guidelines + +- **Match existing Lyra-look** (extract palette + line-weight from the `.riv` you'll receive) +- **Subtle is better** — these animations play during emotional moments, they should *support* not *demand* attention +- **Loop-friendly** — `idle`, `calm`, `listening` should breathe naturally, no pop on loop boundary +- **Smooth transitions** — prefer 200-400ms crossfades over hard cuts. Especially: `empathy → idle → happy` should never feel jarring (route through idle, never direct jump from negative to positive) +- **Readability at 40px** — exaggerate eye/brow shapes slightly, avoid mouth-only emotion (mouth is too small at 40px to carry expression) + +## Technical Requirements + +- **Output: ONE `.riv` file** named exactly `lyra-avatar.riv` (replaces current file) +- **Single artboard, single state-machine** — preserve existing structure +- **Rive editor version**: 2024.x (compatible with `rive-react-native ^9.0.1`) +- **Performance**: 60 fps target on mid-range Android (Pixel 5-class) +- **File-size**: ≤500KB (current 264KB, want headroom for new states) +- **Loop-cycle precision**: `calm` should be ~4 seconds (we sync app-side to user's 4-7-8 breathing exercise) + +## Bonus task (optional, +scope) + +Existing `happy` uses a 2-phase manual switch via 900ms JS-timeout — clunky. **If you can fix this so it loops cleanly inside the `.riv` itself** (intro auto-blends into loop without app-side coordination), that's worth +$X. + +## Timeline + Budget + +- **$100 USD flat**, paid: 50% on first-draft approval, 50% on final delivery +- **1 week** from brief-confirmation +- **Milestones**: + 1. Day 0: brief-confirm + you receive `.riv` file + answers to your questions + 2. Day 2-3: first draft of 1-2 states for visual-direction approval (style-confirm) + 3. Day 4-6: remaining states + 1 round of revisions + 4. Day 7: final delivery + +## Deliverables you provide + +1. **`lyra-avatar.riv`** — replaces existing file +2. **Short README** (1 page max): + - List of all timeline-names + when each plays + - Any limitations or known issues + - State-machine diagram (simple) +3. **Source-file** (Rive editor `.rev` or equivalent) for future edits +4. **Optional bonus**: short demo-video (15-30 sec) showing all states cycling — earns trust + +## Questions to ask BEFORE starting (please answer in your application) + +1. Can I use the current `.riv` as a base and add states, or do you want a clean rebuild? +2. Confirm Rive runtime version (`rive-react-native ^9.0.1`) — compatible with your export? +3. Should I also fix the existing `happy` 2-phase JS-timer (auto-blend in `.riv` instead)? +4. For the German market: any culture-specific gestures to avoid? +5. Any brand-colors / hex-codes I must match? +6. Audio cues or visual-only? +7. Do you have Figma / brand-guide I should reference? + +## What I value in your work + +- Restraint over flashiness +- Clean state-machine architecture (other devs may extend later) +- Honest communication if scope is too tight for budget — happy to scope down to 4 states (Tier 1 only) +- Async-first (Slack-like / email / Discord-DM) + +## How to apply + +Send me: +1. Link to **Rive portfolio** (not Lottie, not After-Effects — actual `.riv` work) +2. **Confirmation you've read this brief** (so I know it's not auto-applied) +3. Your suggested approach: extend existing state-machine OR rebuild? +4. Your answers to the 7 questions above + +Looking forward to working together. + +--- + +**End of brief — copy everything above into your job-post.** + +## How to use this brief (internal — not for animator) + +1. **Vor dem Senden** alle `[Platzhalter]`-Stellen (insbesondere Communication-Channel falls du das spezifizieren willst) ausfüllen +2. **Aktuelle `.riv` mitschicken** — Animator braucht sie als Style-Referenz + State-Machine-Setup +3. **Klarstellen**: Emotion-Namen in der Tabelle sind **Code-Contracts** und müssen exakt so im Rive-File heißen — sonst muss React-Code refactored werden +4. **Vor Vergabe** 2-3 Animator-Portfolios checken — suche „warm/subtle character"-style, NICHT nur knallige Logo-Animationen +5. **Nach Erhalt der ersten Draft** auf echtem Android-Mid-Range-Gerät testen (Pixel 5 oder älter), nicht nur iOS-Simulator +6. **Wo publishen** (in Reihenfolge der Wahrscheinlichkeit): + - Rive Discord (https://rive.app/community → Discord) — Rive-spezialisierte Animatoren, keine LottieFiles-Refugees + - Twitter `#RiveAnimation` hashtag + DMs an Animator-Portfolios die du gut findest + - Fiverr „Rive animator" custom-offers ($30-150 typische Gigs) + - Dribbble „Hiring" section — gemischte Quality, mehr Style-fokus +7. **Wenn `.riv` ankommt**: drag in `apps/rebreak-native/assets/lyra-avatar.riv` (overwrite), commit, fertig. Code ist schon flexibel (RiveAvatar accepts any state-name nach Task #39 component-flex). + +## Sources / Internal-Files + +- Brief-Audit: `apps/rebreak-native/components/RiveAvatar.tsx` (lines 42-51 — Emotion-API contract) +- Existing `.riv`: `apps/rebreak-native/assets/lyra-avatar.riv` (264 KB) +- Plugin: `apps/rebreak-native/plugins/with-rive-asset-android.js` (Android raw-resource auto-mirror) +- Trigger-context: `apps/rebreak-native/app/lyra.tsx` (lines 37-44, 306-323), `apps/rebreak-native/app/urge.tsx`, `apps/rebreak-native/lib/lyraResponse.ts:57-61` (existing `detectEmotion()`) diff --git a/docs/internal/MAIL_DAEMON_DEPLOYMENT.md b/docs/internal/MAIL_DAEMON_DEPLOYMENT.md new file mode 100644 index 0000000..d458fed --- /dev/null +++ b/docs/internal/MAIL_DAEMON_DEPLOYMENT.md @@ -0,0 +1,210 @@ +# MAIL_DAEMON_DEPLOYMENT — Backyard Handoff + +**Erstellt von:** Mo (Mail-Architektur-Agent) +**Datum:** 2026-05-09 +**Status:** Bereit für Deployment — wartet auf Backyard-GO vom User + +## Kontext + +Der `rebreak-imap-idle` Daemon ist ein eigenständiger Node.js-Prozess. +Er hält pro aktivem MailConnection-DB-Eintrag eine persistente IMAP-IDLE-Session +und triggert bei neuer Mail sofort `/api/mail/scan-internal` statt auf den 30min-Cron zu warten. + +Der Daemon liegt unter `backend/imap-idle/index.mjs` und hat seine eigene `package.json`. +Er ist KEIN Teil des Nitro-Builds — er wird direkt via `node` gestartet. + +## Was Backyard tun muss (in dieser Reihenfolge) + +### Schritt 1 — GH-Actions: imap-idle ins Artifact einschließen + +In `.github/workflows/deploy-backend.yml` (oder analog) muss das `backend/imap-idle/`-Verzeichnis +ins deploy-Artifact aufgenommen werden. + +Das Artifact enthält aktuell wahrscheinlich nur `backend/.output-staging/` und `backend/prisma/`. +`backend/imap-idle/` muss ebenfalls mit kopiert werden. + +Konkretes Beispiel (je nach Artifact-Aufbau anpassen): + +```yaml +# In der scp/rsync-Step des deploy-workflows: +- name: Copy imap-idle to server + run: | + scp -r backend/imap-idle/ rebreak-server:/srv/rebreak/backend/imap-idle/ +``` + +### Schritt 2 — npm install auf Server + +Nach dem Artifact-Copy muss auf dem Server `npm install` in `backend/imap-idle/` laufen. +Das installiert `imapflow` und `pg` lokal für den Daemon. + +```bash +cd /srv/rebreak/backend/imap-idle && npm install --production +``` + +Diesen Schritt als deploy-Step in GH-Actions oder in `deploy-from-artifact.sh` ergänzen. + +### Schritt 3 — Zombie-Prozesse aufräumen + +Vor dem ersten Start der neuen pm2-Einträge alte Stale-Einträge entfernen +(falls `rebreak-imap-staging` oder `rebreak-idle-staging` aus altem Setup noch existieren): + +```bash +pm2 delete rebreak-idle-staging rebreak-imap-staging 2>/dev/null || true +``` + +### Schritt 4 — ecosystem.config.js erweitern + +Die folgenden Einträge in `/srv/rebreak/ecosystem.config.js` ergänzen +(unterhalb des bestehenden `rebreak-staging`-Eintrags einfügen): + +```js +// ─── IMAP IDLE Daemon Staging ─────────────────────────────────────────────── +{ + name: "rebreak-idle-staging", + script: "/srv/rebreak/backend/imap-idle/index.mjs", + interpreter: "/root/.nvm/versions/node/v24.11.1/bin/node", + cwd: "/srv/rebreak/backend/imap-idle", + instances: 1, + autorestart: true, + watch: false, + max_memory_restart: "256M", + env: { + NODE_ENV: "production", + // ACHTUNG: Keine Secrets hier hinterlegen. + // Infisical-Wrapper via start-idle-staging.sh (Schritt 5). + }, +}, + +// ─── IMAP IDLE Daemon Prod (auskommentiert bis Prod-Cutover) ─────────────── +// { +// name: "rebreak-idle-prod", +// script: "/srv/rebreak/backend/imap-idle/index.mjs", +// interpreter: "/root/.nvm/versions/node/v24.11.1/bin/node", +// cwd: "/srv/rebreak/backend/imap-idle", +// instances: 1, +// autorestart: true, +// watch: false, +// max_memory_restart: "256M", +// env: { NODE_ENV: "production" }, +// }, +``` + +### Schritt 5 — start-idle-staging.sh erstellen + +Der Daemon braucht die gleichen Infisical-Secrets wie das Backend. +Eine eigene Start-Shell analog zu `backend/start-staging.sh` erstellen: + +Pfad: `/srv/rebreak/backend/imap-idle/start-idle-staging.sh` + +```bash +#!/bin/bash +# rebreak-imap-idle Staging — Infisical-Secret-Injection + +set -euo pipefail +source /etc/environment + +if [[ -z "${INFISICAL_CLIENT_ID:-}" || -z "${INFISICAL_CLIENT_SECRET:-}" ]]; then + echo "[idle] FEHLER: INFISICAL_CLIENT_ID / SECRET nicht gesetzt" >&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) + +[[ -z "$INFISICAL_TOKEN" ]] && { echo "[idle] Infisical login fehlgeschlagen" >&2; exit 1; } + +NODE_BIN="/root/.nvm/versions/node/v24.11.1/bin/node" +DAEMON="/srv/rebreak/backend/imap-idle/index.mjs" + +exec infisical run \ + --projectId="${INFISICAL_PROJECT_ID:-14b11b35-ef59-4b8a-a16b-398f0cc3ad93}" \ + --env=staging \ + --token="$INFISICAL_TOKEN" \ + -- bash -c ' + set -e + export DATABASE_URL="${DATABASE_URL:-${NUXT_DATABASE_URL:-}}" + export ADMIN_SECRET="${ADMIN_SECRET:-${NUXT_ADMIN_SECRET:-}}" + export ENCRYPTION_KEY="${ENCRYPTION_KEY:-${NUXT_ENCRYPTION_KEY:-}}" + export BACKEND_URL="http://127.0.0.1:3016" + exec '"$NODE_BIN"' '"$DAEMON"' + ' +``` + +Dann `chmod +x start-idle-staging.sh` und in ecosystem.config.js den `script`-Key +auf `start-idle-staging.sh` zeigen lassen (mit `interpreter: "bash"`), +analog zum Pattern von `rebreak-staging`. + +### Schritt 6 — pm2 starten + +```bash +pm2 startOrReload /srv/rebreak/ecosystem.config.js +``` + +## Verifikations-Schritte nach Deployment + +```bash +# 1. pm2-Status prüfen +pm2 list +# Erwartung: rebreak-idle-staging → status=online, restart=0 + +# 2. Logs der ersten 60 Sekunden ansehen +pm2 logs rebreak-idle-staging --lines 100 +# Erwartung: "[idle/] connected (...)" für alle aktiven Mailboxen +# "[idle/db] refreshed — N active connections, N sessions" + +# 3. Test: Mail an eine verbundene Mailbox schicken (Betreff: "casino bonus") +# Innerhalb von 5 Sekunden sollte im Log erscheinen: +# "[idle/] exists-event received (new mail)" +# "[idle/] scan-triggered → scanned=X blocked=1" + +# 4. Memory-Check nach 10 Minuten +pm2 monit +# Erwartung: < 100MB bei <20 Connections, < 200MB bei 100 Connections +``` + +## Rollback-Plan + +Falls der Daemon crashed oder instabil ist: + +```bash +pm2 stop rebreak-idle-staging +# NICHT delete — damit Logs erhalten bleiben +``` + +Auswirkung: Mail-Scanning fällt auf den bestehenden 30min-Cron zurück. +Kein Komplett-Ausfall der Mail-Schutz-Funktion. Gambling-Mails werden +weiter gelöscht, nur mit bis zu 30min Verzögerung statt Echtzeit. + +## Bekannte Provider-Quirks + +| Provider | IMAP-Host | Port | TLS | Bekanntes Problem | +|-------------|-----------------------------|------|----------|--------------------------------------------| +| Gmail | imap.gmail.com | 993 | Implicit | App-Password erforderlich (kein OAuth2) | +| iCloud | imap.mail.me.com | 993 | Implicit | App-Specific-Password in Apple-Settings | +| Outlook | outlook.office365.com | 993 | Implicit | IDLE-Drop nach ~20min — disableCompression | +| GMX | imap.gmx.net | 993 | Implicit | Stabil, kein besonderer Quirk | +| Web.de | imap.web.de | 993 | Implicit | Stabil | +| T-Online | secureimap.t-online.de | 993 | Implicit | Stabil | +| Posteo | posteo.de | 993 | Implicit | Stabil | + +Outlook-spezifisch: Der Daemon setzt `disableCompression: true` wenn der Host +`office365` enthält — verhindert partial-read-Fehler nach IDLE-Drop. + +## Datei-Übersicht + +``` +backend/imap-idle/ + index.mjs — Daemon-Hauptdatei (ESM, standalone) + package.json — Eigene Dependencies (imapflow, pg) + README.md — Kurz-Doku (lokal starten, env-vars, log-format) +``` + +## Abhaengigkeiten + +- `imapflow ^1.2.18` — IMAP-Client-Library (bereits in backend/package.json) +- `pg ^8.16.3` — Direkter Postgres-Zugriff (kein Prisma im Daemon-Kontext) +- Node.js >= 20 (ESM, top-level await via main()) +- Infisical-CLI auf dem Server (bereits installiert fuer rebreak-staging) diff --git a/docs/internal/PRIVACY_POLICY_USER_NOTES.md b/docs/internal/PRIVACY_POLICY_USER_NOTES.md new file mode 100644 index 0000000..5a76afe --- /dev/null +++ b/docs/internal/PRIVACY_POLICY_USER_NOTES.md @@ -0,0 +1,196 @@ +# Rebreak — Privacy-Policy User-Notes (DSB-Begleitdokument) + +**Stand:** 2026-05-09 +**Verfasser:** Hans Müller (externer DSB i.V.) +**Adressat:** Chahine Brini (Inhaber Rebreak / künftig Raynis GmbH) +**Kontext:** Begleitnotiz zur veröffentlichten Datenschutzerklärung v1 vom 09.05.2026 + +--- + +## ACHTUNG — HIGH-PRIO Flag + +> **User wünscht ausdrücklich KEIN separates Consent-UI** für die Stufe-1-Übertragung +> von Lyra-Chat-Inhalten an Groq/Anthropic. Die Transparenz wird ausschließlich über +> § 11 Abs. 2–3 der Datenschutzerklärung sowie die übergeordnete Art. 9 Abs. 2 lit. a- +> Einwilligung beim Lyra-Onboarding hergestellt. +> +> **DSB-Bewertung:** Vertretbar bei Stufe 1, da kein Klarname/keine E-Mail/keine +> Account-ID übermittelt wird. Voraussetzung: das Lyra-Onboarding muss eine echte, +> granulare, vorab-eingeholte Einwilligung sein (nicht im AGB-Sammelhaken). Sobald +> identifizierende Inhalte im Chat stehen, ist Stufe 2 Pflicht. **Ziel-Datum für +> Stufe 2: Q3 2026 (siehe Migrations-Plan unten).** + +--- + +## 1. TODO — DPA / AVV-Status der 12 Sub-Auftragsverarbeiter + +| # | Anbieter | Sitz | DPA-Status | TIA | Action | +|---|---|---|---|---|---| +| 1 | Hetzner Online GmbH | DE (EU) | TODO — Standard-AVV vorbereitet | n/a | Bei Hetzner: Anhang 4 + 5 ausfüllen, gegenzeichnen lassen | +| 2 | Stripe Payments Europe Ltd. | IE (EU) → Stripe Inc. (USA) | TODO — Stripe-DPA online akzeptieren (Dashboard) | TODO leichtgewichtig | https://stripe.com/legal/dpa | +| 3 | Groq, Inc. | USA | OFFEN — auf Groq-DPA warten / via Sales anfragen | TODO PFLICHT | Hoch-Prio: Groq Sales kontaktieren falls kein Self-Service DPA | +| 4 | Anthropic PBC | USA | TODO — Anthropic Commercial-Terms + DPA-Addendum | TODO PFLICHT | https://www.anthropic.com/legal/commercial-terms | +| 5 | OpenRouter, Inc. | USA | OFFEN — DPA-Verfügbarkeit unklar, ggf. nicht produktiv nutzen | TODO PFLICHT | Falls keine SCCs verfügbar: Provider rauswerfen | +| 6 | Cartesia, Inc. | USA | OFFEN — DPA anfordern | TODO PFLICHT | Falls TTS optional: erst aktivieren, wenn DPA vorliegt | +| 7 | ElevenLabs Inc. | USA | TODO — ElevenLabs Enterprise-DPA | TODO PFLICHT | https://elevenlabs.io/dpa | +| 8 | Deepgram, Inc. | USA | TODO — Deepgram-DPA via Account-Manager | TODO PFLICHT | Falls STT optional: erst aktivieren, wenn DPA vorliegt | +| 9 | Cloudflare, Inc. | USA (EU-Edge) | TODO — Cloudflare-DPA online | leichtgewichtig | https://www.cloudflare.com/cloudflare-customer-dpa/ | +| 10 | Apple Inc. (APNs) | USA | abgedeckt durch Apple Developer Program License Agreement | leichtgewichtig | Existierender ADP-Vertrag enthält DPA-Anhang | +| 11 | Google LLC (FCM) | USA | TODO — Firebase-DPA via Console | leichtgewichtig | https://firebase.google.com/terms/data-processing-terms | +| 12 | Infisical Inc. | USA | TODO — DPA falls verfügbar; ohne Endnutzer-PII niedrige Prio | n/a | Niedrige Prio (nur tech-Secrets, keine Endnutzer-Daten) | + +**Zusammenfassung:** 0 von 12 DPAs aktuell formal abgeschlossen. Empfehlung: Hetzner + +Stripe + Anthropic + Groq + Cloudflare + Firebase als Top-6 priorisieren (decken die +materiellen Drittland-Übertragungen ab). Realistisches Zeitfenster: 4–6 Wochen. + +--- + +## 2. Anwalt-Review-Checkliste — 3 kritische Punkte + +> **Ich (DSB) bin kein Rechtsanwalt.** Folgende Passagen sind vor produktiver +> Veröffentlichung der Policy zwingend durch eine im IT-/Datenschutzrecht +> spezialisierte Kanzlei zu prüfen. + +### 2.1 Pro-Trial als Gegenleistung für demographische Daten — Risk: HOCH + +**Stelle:** § 5 Abs. 4 Datenschutzerklärung („Pro-Trial-Reward") +**Norm:** Art. 7 Abs. 4 DSGVO (Kopplungsverbot), EDSA-Leitlinien 05/2020 +**Risiko:** Aufsichtsbehörden bewerten „Vorteil gegen Datenpreisgabe" zunehmend +restriktiv. Argumentation „Pro-Features gehen über Kernleistung hinaus" ist tragfähig, +aber nicht risikolos. +**Anwaltsfrage:** Reicht die transparente Darstellung + jederzeitige +Widerrufsmöglichkeit + keine-Kopplung-an-Kernfunktion-Klausel zur Rechtfertigung? +Empfehlung: alternative Formulierungen (z. B. „Anerkennung" statt „Belohnung"), ggf. +Ergänzung um pseudonyme Erhebungs-Variante als Default. + +### 2.2 LLM-Übertragung Stufe 1 ohne separates Consent-UI — Risk: MITTEL + +**Stelle:** § 11 Abs. 2–3 +**Norm:** Art. 9 Abs. 2 lit. a DSGVO, Erwägungsgrund 32 (Granularität der +Einwilligung), Art. 12 DSGVO (Verständlichkeit) +**Risiko:** Aufsichtsbehörden könnten argumentieren, dass die spezifische +Drittland-Übertragung von Gesundheitsdaten an US-LLM-Anbieter eine eigene, granulare +Einwilligung erfordert. +**Anwaltsfrage:** Reicht die übergeordnete Lyra-Onboarding-Einwilligung + +transparente Darstellung in der Datenschutzerklärung aus? Falls nein: Mindest-Anforderung +an UI-Hinweis (z. B. einmalige In-Chat-Notiz „Lyra-Antworten werden via US-Anbieter +generiert", ohne Klick-Block) festlegen. **User-Position:** kein separates Consent-UI +gewünscht — Anwalt soll konkret ja/nein dazu sagen. + +### 2.3 Übergangsklausel Einzelunternehmer → Raynis GmbH — Risk: MITTEL + +**Stelle:** § 1 Abs. 2 +**Norm:** Art. 7 DSGVO (Einwilligung gegenüber konkretem Verantwortlichen), § 25 UmwG / +Asset-Deal-Mechanik, Erwägungsgrund 42 DSGVO +**Risiko:** Bestehende Einwilligungen wurden gegenüber „Chahine Brini, einzelkaufmännisch" +erteilt. Ein einseitiger „Geht-auf-die-GmbH-über"-Hinweis kann nach Aufsichtsbehörden- +Lesart unzureichend sein — insbesondere bei Art. 9-Daten. +**Anwaltsfrage:** Reicht eine vorab-Information per E-Mail + In-App-Notice zur +Übertragung der Einwilligung auf die GmbH, oder muss eine erneute aktive +Bestätigung („Re-Consent") eingeholt werden? Gilt unterschiedliche Behandlung für +Art. 6 vs. Art. 9 Daten? Wir empfehlen, als Default einen Re-Consent-Flow vorzubereiten +und ggf. nicht zu nutzen, falls Anwalt Entwarnung gibt. + +--- + +## 3. Stufe-2-Migrations-Plan: Lyra-Pseudonymisierung (Q3 2026) + +### Ziel +Pre-Processing-Layer im backend zwischen `coach/sos-stream`-Endpoint und LLM-Provider, +der personenbezogene Entitäten (Namen, Orte, E-Mails, Telefonnummern, IBANs, +Verein-/Firmennamen) maskiert, BEVOR der Prompt das Backend verlässt. + +### Technische Schritte (high-level Spec, Detail-Spec geht an `rebreak-backend`) + +1. **Library-Auswahl (KW 27–28 / Juli 2026):** + - Kandidaten: `@microsoft/presidio-analyzer` (Python, via Sidecar), `compromise` + (JS, lightweight), self-hosted spaCy-Service mit dt. Modell. + - Trade-off: Latenz vs. Recall. Ziel: < 80 ms p95-Overhead pro Nachricht. + +2. **Maskierungs-Mapping (KW 28):** + - Pro Conversation eine ephemere ID-Tabelle (memory-only, 30 min TTL). + - Maskierungen: `[PERSON_1]`, `[ORT_1]`, `[EMAIL]`, `[PHONE]`. + - Re-Substitution beim Streaming-Output: vor dem Senden an Client zurückübersetzen + (User soll seine eigenen Namen wieder sehen). + +3. **Backend-Hook (KW 29):** + - Neuer Service `backend/server/services/pii-mask.ts` mit zwei Funktionen: + `maskBeforeLLM(prompt, conversationId)` und `unmaskAfterLLM(stream, conversationId)`. + - Integration in `backend/server/api/coach/sos-stream.get.ts`. + +4. **Telemetrie + Eval (KW 30–31):** + - Anonymisierte Metriken: Anzahl Maskierungen pro Nachricht, Latenz, False-Positive- + Rate (manuelle Stichprobe von 200 Konversationen). + - DSFA-Update mit Stufe-2-Beschreibung. + +5. **Privacy-Policy-Update (KW 32):** + - § 11 Abs. 2 in Stufe-1- und Stufe-2-Beschreibung umstellen. + - Versionierungs-Hinweis nach § 16. + +### Voraussetzungen / Blocker + +- DPAs aller LLM-Anbieter müssen vorher unterschrieben sein (siehe Punkt 1). +- DSFA-Update muss parallel laufen (Hans Müller). +- Backend-Sprint mit ca. 8–12 Personentagen Aufwand schätzbar. + +--- + +## 4. Backyard-Migration-Empfehlung — Marketing-Site / Privacy-Page + +### Status quo + +- `datenschutz.vue` und (neu) `privacy-policy.vue` liegen aktuell im trucko-monorepo + unter `apps/rebreak/app/pages/`. +- Deployment der Marketing-Site läuft separat (nicht über Hetzner-Backend-Pipeline). + +### Empfehlung an `rebreak-strategist` + `backyard` + +Die öffentlich kommunizierte Datenschutzerklärung sollte mittelfristig im +**rebreak-monorepo** leben und über die etablierte Hetzner-Pipeline deployt werden. +Begründung: + +1. **Versionskontrolle + Audit-Trail** — bei einer DiGA-Anwendung ist die Historie der + Datenschutzerklärung als Compliance-Nachweis relevant. Liegt sie im Hauptrepo, ist + sie Teil derselben CI/CD-Logik und Backups wie der Backend-Code. +2. **Stand-Konsistenz** — derzeitige Trennung führt zu Stand-Drift (Marketing-Site: + 01.05.; Backend-Realität: 09.05.). Single-Source-of-Truth-Prinzip. +3. **Hetzner-Hosting** — Datenresidenz EU/DE-konsistent ohne Cloudflare-Pages-Drittland- + Risiko (sofern Marketing-Site aktuell dort läuft). + +**Action:** Backyard-Agent erstellt Migrations-Plan (geschätzt 2 Sprint-Punkte). Bis +dahin pflegen wir die Datei in trucko, mit Cross-Reference im rebreak-monorepo +(`docs/internal/PRIVACY_POLICY_USER_NOTES.md` ← du liest sie gerade). + +--- + +## 5. Risk-Summary (Snapshot 09.05.2026) + +| Bereich | Risk-Level | Begründung | Mitigation | +|---|---|---|---| +| Drittland-Transfer Lyra (Groq/Anthropic) | KRITISCH bis DPAs vorliegen, danach MITTEL | Art. 9-Daten in USA, FISA 702 Risiko | DPAs + TIA + Stufe-2-Pseudo Q3 2026 | +| Pro-Trial-Kopplung | HOCH | Art. 7 Abs. 4 DSGVO Auslegungs-Risiko | Anwalt-Review + transparente Darstellung | +| Re-Consent bei Raynis-GmbH-Übergang | MITTEL | Einwilligungs-Adressat ändert sich | Re-Consent-Flow vorbereiten | +| Demographische Daten | NIEDRIG | Streng user-initiated, klare Trennung von Lyra-Memories | Profile-Form-Validierung | +| Mail-Schutz-Modul | MITTEL bei Aktivierung | Tiefer Eingriff in Mailbox eines Suchterkrankten | Echte opt-in, granulare Einwilligung | +| Cookie/Tracking | NIEDRIG | Keine Drittanbieter-Tracker im Einsatz | Keine Action | +| Push-Notifications | NIEDRIG–MITTEL | APNs/FCM = USA-Transfer + Inhalt kann Gesundheitsbezug haben | Inhalt der Push-Texte minimieren („Du hast eine Erinnerung" statt „Streak gefährdet") | +| Drittland Anbieter ohne DPA | KRITISCH bis geklärt | OpenRouter, Cartesia, Deepgram unklar | Falls kein DPA: Anbieter rauswerfen | + +--- + +## 6. Nächste Schritte (priorisiert) + +1. **Diese Woche:** Hetzner-AVV + Stripe-DPA + Cloudflare-DPA + Firebase-DPA online + abschließen (alle Self-Service, < 2h Aufwand zusammen). +2. **KW 20 (12.–18.05.):** Anthropic Commercial-Terms / Groq DPA-Anfrage absetzen. +3. **KW 20:** Anwalt-Termin zu den 3 Punkten in Sektion 2 vereinbaren. +4. **KW 21:** OpenRouter / Cartesia / Deepgram-Status klären → ggf. aus Verarbeitungs- + verzeichnis und § 6-Tabelle streichen, bis DPA vorliegt. +5. **KW 22–23:** Verarbeitungsverzeichnis (Art. 30 DSGVO) als separates Dokument + erstellen (Vorlage GDD / LfD Niedersachsen verwenden). +6. **KW 24–28:** DSFA gemäß Art. 35 DSGVO finalisieren. +7. **Q3 2026:** Stufe-2-Pseudonymisierung implementieren. + +--- + +**Bei Rückfragen:** datenschutz@rebreak.org · Betreff „DSB-Notes v1" diff --git a/docs/internal/RECOVERY_LOG_2026-05-10.md b/docs/internal/RECOVERY_LOG_2026-05-10.md new file mode 100644 index 0000000..9696cf2 --- /dev/null +++ b/docs/internal/RECOVERY_LOG_2026-05-10.md @@ -0,0 +1,283 @@ +# Recovery-Log 2026-05-10 — Lost Work + Workflow-Regeln + +**Stand:** 2026-05-10 +**Verantwortlich:** Chahine +**Anlass:** verlorene UI-Arbeit nach mehrfachen `git stash`/`cherry-pick`-Zyklen am 9. Mai + +--- + +## 1. Was passiert ist (Timeline) + +### 1.1 Auslöser — Cutover-Incident 7. Mai 22:17 + +`apps/rebreak/` (Nuxt) → `backend/` (Standalone Nitro) Cutover. Force-Push aus dem neuen Mac-Repo zu `RaynisDev/rebreak.git` triggerte den Server-Webhook, der scheiterte: + +- `cd /srv/rebreak/apps/rebreak`-Pfad existierte im neuen Layout nicht +- Auth-Middleware crashed mit HTTP 500 (`Cannot read properties of undefined (reading 'url')`) weil `backend/nitro.config.ts.runtimeConfig` keine `supabase`-Section hatte +- ALLE authentifizierten Endpoints kaputt + +Rollback: `git reset --hard origin/main` → HEAD auf `922d5dc`. Tag `pre-revert-2217` als Sicherung gesetzt. + +Siehe `ops/CUTOVER_PLAN.md` §1.3 für volle Incident-Beschreibung. + +### 1.2 Folgesymptom — Stash-Hopping am 9. Mai + +Nach dem Reset arbeitete der User intensiv am Cherry-Pick-Workflow zwischen `main` und `upgrade/sdk-54`. Reflog zeigt **10+ Branch-Switches in 4 Stunden** (14:51–18:11). Pattern: + +``` +commit auf upgrade/sdk-54 + → checkout main + → cherry-pick (selber Commit, neuer Hash) + → checkout upgrade/sdk-54 + → uncommitted changes: git stash + → ... nächster Commit ... +``` + +`git fsck --no-reflogs --lost-found` zeigt **9 dangling WIP-Stash-Commits** als Resultat: +`wip-pre-cherrypick`, `wip-pre-daemon-fix`, `wip-pre-daemon-push`, `wip-pre-backend-push-2`, `wip-pre-speak-fix-2`, `wip-mdm-session`, plus 3× `wip: sdk-54 ui/backend changes`. + +### 1.3 Konkreter Verlust — Commit `35189b9` "wip-pre-cherrypick" + +Am 9. Mai 17:57 wurde ein Stash mit gerade fertiggestellter UI-Arbeit angelegt — der Stash-Apply lief nicht sauber zurück (Conflict + `git checkout .` zum Aufräumen, oder `git stash drop` ohne saubere `pop`). Die Arbeit landete als dangling Merge-Commit `35189b9`, war aber im Working Tree weg. + +**Was im Stash war:** + +| File | Was wäre drin gewesen | +|---|---| +| `components/games/GameOverScreen.tsx` | 256 → **468 Zeilen**: StarRating, RiveAvatar, tier-aware Lyra-Messages, Rating-Form, Share-to-Community | +| `components/urge/UrgeGames.tsx` | Header-Refactor, `scoreLabel`/`goodScore`-Props | +| `app/settings.tsx` | LanguageIcon-Block, dynamic icon-rendering | +| `locales/de.json` + `en.json` | `gameOver.lyra_title_*` / `lyra_body_*` Keys (record/good/ok/low), Rating-Strings, Share-Strings | + +User hat das nach 24h beim Test gemerkt: SOS sah aus „wie alter Stand" — kein neuer GameOverScreen, kein Snake-Score-Dashboard, OpenAI-TTS statt ElevenLabs. + +--- + +## 2. Recovery-Aktion 2026-05-10 + +`35189b9` Files via `git checkout 35189b9 -- ` ins Working Tree zurückgeholt. Locales **chirurgisch** gemerged (Python-Script für `gameOver`-Section only — andere Sections — Mail-Status, Auth-Errors etc. — blieben unangetastet, weil aktuelle de.json/en.json neuere Strings enthielten die in 35189b9 nicht waren). + +Plus: `urge.tsx` + `lib/sosTtsQueue.ts` umgestellt von `endpointForProvider(currentProvider())` (alter TtsProviderToggle-Pfad mit OpenAI-Default) auf `/api/coach/speak` — der **tier-aware Backend-Dispatcher** (siehe §5). + +Backend (`backend/server/api/coach/speak.post.ts`) war bereits korrekt fertig (mtime 10. Mai 16:18, Plan-aware: Free→Google / Pro→Cartesia / Legend→ElevenLabs) — kein Touch nötig. + +--- + +## 3. Was JETZT NOCH FEHLT (nicht aus 35189b9 wiederhergestellt) + +| Feature | Status | Notiz | +|---|---|---| +| **Game-Sharing-Post** | offen | Aus User-Erinnerung: Game-Result-Sharing zur Community (Post mit Score + Lyra-Caption). Nicht in 35189b9 enthalten — wahrscheinlich anderer dangling stash. **TODO separat**. | +| **TtsProviderToggle Wiring** | offen, aber nicht kritisch | Component existiert (`components/urge/TtsProviderToggle.tsx`), nirgends gerendert. Laut `ops/UI_MIGRATION_PLAN.md §3 Tab 5 Debug` gehört der in `__DEV__`-Tab. | + +Game-Sharing kommt in nächster Session. Anderen dangling stash-Commits prüfen via `git show ` aus `git fsck --lost-found` Output. + +--- + +## 4. Workflow-Regeln gegen Wiederholung + +### 4.1 KEIN rapides Stash + Cherry-Pick mehr + +**Verboten:** mehrere `git stash` hintereinander während Branch-Switching. `git stash list` darf nie länger als 1 Eintrag werden. + +**Stattdessen (in Priorität):** + +1. **`git worktree add`** — zweiter Working-Tree für andere Branches: + ```bash + git worktree add ../rebreak-main main + # Cherry-pick im 2. Worktree, kein stash nötig + cd ../rebreak-main + git cherry-pick + git push + cd ../rebreak-monorepo + ``` + Beide Trees sind unabhängig, parallel benutzbar in zwei IDE-Fenstern. + +2. **Commit-First-Pattern** — vor jedem `checkout` immer committen (auch WIP-commits sind besser als stash): + ```bash + git add -A && git commit -m "wip: in progress" + git checkout main + # ... arbeit auf main ... + git checkout upgrade/sdk-54 + # WIP commit unverloren, kann amended werden + ``` + +3. **NIE `git stash drop`** — nur `git stash pop`. Wenn pop conflicted: NICHT mit `git checkout .` aufräumen, sondern Conflict-Markers manuell auflösen + committen. + +### 4.2 Recovery-Kommandos für die Zukunft + +Falls trotzdem mal wieder Arbeit verloren geht: + +```bash +# Alle dangling commits auflisten: +git fsck --no-reflogs --lost-found + +# Inhalt eines Commits inspizieren: +git show --stat + +# Files aus einem dangling commit zurückholen (ohne git history zu touchen): +git checkout -- + +# Volles Reflog mit Datum: +git reflog --date=format:"%Y-%m-%d %H:%M" +``` + +Tag `pre-revert-2217` (vom 7. Mai) bleibt als Notbremse-Anker erhalten. + +### 4.3 Zwei Branches gleichzeitig sind ein Anti-Pattern + +Aktuell: `main` (Production) + `upgrade/sdk-54` (Dev). Cherry-Pick-Pflicht zwischen beiden ist die **eigentliche Wurzel** des Problems. + +**Empfehlung (User-Decision):** sobald `upgrade/sdk-54` stabil ist → `main` durch `upgrade/sdk-54` ersetzen (force-push) und nur noch *einen* Branch fahren. Die GH-Actions-Pipeline deployt von `main`, also nach Force-Push ist alles auf einem Branch konsolidiert. + +--- + +## 5. Tier-Aware TTS-Architektur (jetzt aktiv) + +Damit klar ist wie das System nach dem Recovery funktioniert: + +``` +User auf SOS-Page (urge.tsx) + → ttsQueue.endpoint = '/api/coach/speak' + → POST /api/coach/speak { text, mode: 'sos' } + → Backend: speak.post.ts + → requireUser(event) + → profile.plan aus DB + → free → speakGoogle() (60s/day quota) + → pro → speakCartesia() (300s/day quota) + → legend → speakElevenLabs() (unlimited) + → Backend liefert raw audio/mpeg stream + → Client erwartet immer raw audio/mpeg (kein isGoogleCloud-Branch mehr nötig) +``` + +**Wichtig:** Kein User-Toggle. Der Provider hängt **ausschließlich** an `profile.plan`. Wenn ein Pro-User Cartesia-Stimme nicht mag → Plan-Tier muss geändert werden, nicht ein Toggle. + +`TtsProviderToggle.tsx` Component bleibt im Repo aber ohne Wiring. Falls Debug-Tab gebaut wird (`UI_MIGRATION_PLAN.md §3 Tab 5`), kommt der Toggle dort hin (`__DEV__`-only). + +--- + +## 6. Game-Flow in SOS vs Standalone + +| Mode | Eintritt | Game-Over-Verhalten | +|---|---|---| +| **SOS-Mode** (`urge.tsx`) | aus Lyra-Chip „Spiel" | Game endet → `onComplete(score)` direkt → SOS-Session läuft weiter, Lyra antwortet auf Score. **KEIN GameOverScreen.** | +| **Standalone-Mode** (`games.tsx`) | aus Header-Dropdown „Games" | Game endet → `` rendert mit StarRating + Lyra-Message + Share-to-Community-Button. Retry/Exit drinnen. | + +Implementation: `mode: 'sos' \| 'standalone'`-Prop auf SnakeGame/MemoryGame/TicTacToeGame/TetrisGame. Default = `'standalone'`. urge.tsx setzt explizit `mode="sos"`. + +--- + +## 7. Keyboard-Overlap — generische Lösung + +App-übergreifender Bug: TextInput wird beim Tippen vom Keyboard verdeckt (Mail-Password-Edit, Auth-Forms, Profile-Edit, Demographics, ComposeCard, Chat-Input, etc.). + +**Aktiver Stack ab 2026-05-10:** [`react-native-keyboard-controller`](https://github.com/kirillzyusko/react-native-keyboard-controller) — de-facto Standard seit 2024 für RN-Keyboard-Avoidance. Native Synced (iOS-Curve pixel-genau), kein Driver-Mix, kein Bouncing. + +**Setup:** +- Bereits installiert: `pnpm add react-native-keyboard-controller` ✓ +- Root-Layout wrapped mit `` (`apps/rebreak-native/app/_layout.tsx`) ✓ +- **Native-Build nötig nach Install:** `cd apps/rebreak-native/ios && pod install` (Autolinking macht den Rest), dann frischer Xcode-Build + +**Migrierte Components (Reference-Beispiele):** +- `EditMailAccountSheet.tsx` — `useKeyboardAnimation()` + `Animated.subtract(slideY, height)` +- `GameOverScreen.tsx` — gleiche Pattern, mit Spring-Slide-In bewahrt + +### 7.1 Wann was nutzen + +Empfehlung in dieser Reihenfolge: + +| Situation | Lösung | +|---|---| +| **Bottom-Sheet mit Form/Input** (EditMail, ConnectMail, AddDomain, GameOver, künftig…) | **``** Composable (`components/KeyboardAwareSheet.tsx`). Kapselt Modal + Backdrop + Slide-In + Sheet-Grow + Form-an-Bottom-Spacer. **Beispiel:** `EditMailAccountSheet.tsx`. | +| **Vollbild-Form** (Auth, Profile-Edit, Signup) | `` aus `react-native-keyboard-controller` (NICHT von RN!) als Outermost. Drop-in, funktioniert wie erwartet. | +| **Sticky-Bottom-Bar über Tastatur** (Send-Button am Screen-Edge, etc.) | `` aus der Library — sticked automatisch über Tastatur. | +| **Chat/SOS** (FlatList + Input-Bar) | Wie bisher in `PostCommentsSheet.tsx`. Funktioniert weiter. | +| **Legacy** | `hooks/useSheetKeyboardLift.ts` + `hooks/useKeyboardHeight.ts` + `components/KeyboardAdjustedView.tsx` bleiben im Repo aber sollten **nicht mehr neu verwendet werden** — durch `` ersetzt. | + +### 7.1.1 Auto-sized Sheets (kein leerer Platz unterhalb des Inhalts) + +Für kompakte Forms (1 Input + Save-Button — z.B. EditMailAccountSheet): KEINE feste `height` setzen, Sheet auto-sized via `position: 'absolute', bottom: 0`. `useSheetKeyboardLift({ offscreenY: SCREEN_HEIGHT })` für initial-off-screen + Keyboard-Lift. Resultat: Sheet sitzt eng über der Tastatur ohne weißen Leerraum darunter. + +Für Sheets mit variablem Listen-Inhalt (Comments, längere Forms): `height` setzen. ScrollView braucht constrained height zum scrollen. + +### 7.1.2 Library-Migration-Pfad: `react-native-keyboard-controller` + +De-facto-Standard seit 2024 für Keyboard-Avoidance in RN. Löst alle Pain-Points (Driver-Mix, iOS-Modal-Quirks, Sheet-Lifts, smooth Animationen) systemisch über native Module — kein eigener Animated-Code mehr nötig. Kostet: + +- `pnpm add react-native-keyboard-controller` +- `npx expo prebuild` + iOS pod install (= neuer Native-Build nötig) +- Wrapper am App-Root: `` +- Components ersetzen: `` von der Library statt RN's eigenes +- Plus: `useKeyboardAnimation()` Hook für custom Animationen + +**Empfehlung:** wenn 2-3 weitere Sheets/Forms Probleme machen → migrieren. Bis dahin: `useSheetKeyboardLift()` Pattern reicht für die meisten Fälle. + +### 7.2 Anti-Pattern zu vermeiden + +- **``** — funktioniert nur in Modals zuverlässig, bricht bei Full-Screens mit `paddingTop: insets.top`. **Nicht mehr neu nutzen.** +- **Pressable mit style-Funktion** für Buttons mit kritischem Visual: `style={({pressed}) => ...}` schluckt manchmal Style-Properties (RN-Quirk). Für Buttons mit solidem BG + Border lieber `` Pattern. +- **Driver-Mix auf einem ``** — z.B. `height: animatedValue` (JS-driver) zusammen mit `transform: [{ translateY: animatedValue }]` (native driver). Crashed mit `"Style property 'height' is not supported by native animated module"`. **Lösung:** `useSheetKeyboardLift()` Composable nutzt nur translate (beides native). +- **`marginBottom: keyboardHeight` als JS-Style** + native transform im selben View → Bouncing weil zwei Threads layouten. **Lösung:** Animated.subtract(slideY, keyboardLift), beides Animated.Values, native driver konsistent. + +--- + +## 8. Open Issues (zukünftige Sessions) + +### 8.1 Aus aktueller Session 2026-05-10 verschoben + +- [ ] **Game-Sharing-Post-Render** — soll genau wie in `trucko-monorepo/apps/rebreak/app/components/CommunityPostCard.vue` (category=`game_share`) aussehen. Aktuell rendert die Native-App einen generischen Post statt einer Game-Share-Card mit Score-Pill + Lyra-Caption + Challenge-CTA. Source-of-truth: Vue-Pendant. +- [ ] **Mail-Page-Chart** — `MailWeeklyChart.tsx` ist bereits angelegt aber Render-Logic noch nicht 1:1 vom Nuxt-`mail-stats-chart.vue` portiert. 7-Tage-Bar-Chart mit `account_*`-Stats. +- [ ] **iron.png-Warning vollständig fixen** — `dm.tsx` ist gefixt, aber `room.tsx` (3 Stellen: 308, 537, 598) und `components/chat/RoomCard.tsx:52` nutzen noch raw `room.avatarUrl` / `m.avatar`. Wenn das vom Backend ein Avatar-ID statt URL liefert, gleicher Bug. `resolveAvatar()` darüber wickeln. +- [ ] **TetrisActionBtn / DPadBtn rollout** — falls noch andere Stellen Pressable-mit-style-funktion nutzen für Game-relevante Buttons, gleichen TouchableWithoutFeedback-Pattern anwenden. + +### 8.2 Aus voriger Session + +- [ ] `KeyboardAdjustedView` rollout über alle TextInput-Stellen (siehe Liste in §9) +- [ ] TtsProviderToggle in `__DEV__`-Debug-Tab einbauen +- [ ] Single-Branch-Konsolidierung: `upgrade/sdk-54` → `main` Force-Push +- [ ] Andere 8 dangling stashes inspizieren ob noch was Wertvolles drin ist +- [ ] expo-av Deprecation Warning — Migration zu `expo-audio` + `expo-video` (SDK 54 Pflicht). Tracker. + +### 8.3 Snake-Sounds — Audio-Files droppen + +`hooks/useSnakeSounds.ts` läuft aktuell im Haptic-only-Mode. Für echten 8-Bit-Retro-Sound: + +1. `apps/rebreak-native/assets/sounds/` Dir anlegen +2. 4 kurze Audio-Files reinlegen (Free-Quellen: freesound.org, opengameart.org/content/8-bit-sound-pack, sfxr.me): + - `snake-eat.mp3` ~80ms tonale "blip" + - `snake-move.mp3` ~30ms Tick (optional) + - `snake-gameover.mp3` ~400ms abfallende Töne + - `snake-record.mp3` ~600ms aufsteigender Chime +3. `useSnakeSounds.ts` öffnen, `require()` und `Audio.Sound.createAsync()` Lines unkommentieren (in der Datei-Doku exakt beschrieben) + +Nach Drop fallen die Haptics nicht weg — Audio + Haptic feuern dann beide. + +### 8.4 Cache-Invalidierung — neuer Pattern in `useMe.ts` + +Profile-Avatar-/Nickname-Änderungen sind jetzt **app-weit live**: nach jedem `PATCH /api/auth/me` muss `invalidateMe()` aus `hooks/useMe.ts` aufgerufen werden (oder `reload()` einer useMe-Instanz, was intern gleichbedeutend ist). Alle anderen useMe-Konsumenten (AppHeader, ComposeCard, PostCard, NotificationsDropdown, …) re-fetchen via Listener-Subscribe automatisch — kein App-Reload mehr nötig. + +**Dasselbe Pattern für andere User-Daten** (Streak, Demographics, Devices) wenn das gleiche Bug-Symptom auftritt. + +## 9. Files mit TextInput (für KeyboardAdjustedView-Rollout) + +``` +app/room.tsx +app/lyra.tsx +app/urge.tsx +app/(auth)/signup.tsx +app/(auth)/signin.tsx +app/(auth)/forgot-password.tsx +app/(auth)/confirm-otp.tsx +app/profile/edit.tsx +components/PostCommentsSheet.tsx ← bereits korrekt (Vorbild-Pattern) +components/ComposeCard.tsx +components/chat/CreateRoomSheet.tsx +components/chat/ChatInput.tsx +components/mail/ConnectMailSheet.tsx +components/mail/EditMailAccountSheet.tsx ← User-explizit gemeldet +components/blocker/AddDomainSheet.tsx +components/urge/InlineRatingDrawer.tsx +components/urge/SosFeedbackModal.tsx +components/urge/ShareSuccessDrawer.tsx +components/games/GameOverScreen.tsx +``` diff --git a/ops/ACCESSIBILITY_AUDIT.md b/ops/ACCESSIBILITY_AUDIT.md new file mode 100644 index 0000000..f86e0fb --- /dev/null +++ b/ops/ACCESSIBILITY_AUDIT.md @@ -0,0 +1,432 @@ +# Rebreak-Native — Accessibility Audit & DiGA-Roadmap + +Author: Ahmed (QA) · Stand: 2026-05-07 · Status: Initial Audit, READ-ONLY + +User-Trigger 2026-05-08: „Thema accessibility auf beide Plattformen checken — es gibt +Test-Frameworks dafür. Damit können wir bei DiGA punkten." + +Scope dieses Dokuments: +1. Bestandsaufnahme der A11y-Awareness im rebreak-native-Code (iOS + Android) +2. Mapping auf WCAG 2.1 Level AA + DiGA-Anforderungen +3. Test-Framework-Empfehlung (RN-spezifisch) +4. Roadmap Pre-TestFlight / Pre-DiGA-Antrag / Post-Launch + +--- + +## 1. Executive Summary + +**Aktuelle A11y-Coverage in rebreak-native: ~1,3 %.** + +Empirisch: 6 Treffer für `accessibilityLabel|accessibilityRole|accessibilityHint|accessibilityState|accessible=`-Props +verteilt auf 5 Files (Mail-Add-Account, ProtectionLockedCard, AppHeader-Back-Button, +ProtectionCard-Settings-Icon, DomainGrid-Add-Btn + DomainGrid-State). + +Demgegenüber im selben Tree: **453 Touchable-Komponenten** (`Pressable` / +`TouchableOpacity` / `97 % unbeschriftet** für Screen-Reader (VoiceOver / TalkBack). + +**DiGA-Risk-Score: HOCH.** + +Begründung: +- BfArM verlangt für DiGA-Zertifizierung die Erfüllung von **WCAG 2.1 Level AA** + (DVG §139e, BIK BITV-konformes Verfahren). Heute erfüllt rebreak-native diese + Stufe **auf keiner Seite**. +- Recovery-User-Kohorte hat überdurchschnittlich oft Komorbiditäten: + Sehbeeinträchtigung (Diabetes, Augenleiden), motorische Einschränkungen, + starke kognitive Last in Krisen-Momenten — d.h. der **SOS-Flow ist + a11y-mission-critical**, und der ist heute komplett unzugänglich für Screen-Reader. +- Apple App-Review (für External Beta) und Google Play prüfen seit 2024 bei + Mental-Health-/Health-Apps zunehmend systematisch auf VoiceOver/TalkBack-Walkthrough. + +**Empfehlung:** Sofortmaßnahme für SOS-Flow + Auth-Flow + Demographics-Form vor +TestFlight Internal (Wochenend-Cutover). Roadmap-Arbeit für Vollabdeckung pre-DiGA. + +--- + +## 2. Component-by-Component-Status (Critical Paths) + +Legende: ✅ ausreichend / ⚠️ teilweise / 🔴 fehlt komplett + +### 2.1 SOS-Flow — `app/urge.tsx` (1333 Lines) — 🔴 + +Höchste Priorität (Krisen-Use-Case, Recovery-Schutz). + +| Element | Line | A11y-Status | Finding | +| ----------------------------------------------------- | ------ | ----------- | ----------------------------------------------------------------------- | +| Exit-Button (Pressable + Icon-only) | 1094 | 🔴 | kein `accessibilityLabel`, ScreenReader liest „Button" (oder schweigt) | +| TTS-Stop-Button | 1116 | 🔴 | kein Label, kein Hint, keine `accessibilityState={{busy: …}}` | +| Sound-Toggle-Button | 1123 | 🔴 | toggled `soundEnabled` ohne `accessibilityState={{checked}}` | +| Scroll-Down-Button | 1160 | 🔴 | nur Icon | +| Lyra-Chip-Button (`handleChip`) | 1209 | 🔴 | kritisch: das sind die SOS-Action-Chips (Atem/Spiel/Cooldown), Screen-Reader liest gar nichts | +| Eingabefeld TextInput | 1231 | 🔴 | kein `accessibilityLabel` | +| Send-Button (Pressable, mit Disabled-State) | 1244 | 🔴 | `disabled={thinking || !input.trim()}` — Screen-Reader bekommt kein `accessibilityState={{disabled}}` | + +**Verdict:** SOS-Flow ist für sehbehinderte User nicht bedienbar. Absoluter +DiGA-Blocker. + +### 2.2 SOS-Spiele — `components/urge/UrgeGames.tsx` (1067 Lines) — 🔴 + +Wir haben gerade native D-Pad-Buttons gemacht (Snake) und Tetris-Action-Buttons. +**Keiner** hat `accessibilityLabel` oder `accessibilityRole`. + +| Element | Line | Finding | +| ------------------------------------ | ---- | -------------------------------------------------------------------------- | +| Game-Picker-Pressable | 37 | 🔴 ohne Label | +| Snake-D-Pad-Btn (Up/Down/Left/Right) | 360 | 🔴 reine Icon-Pressable, Direction-Info komplett unsichtbar für VoiceOver | +| Tetris-Action-Btn (Rotate/Drop) | 405 | ⚠️ hat sichtbares Label aber kein explicit `accessibilityLabel` | +| Memory-Card-Pressable | 541 | 🔴 ohne Label, kein State-Info „revealed/matched" | +| Abandon-Button | 292 | 🔴 ohne Label | +| Replay/Continue-Buttons | 724 | ⚠️ haben Text-Inhalt — aber `accessibilityRole="button"` fehlt | + +**Verdict:** Snake/Tetris/Memory/RPS sind faktisch reine Sehende-Spiele. Für +DiGA-Recovery-Effektmessung problematisch (a11y-User können die Distraktions- +Mechanik nicht nutzen). + +### 2.3 Profile + Demographics — `app/profile/index.tsx` + `components/profile/DemographicsAccordion.tsx` (272 / 621 Lines) — 🔴 + +DiGA-Pflicht-Daten-Erhebung (Phase C). + +| Element | Line | Finding | +| ------------------------------------------------ | -------- | ------------------------------------------------------------------------------ | +| TextInput Geburtsjahr | 267 | 🔴 kein Label, nur Placeholder „z.B. 1989" — nicht für VoiceOver lesbar | +| SelectButton Geschlecht | 296 | 🔴 nicht als „Combobox/Spinner" markiert, Screen-Reader nennt keine Auswahl | +| TextInput Beruf | 304 | 🔴 wie Geburtsjahr | +| SelectButton Familienstand | 320 | 🔴 | +| SelectButton Bundesland | 328 | 🔴 | +| TextInput Stadt | 336 | 🔴 | +| Modal-Picker (`onSelect(value)`) | 587 | 🔴 jeder Picker-Eintrag ist `Pressable` ohne Label → VoiceOver liest gar nichts | +| Revoke-Consent-Pressable | 351 | 🔴 wichtig für DSGVO Art. 7(3) | +| Modal-Backdrop | 539 | 🔴 fehlt `accessibilityViewIsModal` (iOS) / Focus-Trap | + +**Verdict:** Gerade diese Form ist DSGVO Art. 9 + DiGA-Datensatz-Pflicht. Wenn +ein blinder User die Demographics nicht ausfüllen kann, **fehlen seine Daten in +der DiGA-Versorgungs-Studie** (Bias). + +### 2.4 Header + Dropdown — `components/AppHeader.tsx` + `components/header/HeaderDropdownMenu.tsx` — ⚠️ + +| Element | Line | Finding | +| ---------------------------------- | ---------- | ---------------------------------------------------- | +| Back-Button | 56 | ✅ `accessibilityLabel="Zurück"` | +| Notification-Bell-Pressable | 72 | 🔴 kein Label, Badge-Count gar nicht angesagt | +| Avatar/Dropdown-Trigger | 88 | 🔴 kein Label „Profilmenü öffnen" | +| Dropdown Backdrop-Pressable | 86 (Menu) | 🔴 | +| Dropdown Items (Profile/Settings/Logout) | 108–239 | 🔴 keine Labels, Modal hat kein `accessibilityViewIsModal` | + +### 2.5 ComposeCard — `components/ComposeCard.tsx` — ⚠️ + +hitSlop ≥44pt korrekt umgesetzt (Apple-HIG-konform), aber **keine** a11y-Labels: + +| Element | Line | Finding | +| ----------------------------- | ---- | -------------------------------------------------- | +| TextInput Compose | 110 | 🔴 nur Placeholder, kein Label | +| Image-Remove-Pressable | 130 | 🔴 nur Close-Icon | +| Image-Picker-Pressable | 147 | 🔴 Text „Foto" nur visuell | +| Cancel-Pressable | 160 | ⚠️ hat Text-Child, aber Role/Label-Mapping unklar | +| Share-Submit-Pressable | 168 | 🔴 disabled-State nicht annonciert | + +### 2.6 Blocker — `app/(app)/blocker.tsx` + `components/blocker/*.tsx` — ⚠️ (best-of) + +Hier ist mit Abstand die meiste a11y-Awareness — Schutz-Settings sind DiGA- +mission-critical und das ist hier korrekt erkannt: + +| Element | A11y-Status | Note | +| -------------------------------------- | ----------- | --------------------------------------------- | +| ProtectionCard Settings-Icon | ✅ | `accessibilityLabel={t('blocker.protection_settings_a11y')}` | +| ProtectionLockedCard Settings-Icon | ✅ | dito | +| DomainGrid Add-Domain-Pressable | ✅ | Label + `accessibilityState={{disabled}}` | +| Switch (LayerSwitchCard, ProtectionCard) | ⚠️ | RN-Switch hat default `accessibilityRole="switch"` aber kein i18n-Label | +| ProtectionDetailsSheet | 🔴 | Modal ohne `accessibilityViewIsModal` | +| AddDomainSheet TextInput | 🔴 | kein Label | +| CooldownBanner | 🔴 | Animation, kein `accessibilityLiveRegion="polite"` | +| DeactivationExplainerSheet | 🔴 | Modal-Pattern wie oben | + +### 2.7 Auth-Flow — `app/(auth)/{signin,signup,forgot-password,confirm,confirm-otp,device-limit}.tsx` — 🔴 + +Zugang zur App = a11y-Pflicht-Pfad. + +| Element (signin) | Line | Finding | +| ----------------------------- | ---- | ---------------------------------------------- | +| OAuth-Google-Btn | 103 | 🔴 nur Icon-+-Text-Children, kein expliziter Label | +| OAuth-Apple-Btn | 117 | 🔴 dito | +| TextInput Email | 139 | ✅ `autoComplete="email"` + ⚠️ kein expliziter `accessibilityLabel` | +| TextInput Password | 151 | ✅ `autoComplete="password"` + ⚠️ kein Label | +| Forgot-Password-Pressable | 162 | 🔴 | +| Submit-Button | 173 | 🔴 disabled-State nicht annonciert | +| Signup-Link-Pressable | 186 | 🔴 | + +`autoComplete` hilft Password-Manager, ersetzt aber **kein** `accessibilityLabel`. + +### 2.8 Community / PostCard / PostCommentsSheet — 🔴 + +`components/PostCard.tsx`: hitSlop ≥44pt korrekt, aber Like-/Comment-Buttons +ohne Label. Like-Count (`localCount`) wird visuell angezeigt aber nicht in +`accessibilityValue` exposed → Screen-Reader liest nur „Button". + +### 2.9 Animation & Reduce-Motion — 🔴 (Cross-cutting) + +- **270 Animation/Reanimated-Usages** im Code (`Animated.*` / `FadeIn` / + `useNativeDriver` / `useSharedValue`). +- **0 Treffer** für `AccessibilityInfo.isReduceMotionEnabled` / + `useReduceMotion()`. + +WCAG 2.3.3 (Animation from Interactions, AAA) und 2.2.2 (Pause/Stop/Hide, AA) sind +heute pauschal verletzt — die App respektiert Systemeinstellung „Bewegung +reduzieren" nicht. Für vestibuläre Empfindlichkeit problematisch. + +### 2.10 Dynamic-Type / Font-Scaling — ⚠️ (Cross-cutting) + +- 398 explizite `fontSize:` / `font-size`-Vorkommen im Code. +- 0 `allowFontScaling={false}` Bypässe (gut!) — RN skaliert per default mit + iOS Dynamic-Type / Android-Font-Scale. +- ABER: viele Layouts nutzen `fontSize` als hartcodierten Pixel — bei extremem + Font-Scale (Accessibility Sizes XXX-Large) brechen die Layouts wahrscheinlich. + +### 2.11 Color-Contrast — ⚠️ (Cross-cutting) + +Häufige Hex-Codes aus Codebase: +- `#a3a3a3` (neutral-400) auf `#ffffff` → contrast-ratio **2,84:1** → **fail + WCAG AA** (4.5:1 für Body-Text) +- `#737373` (neutral-500) auf `#ffffff` → contrast-ratio **4,48:1** → **fail + WCAG AA** (knapp; Norm fordert 4,5:1) +- `placeholderTextColor="#a3a3a3"` (signin Lines 143, 155) → fail + +→ Alle „muted" / „placeholder" Texte erfüllen WCAG AA nicht. Screenshot- +basierter axe-Audit würde dutzende Findings melden. + +### 2.12 Screen-Reader-Detection — 🔴 (Cross-cutting) + +`grep AccessibilityInfo|isScreenReaderEnabled` → 0 Treffer. + +Heißt: keine Komponente verhält sich anders, wenn Screen-Reader an ist +(z.B. Auto-Play-Audio von Lyra-TTS bei aktivem VoiceOver = problematisch, +wenn beide gleichzeitig sprechen). + +### 2.13 Touch-Target-Size — ⚠️ + +- iOS HIG verlangt 44×44pt → vielfach via `hitSlop=12` nachträglich erfüllt + (gut: ComposeCard, PostCard). +- Android Material verlangt **48×48dp** → heutige Mehrheit der Buttons ist + hitSlop=12 → **44pt erreicht aber 48dp Android-Norm fehlt knapp**. + +--- + +## 3. Test-Framework-Empfehlung + +### 3.1 Was es gibt für RN + +| Tool | Was es kann | Empfehlung | +| ------------------------------------------ | ----------------------------------------------------------------------------- | ---------- | +| `@testing-library/react-native` (RNTL) | `getByA11yLabel`, `getByRole`, `getByA11yState` — Component-Test-Assertions | **JA — Pflicht** | +| `@testing-library/jest-native` | Custom Matchers `toBeAccessible`, `toHaveAccessibilityValue` | **JA** | +| `react-native-accessibility-engine` (Meta) | DEV-time Audit-Output beim Render — gibt Warnings für fehlende Labels | **JA, Phase 2** | +| `axe-core-react-native` | Programmatic axe-Engine-Run gegen Component-Tree | **Optional**, instabil für SDK 53 | +| Maestro | E2E — kann `id: ""` als Selektor → indirekt a11y-Test | **JA** | +| Apple Accessibility Inspector (Xcode) | manuelle Audit-Tour mit Audit-Button | **Pflicht**, manuell pre-Release | +| Android Accessibility Scanner (Play Store) | manueller Audit über App, gibt Findings-Report | **Pflicht**, manuell pre-Release | +| BIK BITV-Test (DE) | offizieller deutscher BITV-Test-Bericht — DiGA-konform | **Pflicht für DiGA-Antrag**, externer Provider | + +### 3.2 Empfehlung in einem Satz + +**`jest-expo` + `@testing-library/react-native` + `jest-native` für automatisierte Component-A11y-Assertions, plus `react-native-accessibility-engine` als Dev-Time-Linter, plus Maestro-Flows mit `id: ""`-Selektoren als E2E-Validation. Manuelle VoiceOver/TalkBack-Tour pre-Release. BIK BITV-Test als externer Audit pre-DiGA-Antrag.** + +### 3.3 Setup-Aufwand + +| Schritt | Aufwand | +| -------------------------------------------------------------- | --------- | +| `pnpm add -D jest-expo @testing-library/react-native @testing-library/jest-native` | 15 min | +| `jest.config.js` + `jest-setup.ts` mit jest-native-Matchern | 30 min | +| Erste 3 Component-A11y-Tests (Smoke) | 2 h | +| `react-native-accessibility-engine` integrieren | 1 h | +| Maestro-A11y-Selektoren in vorhandenen Flows umstellen | 1 h | +| Dokumentierte VoiceOver/TalkBack-Manual-Test-Checkliste | 2 h | + +### 3.4 Beispiel — A11y-Component-Test + +```typescript +// apps/rebreak-native/tests/components/AppHeader.a11y.test.tsx +import { render } from '@testing-library/react-native'; +import '@testing-library/jest-native/extend-expect'; +import { AppHeader } from '../../components/AppHeader'; + +describe('AppHeader — a11y contracts', () => { + it('Back-Button hat accessibilityLabel und role="button"', () => { + const { getByA11yLabel } = render(); + const back = getByA11yLabel('Zurück'); + expect(back).toBeTruthy(); + expect(back).toHaveAccessibilityRole('button'); + }); + + it('Notification-Bell hat Label mit Badge-Count', () => { + const { getByA11yLabel } = render(); + expect(getByA11yLabel(/Benachrichtigungen.*3/i)).toBeTruthy(); + }); + + it('Avatar/Dropdown-Trigger hat Label', () => { + const { getByA11yLabel } = render(); + expect(getByA11yLabel(/Profilmenü/i)).toBeTruthy(); + }); +}); +``` + +--- + +## 4. WCAG 2.1 Level AA — Mapping rebreak-native (heute) + +Pflicht-Kriterien für DiGA, geprüft gegen Codebase 2026-05-07: + +| WCAG-SC | Level | Status heute | Begründung | +| -------------------------------- | ----- | ------------ | ---------------------------------------------------- | +| 1.1.1 Non-text Content | A | 🔴 Fail | 97 % der Icon-Pressables ohne `accessibilityLabel` | +| 1.3.1 Info and Relationships | A | 🔴 Fail | Form-Labels in Demographics fehlen, Modal-Roles fehlen | +| 1.3.5 Identify Input Purpose | AA | ⚠️ Partial | Auth nutzt `autoComplete`, sonst nirgends | +| 1.4.3 Contrast (Minimum) Text | AA | 🔴 Fail | `#a3a3a3 / #ffffff` = 2,84:1 — siehe oben | +| 1.4.4 Resize Text | AA | ⚠️ Partial | RN font scales by default, aber Layout bricht bei XXL | +| 1.4.10 Reflow | AA | ⚠️ Unknown | nicht systematisch getestet | +| 1.4.11 Non-text Contrast | AA | 🔴 Fail | Switch-Border, Icon-Outlines auf vielen Hellgrau-Backgrounds | +| 2.1.1 Keyboard | A | n.a. | RN nativ (kein Keyboard-Use-Case auf Phone) | +| 2.2.2 Pause, Stop, Hide | A | 🔴 Fail | Animationen pausieren nicht bei Reduce-Motion | +| 2.4.3 Focus Order | A | 🔴 Unknown | Modals haben kein Focus-Trap → Order kaputt mit VoiceOver | +| 2.4.6 Headings and Labels | AA | 🔴 Fail | Headings wie ProfileHeader haben kein `accessibilityRole="header"` | +| 2.5.5 Target Size | AA | ⚠️ Partial | iOS 44pt via hitSlop OK, Android 48dp knapp | +| 3.2.1 On Focus | A | ✅ Pass | keine unerwarteten Context-Changes on Focus | +| 3.3.1 Error Identification | A | ⚠️ Partial | Errors als Text gerendert, aber kein `accessibilityLiveRegion="assertive"` | +| 3.3.2 Labels or Instructions | A | 🔴 Fail | Form-Inputs haben Placeholder statt Label | +| 4.1.2 Name, Role, Value | A | 🔴 Fail | überall fehlt Name/Role-Markup | +| 4.1.3 Status Messages | AA | 🔴 Fail | Toasts/SuccessAlert nicht als `accessibilityLiveRegion` | + +**Zusammenfassung:** rebreak-native erfüllt heute ~3 von 17 für DiGA relevanten +WCAG-AA-Kriterien. + +--- + +## 5. DiGA-Punkte-Strategy + +### 5.1 Was BfArM real fragt (laut DiGA-Verfahrensverzeichnis) + +DiGA-Antrag-Modul „Barrierefreiheit" verlangt: +- **Selbsterklärung WCAG 2.1 AA-Konformität** (Pflicht, schriftlich) +- **Test-Bericht** (BIK BITV-Test ODER eigener dokumentierter Audit) +- **Nutzergruppen-Reflexion** (welche Behinderungs-Pattern wurden wie adressiert?) +- **Process-Commitment** (wie wird A11y in Entwicklung+Releases sichergestellt?) + +### 5.2 Low-hanging-fruit (hoher BfArM-Eindruck, niedriger Aufwand) + +| Maßnahme | Effort | DiGA-Score | +| --------------------------------------------------------------------------- | ------ | ---------- | +| `accessibilityLabel` auf alle Icon-Pressables im SOS-Flow + Auth + Demographics | 1 Tag | hoch | +| `accessibilityRole="header"` auf alle h1/h2-Texte | 2 h | mittel | +| `useReduceMotion`-Hook + `Animated.timing` skip wenn true | 4 h | hoch | +| `accessibilityViewIsModal` auf alle 12 Modals | 3 h | mittel | +| Color-Tokens in `lib/theme.ts` auf WCAG-AA-konforme Hex anheben (`#a3a3a3` → `#737373`, etc.) | 4 h | hoch | +| Standard-Typing-Pattern für Forms: `` | 1 Tag | hoch | + +### 5.3 Architektur-Investments (hoher Effort, höherer Score) + +- **A11y-Wrapper-Komponenten**: ``, `` zentral mit + Pflicht-Props. Migrationsweg über alle 453 Touchables. +- **Theme-Audit-Pipeline**: lint-rule die jede neue Hex-Color gegen + `getContrastRatio(fg, bg)` prüft. +- **CI-Gate**: jest-A11y-Tests und `react-native-accessibility-engine` in CI, + PR-Block bei Regression. +- **Dynamic-Type-aware-Layouts**: alle „fixed-width-Cards" auf flex-basiert + refactoren, Tests bei XXL-Font. +- **i18n-Pflicht-Audit**: jeder neue `accessibilityLabel` muss aus `t(…)` + kommen, nicht hartcodiert „Zurück" wie heute in AppHeader. + +### 5.4 DiGA-Self-Statement (Vorschlag für DSFA mit Hans-Müller) + +> rebreak verpflichtet sich zur Erfüllung der WCAG-2.1-Level-AA-Kriterien +> entsprechend BIK-BITV-Test-Standard. Pre-Release-Audit erfolgt durch +> [BIK-Provider], jährliches Re-Assessment ist Bestandteil unserer +> Entwicklungsprozesse. Automatisierte A11y-Component-Tests sind Bestandteil +> unseres CI-Gates (Pull-Request-Blocker bei Regression). + +→ vor DiGA-Antrag prüfen mit Hans-Müller (DSB). + +--- + +## 6. Roadmap + +### 6.1 Pre-TestFlight (Wochenende 2026-05-09/10) — absolutes Minimum + +Ziel: A11y-Apple-Review-Risk reduzieren, ohne den Cutover zu blockieren. + +| Task | Form | Aufwand | +| --------------------------------------------------------------------------------- | ------------- | ------- | +| `accessibilityLabel` auf alle 7 Pressables in `app/urge.tsx` | manuell | 30 min | +| `accessibilityLabel` auf 4 OAuth + Forgot + Submit + Signup-Link in `signin.tsx` | manuell | 20 min | +| `accessibilityLabel` auf Notification-Bell + Avatar-Trigger in `AppHeader.tsx` | manuell | 10 min | +| `accessibilityLabel` auf Demographics-TextInputs + SelectButtons (8 Felder) | manuell | 30 min | +| `accessibilityViewIsModal={true}` auf SosFeedbackModal + GamePickerDrawer + InlineRatingDrawer + ProtectionDetailsSheet | manuell | 30 min | +| Manueller VoiceOver-Smoke-Walk (Login → SOS-Trigger → Lyra-Chip) auf iPhone-Build | manuell | 30 min | + +**Owner:** rebreak-native-ui (UI-Edit-Approval beim User holen). + +**Wichtig:** das ist NUR Pflaster für TestFlight Internal. Reicht nicht für DiGA. + +### 6.2 Pre-DiGA-Antrag (Phase nach Public-Beta) — Vollabdeckung Critical Paths + +| Task | Aufwand | +| ---------------------------------------------------------------------------------- | ------- | +| jest-expo + RNTL + jest-native installieren + jest.config.js | 1 h | +| `react-native-accessibility-engine` als Dev-Plugin | 1 h | +| A11y-Wrapper-Components `` + `` | 1 Tag | +| Migration: alle 453 Pressables → A11yPressable mit Pflicht-Label | 5 Tage | +| Migration: alle 50 TextInputs → A11yTextInput mit Pflicht-Label | 1 Tag | +| `useReduceMotion()`-Hook in alle Animation-Files | 2 Tage | +| Color-Token-Audit in `lib/theme.ts` (WCAG-AA-konform) | 1 Tag | +| Headings-Roles auf alle Section-Titel | 4 h | +| `accessibilityLiveRegion="polite"` auf Toasts/SuccessAlert/CooldownBanner | 2 h | +| jest-A11y-Component-Tests (5 wichtigste Components, je 4–6 Assertions) | 1 Tag | +| Maestro-Flows: Selektoren auf accessibilityLabel umstellen | 1 Tag | +| Dokumentierte VoiceOver/TalkBack-Manual-QA-Checkliste | 0,5 Tag | + +**Owner:** rebreak-native-ui + Ahmed (Tests). + +### 6.3 Post-Launch — kontinuierliches A11y-Gate + +| Task | Aufwand | +| ------------------------------------------------------------------------------- | ------- | +| GitHub Action: jest-A11y-Tests + RN-A11y-Engine in CI, PR-Block bei Regression | 2 h | +| BIK BITV-Test-Provider beauftragen pre-DiGA-Antrag | User-Action | +| Apple Accessibility Audit (Xcode) als Pre-Release-Step in `ops/` dokumentieren | 1 h | +| Android Accessibility Scanner als Pre-Release-Step | 1 h | +| jährliche A11y-Audit-Cycle (DSFA-Anhang) | User+Hans-Müller | + +--- + +## 7. Konkrete TODOs nach Priorität + +### Hoch (vor TestFlight, Wochenende) + +1. SOS-Flow `app/urge.tsx` Lines 1094, 1116, 1123, 1160, 1209, 1231, 1244 — `accessibilityLabel` ergänzen (rebreak-native-ui). +2. Auth-Flow `(auth)/signin.tsx` Lines 103, 117, 139, 151, 162, 173, 186 — Labels (rebreak-native-ui). +3. AppHeader Lines 72, 88 — Notification + Avatar Labels (rebreak-native-ui). +4. Demographics Form Lines 267, 296, 304, 320, 328, 336, 351 — Labels (rebreak-native-ui). +5. Modals: `accessibilityViewIsModal` setzen (5 Sheets/Modals) (rebreak-native-ui). + +### Mittel (Pre-DiGA-Antrag) + +6. Test-Framework-Setup (`jest-expo`, RNTL, jest-native) (Ahmed). +7. A11y-Wrapper-Components (rebreak-native-ui + Ahmed-Konsultation für Test-Hooks). +8. Color-Token-Refactor in `lib/theme.ts` (rebreak-native-ui). +9. `useReduceMotion`-Cross-cutting (rebreak-native-ui). +10. BIK BITV-Test-Provider auswählen (User-Decision). + +### Niedrig (Post-Launch) + +11. CI-A11y-Gate (Ahmed + DevOps/Backyard). +12. Dokumentierte Pre-Release-Checkliste in `ops/RELEASE_READINESS.md` ergänzen (Ahmed). +13. Quartals-A11y-Re-Audit nach Feature-Release (DSFA-Anhang) (Ahmed + Hans-Müller). + +--- + +## 8. Open Questions an User + +1. **A11y-Bug-Fix-Scope am Wochenende vor TestFlight:** Soll rebreak-native-ui die ~25 fehlenden Labels in SOS-/Auth-/Demographics-/Header-Critical-Paths noch in den Cutover-Build einbauen (Effort ~2 h, Apple-Review-Risk-Reducer für External Beta), oder erst V2 nach Internal? +2. **BIK BITV-Test-Provider:** Soll Ahmed Provider-Vorschläge sammeln (BIT-inklusiv, BFIT-Bund, etc., Kosten 5–15k EUR), oder hat User schon Kontakt? Zeitpunkt: vor oder nach DiGA-Antrag-Submission? +3. **DiGA-Self-Statement-Wording:** Soll im DSFA-Anhang explizit „WCAG 2.1 AA"-Commitment stehen mit Test-Coverage-Quote (siehe TESTING_STATE.md §4.4) oder bewusst weicher formulieren („wir streben an…")? Hans-Müller-Frage. + +--- + +Ende. — Ahmed diff --git a/ops/mac-version-research.md b/ops/mac-version-research.md new file mode 100644 index 0000000..14e83ae --- /dev/null +++ b/ops/mac-version-research.md @@ -0,0 +1,244 @@ +# ReBreak macOS — Entscheidungsgrundlage + +**Stand:** 2026-05-10 +**Scope:** Research only. Kein Prototype, kein Code, keine Dependencies. +**ReBreak-Stack:** Expo SDK 54, RN 0.81, NEFilterDataProvider (iOS App Extension), FamilyControls/ManagedSettings, Hermes + NewArch. + +--- + +## 1. TL;DR + +Pfad 4 (Browser-Extension) ist der schnellste Weg zu einem funktionierenden macOS-Blocker ohne App-Umbau. Pfad 3 (Native Swift Mac-App) ist der langfristig sauberste Weg mit echtem System-Level-Blocking, aber erfordert einen separaten Greenfield-Build. Pfad 1 und 2 scheitern beide am selben fundamentalen Problem: FamilyControls und NEFilterDataProvider in ihrer iOS-Form existieren auf macOS nicht — die RN-zu-Mac-Bridges kaufen dir UI-Portierung, lösen aber nicht das Kernproblem Blocking. + +--- + +## 2. Pfad-Vergleich-Tabelle + +| Pfad | Effort (Wochen) | Blocking funktioniert? | Cross-platform? | Maintenance | Risiko | +|---|---|---|---|---|---| +| 1 — Mac Catalyst | 6–10 | Nein (FamilyControls iOS-only) | Nein | Hoch (Apple API-drift) | Sehr hoch | +| 2 — RN macOS | 8–14 | Nein (kein FamilyControls, NEFilter anders) | Nein | Mittel-Hoch | Hoch | +| 3 — Native Swift | 8–12 | Ja (NEFilterDataProvider System Extension) | Nein (nur Mac) | Niedrig | Mittel | +| 4 — Browser-Extension | 3–5 | Eingeschränkt (kein App-Bypass, kein HTTPS-Intercept ohne Proxy) | Ja (Win/Mac/Linux) | Niedrig | Niedrig | +| 5 — MDM-Profil | 0 | Ja (DNS-Level, kein Bypass ohne IT-Admin) | Nein (nur eigenes Device) | Null | Null | + +--- + +## 3. Pfad-Details + +### Pfad 1: Mac Catalyst + +**Was funktioniert:** +- react-native-bottom-tabs explizit macOS-fähig (Callstack hat Screenshots im README) +- nativewind: README sagt "works on all RN platforms" +- react-native-screens: keine aktiven macOS-spezifischen open Bugs +- Nuxt-unabhängige Logik (API-calls, Auth via Supabase-JS) wäre ohne Änderung nutzbar + +**Was bricht:** + +FamilyControls und ManagedSettings sind iOS/iPadOS-only. Apple hat diese Frameworks nie auf macOS portiert, auch nicht via Catalyst. `@available(macOS, unavailable)` ist in Apples eigenen Headers gesetzt. Das bedeutet: der gesamte Screen-Time-Layer (AppShield, App-Blocking, Activity-Monitoring) ist auf macOS nicht verfügbar. Kein Workaround ohne komplettes Redesign. + +NEFilterDataProvider auf iOS ist ein App Extension, der ohne Sondergenehmigung läuft. Auf macOS Catalyst ist das Framework technisch präsent, aber Network Extension Content Filter auf macOS erfordert die Entitlement `com.apple.developer.network-extension.content-filter`, die bei Apple manuell beantragt werden muss und an System Extensions (nicht App Extensions) gebunden ist. Catalyst-Apps sind keine System Extensions. + +Konkrete Module-Probleme: +- `react-native-mmkv` v3+: README nennt nur iOS/Android/Web. GitHub zeigt 47 offene Issues, macOS/Catalyst nicht erwähnt. Das Library liefert ab v3 precompiled XCFrameworks — und das `maccatalyst`-Slice fehlt laut Issue #1268 (Stand Mai 2026 open, keine Aktivität). +- `@react-native-async-storage/async-storage`: Issue #1268 (open, März 2026): v3 liefert kein `maccatalyst`-Slice im XCFramework mehr. Build bricht. +- `@lodev09/react-native-true-sheet`: Issues gefunden unter macOS "Designed for iPhone"-Modus (macOS führt iOS-Apps seit macOS 11 aus, aber das ist kein Catalyst-Build). Fix-PR für diesen Modus war in Arbeit, Status unklar. +- `rive-react-native`: Kein macOS-Support erwähnt, iOS/Android only laut README. +- `lottie-react-native`: 0 macOS/Catalyst Issues — deutet darauf hin dass niemand es versucht (kein positiver Support-Signal). +- `expo-apple-authentication`: Funktioniert technisch auf macOS Catalyst (Sign in with Apple ist verfügbar), aber ist nicht dokumentiert. +- `expo-haptics`: No-op oder crash auf macOS (kein Taptic Engine). + +**Geschätzter Effort:** 6–10 Wochen allein für Build-Green auf Catalyst, ohne dass Blocking funktioniert. + +**Blocking-Fazit:** Nicht machbar. Catalyst löst nur das UI-Problem, nicht das Kern-Feature. + +--- + +### Pfad 2: React Native macOS (Microsoft Fork) + +**Maintenance-State:** +- Aktuelles Release: v0.81.2 (2026-02-11) — exakt kompatibel mit ReBreaks RN 0.81.5 +- Repo aktiv: zuletzt geupdated 2026-05-09, 4.327 Stars, 96 open Issues +- NewArch (Fabric): teilweise implementiert. Aktive open Bugs: TextInput multiline scrollt nicht in Fabric, Focus-Ring-Verhalten, Transform-Clipping. Grundlegende Fabric-Issues sind aber weiter zugemacht worden (Text selectable, platform colors etc.) — die Richtung stimmt. +- RN macOS unterstützt macOS 11 (Big Sur) und neuer. + +**Expo-Kompatibilität:** +RN macOS ist ein Fork von facebook/react-native, nicht kompatibel mit Expo Managed Workflow. Expo prebuild (bare workflow) ist theoretisch möglich, aber Expo-Module sind nicht für RN macOS gebaut. expo-modules-core enthält keine macOS-Targets. Kein Expo SDK Modul (expo-av, expo-notifications, expo-haptics, expo-apple-authentication etc.) hat offiziell RN macOS Support. + +Das bedeutet: alle Expo-Module müssten durch native macOS Äquivalente ersetzt oder komplett gestripped werden. + +**Was funktioniert:** +- react-native-bottom-tabs: explizit macOS-Support vorhanden (Callstack README zeigt macOS Screenshot) +- react-navigation/native: läuft auf RN macOS (Microsoft nutzt es intern für Teams/Outlook-Teile) +- zustand, react-query, i18next: pure JS, kein Problem +- Supabase-JS: pure JS, kein Problem + +**Was bricht / fehlt:** +- Kein FamilyControls, kein ManagedSettings — exakt gleiche Lage wie Pfad 1 +- NEFilterDataProvider auf macOS: ANDERS als auf iOS. Auf macOS muss der Filter-Provider als System Extension laufen (eigener Prozess, privilegiert, separate App-Bundle-Component). RN macOS hat kein Framework dafür — das wäre nativer Swift/ObjC Code komplett außerhalb des RN-Layers. +- react-native-mmkv: iOS/Android/Web only (README explizit). Kein macOS-Target. +- react-native-reanimated: 354 offene Issues mit "macos" im Search-Context, aber keines bezieht sich auf RN macOS spezifisch. Reanimated macht seine eigene JSI-Integration und ist nicht für RN macOS portiert (Hypothese, ungeprüft — kein positiver Hinweis auf Support). +- react-native-gesture-handler: hat 151 macOS-related Issues, aber die meisten beziehen sich auf Mac Catalyst oder unrelated. Keine explizite RN macOS Unterstützung in der README. +- rive-react-native: iOS/Android only +- lottie-react-native: 0 macOS Issues (kein positiver Signal) +- expo-router: nicht kompatibel mit RN macOS (Expo-Abhängigkeit) + +**Effort-Schätzung:** +- Woche 1–2: RN macOS initialisieren, Build-System aufsetzen, Podfile anpassen +- Woche 3–5: Expo-Module ersetzen (expo-av → AVFoundation nativ, expo-notifications → NSUserNotifications, etc.) +- Woche 6–8: mmkv durch NSUserDefaults oder nativem Äquivalent ersetzen, Reanimated patchen oder ersetzen +- Woche 9–12: Rive-Animationen entfernen/ersetzen, Layout-Bugs fixen (Fabric-Issues), macOS-spezifische UI (Fenster-Resize, Menübar, Kontextmenüs) +- Woche 13–14: Testing, kein Blocking-Feature + +Mindestens 12–14 Wochen, und am Ende kein Blocking. Das ist die Investition ohne das Kern-Feature. + +**Blocking-Fazit:** Nicht machbar mit vertretbarem Aufwand. System Extension ist nativer Swift-Code komplett außerhalb des RN-Layers, und der Aufwand dafür überschneidet sich mit Pfad 3. + +--- + +### Pfad 3: Native Swift Mac-App (Greenfield) + +**NEFilterDataProvider auf macOS — Status:** +NEFilterDataProvider ist auf macOS seit macOS 10.15 als System Extension verfügbar (via `NetworkExtension.framework`). SelfControl (4.344 Stars, aktiv, zuletzt 2026-05-10 geupdated) nutzt als Alternative `/etc/hosts`-Manipulation + Berkeley Packet Filter (`pf`) via `PacketFilter.m` und `HostFileBlocker.m`. Das ist der einfachere Weg, erfordert aber Adminrechte. + +System Extension NEFilterDataProvider (kein Root nötig, aber): +- Entitlement `com.apple.developer.network-extension.content-filter` muss bei Apple beantragt werden (kein normales Developer-Account-Feature). Apple erwartet Use-Case-Begründung. +- System Extension muss vom User in Systemeinstellungen > Sicherheit aktiviert werden (macOS-Gatekeeper-Flow). +- Build-Komplexität: separate Bundle-Target in Xcode, eigener App-Lifecycle, IPC zwischen Main-App und Extension. +- Gut dokumentiert in Apple Human Interface Guidelines und Network Extension Programming Guide. + +Kein öffentliches Swift-Beispiel auf GitHub gefunden (API-Suche lieferte 0 Ergebnisse für NEFilterDataProvider + Swift + macOS in Repositories). Das deutet auf closed-source-Landschaft hin (Freedom, Focus, Cold Turkey, Parental Controls etc. sind alle proprietär). + +Alternativer Ansatz — NEDNSProxyProvider (DNS-Level-Filter): +- Einfachere Entitlement, kein System Extension Review-Prozess +- Blockiert auf DNS-Ebene (kein per-Request-Filtering, kein HTTPS-Inspection) +- Wirksam gegen ~208k Casino-Domains wenn die Blocklist als lokaler DNS-Resolver fungiert +- Vergleichbar mit NextDNS/AdGuard DNS-Approach + +Reuse vom Backend: +- REST-API (Nuxt Nitro auf Hetzner): vollständig wiederverwendbar +- Blocklist JSON (208k Domains): format-kompatibel, nur laden + DNS-Lookup-Check +- Auth-Flow (Supabase JWT): standard HTTP, kein Problem +- Nur die iOS-UI und die iOS-spezifischen APIs müssen neu gebaut werden + +**Notarization:** +Apple Developer Account (Raynis e.K.) ist vorhanden. Notarization ist Standard-Prozess bei Xcode Archive. kein Zusatz-Review nötig (im Gegensatz zu App Store). + +**Minimaler Feature-Scope für ersten Build:** +1. Login via Sign in with Apple (macOS unterstützt das) +2. Blocklist laden von API oder gebündelt +3. NEDNSProxyProvider aktivieren (DNS-Blocking) +4. Status-Anzeige (aktiv/inaktiv, Anzahl blockierter Anfragen) +5. Ein-/Ausschalten +Das ist eine kleine App, nicht feature-parity mit der iOS-App. + +**Geschätzter Effort:** +- Woche 1–2: Xcode-Projekt setup, Network Extension Target, Entitlement-Antrag bei Apple +- Woche 3–4: NEDNSProxyProvider implementieren + Blocklist-Integration +- Woche 5–6: Auth (Sign in with Apple + Supabase), API-Sync der Blocklist +- Woche 7–8: Minimal-UI (SwiftUI, Menübar-App oder Hauptfenster), Notarization +- Woche 9–10: Testing, Edge-Cases, macOS 13/14/15 Kompatibilität + +Realistisch: 10–12 Wochen für einen funktionsfähigen, testbaren Build. Feature-parity (Streak, SOS-Chat, Games) wäre deutlich mehr. + +**Risiken:** +- Entitlement-Genehmigung: Apple kann Anfragen für `com.apple.developer.network-extension.content-filter` ablehnen oder verzögern. Mit NEDNSProxyProvider ist dieses Risiko geringer. +- System Extension Activation: User muss explizit in macOS Systemeinstellungen bestätigen. Onboarding-Hürde. +- macOS Gatekeeper + Notarization: kann bei Libraries/Deps Probleme machen, aber mit reinem SwiftUI + Apple Frameworks ist das manageable. + +--- + +### Pfad 4: Browser-Extension (Safari / Chrome / Firefox) + +**Blocking-Mechanismus:** +WebExtension Standard (MV3): `declarativeNetRequest` API blockiert Requests bevor sie das Netzwerk erreichen. + +Chrome MV3 Limits: +- Static Rules (in Extension Bundle): bis zu 330.000 Regeln via mehrere `rule_resources`-Rulesets (jedes bis zu 30.000 Regeln, 11 Rulesets möglich) +- Dynamic Rules (laufzeit-änderbar): 5.000 (Issue bei w3c/webextensions #319 für Erhöhung auf 30.000 ist offen, Stand Mai 2026 noch nicht merged) +- 208k Domains als static ruleset: realisierbar mit ~7 Rulesets à 30k Regeln + +Limitierung: +- Browser-Extension blockiert nur Browser-Traffic. Native Apps (Casino-Apps, andere Browser) sind nicht betroffen. +- Kein HTTPS-Inspection nötig für Domain-Blocking (anders als Port-based Blocking) +- User kann Extension deaktivieren (kein Self-Binding-Enforcement wie bei iOS) + +**Cross-Browser-Status:** +- Chrome/Chromium: MV3 vollständig, declarativeNetRequest stabil +- Firefox: MV3 Support seit Firefox 127 (Mai 2024), declarativeNetRequest verfügbar aber mit leicht anderen Limits +- Safari: Web Extensions seit Safari 14 (MV2 + einige MV3 Features). declarativeNetRequest in Safari 16.4+. Webkit Content Blocker (separates Format, bis 150k Regeln) ist eine Safari-Alternative. + +**Effort-Schätzung:** +- Woche 1: Extension-Scaffolding (Manifest V3, background service worker, popup UI) +- Woche 2: declarativeNetRequest ruleset generation aus der bestehenden 208k-Domain JSON-Blocklist +- Woche 3: Auth-Integration (Popup login via Supabase, JWT-Sync), Account-check ob aktiv +- Woche 4: Safari-spezifisches Xcode-Wrapping (Safari Web Extensions brauchen ein macOS/iOS App-Bundle), Notarization +- Woche 5: Testing Chrome + Firefox + Safari, Edge-Cases (subdomain handling, www-prefix) + +3–5 Wochen für einen ersten funktionierenden Chrome + Firefox Build. Safari kostet 1–2 extra Wochen (Xcode-Packaging). + +**Vorteile:** +- Läuft auf Windows und Linux ebenfalls (ohne Mehraufwand) +- Kein Apple-Entitlement-Antrag nötig +- Extension Store Distribution: Chrome Web Store + Firefox Add-ons sind einfach +- Safari über App Store Distribution möglich (aber kein Pflicht, Sideloading geht auch) + +**Nachteile:** +- Kein Bypass-Schutz: User kann die Extension deaktivieren +- Kein App-Blocking (Native Casino-Apps auf macOS sind nicht betroffen) +- Kein Awareness-Feature (Streak, SOS-Chat) integrierbar ohne Login-Popup +- Blocklist-Updates: müssen als Extension-Update gepusht werden (oder dynamisch via API mit dem 5k-Limit, was für 208k nicht ausreicht — static rulesets bleiben Pflicht) + +--- + +### Pfad 5: Persönliches MDM-Profil (Web Content Filter Payload) + +Apple Configuration Profile mit `WebContentFilter`-Payload: +- Filtert DNS-Requests oder URLs via Supervised-Device-Mechanism +- Für einzelnes Device: `.mobileconfig`-Datei installieren in Systemeinstellungen > Allgemein > VPN und Geräteverwaltung +- kein Produktfeature — nur für Chahine selbst, nicht für andere ReBreak-User deploybar (außer man baut eine MDM-Infrastruktur, was Scope-mäßig Pfad 3 übersteigt) +- SelfControl-Alternative: kostenlos, Open-Source, kein Dev-Aufwand, läuft heute + +**Für Chahine persönlich:** SelfControl (selfcontrolapp.com) macht genau das, ohne ein einziges Line Code zu schreiben. Blocklist importieren, Timer setzen. + +--- + +## 4. Empfehlung + +**Heute (wenn Luft da ist): Pfad 4 — Browser-Extension** +Niedrigster Aufwand, sofortiger Mehrwert für alle ReBreak-User (nicht nur macOS). Blocking für Browser-Traffic (Hauptweg ins Online-Casino) funktioniert. Kein Apple-Entitlement nötig. Chrome + Firefox in 3–4 Wochen machbar. Safari kommt als +1-Woche-Add-on über Xcode-Wrapper. + +**Bei ARR > 50k EUR oder DiGA-Zulassung: Pfad 3 — Native Swift Mac-App** +Erst dann rechtfertigt sich der Aufwand für echtes System-Level-Blocking. Greenfield-Build, NEDNSProxyProvider als Blocking-Engine, SwiftUI-UI wiederverwendend keine iOS-RN-Komponenten. Backend-API vollständig wiederverwendbar. + +**Pfad 1 und 2: nicht verfolgen.** +Beide kaufen nur UI-Portierung, lösen aber das Kern-Feature (Blocking) nicht. Der Aufwand für Pfad 2 übersteigt Pfad 3 bei gleichzeitig schlechterem Ergebnis. + +**Pfad 5 (MDM): für Chahine persönlich sofort** — SelfControl installieren, Blocklist aus ReBreak JSON importieren. Kein Dev-Aufwand. + +--- + +## 5. Offene Fragen (ungeprüft / hypothetisch markiert) + +- **Hypothese, ungeprüft:** `com.apple.developer.network-extension.content-filter` Entitlement — wie lange dauert Apples Review-Prozess aktuell? Apple-Forum-Posts aus 2023 beschreiben 2–4 Wochen. Stand 2026 unbekannt. +- **Hypothese, ungeprüft:** react-native-reanimated läuft nicht auf RN macOS. Keine Issues gefunden, aber auch kein positiver Hinweis. Wäre durch Testbuild verifizierbar. +- **Hypothese, ungeprüft:** Safari declarativeNetRequest mit 208k static rules ist performant genug. WebKit's Content Blocker (alternatives Format) wäre eine Safari-native Alternative ohne diese Unsicherheit. +- **Offen:** Firefox MV3 Static-Ruleset-Limits — 330k-Limit ist Chrome-spezifisch. Firefox-Limits bei mehreren rule_resources nicht abschließend recherchiert. + +--- + +## 6. Quellen + +- react-native-macos Repo: https://github.com/microsoft/react-native-macos +- react-native-macos releases: v0.81.2 (2026-02-11) +- react-native-bottom-tabs macOS support: https://github.com/callstack/react-native-bottom-tabs (README, Platform-Tabelle) +- async-storage Catalyst Issue #1268 (open, März 2026): https://github.com/react-native-async-storage/async-storage/issues/1268 +- w3c/webextensions Issue #319 (dynamic rules limit, open): https://github.com/w3c/webextensions/issues/319 +- SelfControl macOS app (BlockManager + PacketFilter approach): https://github.com/SelfControlApp/selfcontrol +- Apple Network Extension Programming Guide: https://developer.apple.com/documentation/networkextension +- Apple FamilyControls (iOS/iPadOS only): https://developer.apple.com/documentation/familycontrols +- react-native-mmkv platform support (iOS/Android/Web): https://github.com/mrousavy/react-native-mmkv +- rive-react-native platform support (iOS/Android): https://github.com/rive-app/rive-react-native +- Chrome declarativeNetRequest (static rulesets, 330k total limit): https://developer.chrome.com/docs/extensions/reference/api/declarativeNetRequest +- Safari Web Extensions: https://developer.apple.com/documentation/safariservices/safari-web-extensions diff --git a/ops/mdm/ARCHITECTURE.md b/ops/mdm/ARCHITECTURE.md new file mode 100644 index 0000000..2ac9986 --- /dev/null +++ b/ops/mdm/ARCHITECTURE.md @@ -0,0 +1,122 @@ +# MDM Server — Technische Architektur + +## Server + +- **Hostname:** rebreak-mdm +- **IP:** 178.105.101.137 +- **Provider:** Hetzner Cloud +- **OS:** Ubuntu 24.04 +- **SSH:** `ssh rebreak-mdm` (via ~/.ssh/config Alias) + +## DNS + +- **Domain:** mdm.rebreak.org +- **Registrar:** IONOS +- **Record:** A-Record, 178.105.101.137 +- **TTL:** Standard (300-3600s) + +## Stack-Komponenten + +### nginx (System-Service) +- Version: nginx/1.24.0 +- Port 80: HTTP-zu-HTTPS-Redirect (301) +- Port 443: SSL/TLS mit HTTP/2, reverse proxy zu nanomdm +- Config: `/etc/nginx/sites-available/mdm.rebreak.org` (symlinked in sites-enabled) +- TLS: Let's Encrypt via certbot, auto-renewal via systemd-Timer + +### NanoMDM (Docker-Container) +- Image: `ghcr.io/micromdm/nanomdm:latest` (v0.9.0 zum Zeitpunkt Setup) +- Container-Name: `nanomdm` +- Compose-File: `/opt/nanomdm/docker-compose.yml` +- Netzwerk-Mode: `host` (kein Bridge-Netzwerk — direkter Zugriff auf localhost:5432) +- Lauscht: `127.0.0.1:9000` (nur localhost, nginx proxiet) +- Restart-Policy: `unless-stopped` +- Volumes: + - `/opt/nanomdm/certs:/certs:ro` (CA-cert + Push-cert) + - `nanomdm-data:/data` (Docker-Volume) + +### PostgreSQL (System-Service) +- Version: PostgreSQL 16 +- Socket: `127.0.0.1:5432` (localhost only) +- Datenbank: `nanomdm` +- User: `nanomdm` +- Passwort: in `/root/.nanomdm_db_pass` (chmod 600) +- pg_hba: scram-sha-256 für localhost + 172.17.0.0/16 + 172.18.0.0/16 (Docker-Netze) + +### Certbot (System-Service) +- Cert-Pfad: `/etc/letsencrypt/live/mdm.rebreak.org/` +- Auto-Renewal: systemd-Timer (certbot.timer), prüft 2x täglich +- Renewal: nginx-Reload nach Renewal via Hook + +## Port-Übersicht + +| Port | Bind | Service | Beschreibung | +|------|--------------|-----------|------------------------------------| +| 80 | 0.0.0.0 | nginx | HTTP → HTTPS redirect | +| 443 | 0.0.0.0 | nginx | HTTPS, TLS termination, MDM-proxy | +| 9000 | 127.0.0.1 | nanomdm | MDM-Protokoll (intern only) | +| 5432 | 127.0.0.1 | postgres | DB (intern only) | +| 22 | 0.0.0.0 | sshd | Admin-SSH | + +UFW-Regeln: 22/tcp, 80/tcp, 443/tcp erlaubt. Alles andere denied by default. + +## Zertifikat-Pfade + +| Datei | Inhalt | Permissions | +|------------------------------------|---------------------|-------------| +| `/opt/nanomdm/certs/ca.crt` | MDM CA (self-signed)| 644 | +| `/opt/nanomdm/certs/ca.key` | MDM CA Private Key | 600 | +| `/opt/nanomdm/certs/push.csr` | Apple Push CSR | 644 | +| `/opt/nanomdm/certs/push.key` | Apple Push Priv-Key | 600 | +| `/opt/nanomdm/certs/push.pem` | Apple Push Cert (*) | 600 geplant | +| `/root/.nanomdm_db_pass` | Postgres-Passwort | 600 | + +(*) `push.pem` existiert noch nicht — warte auf Apple-Portal-Upload (Phase D.1) + +## Apple Push Zertifikat — Ablauf + +Apple-Geräte erhalten MDM-Befehle via Apple Push Notification Service (APNS). Dafür braucht NanoMDM ein von Apple signiertes Push-Zertifikat. + +Ablauf (einmal jährlich zu erneuern): + +``` +1. Server generiert push.key + push.csr (einmalig, Key bleibt gleich bei Renewal) +2. Admin lädt push.csr auf identity.apple.com/pushcert hoch +3. Apple signiert und stellt push.pem aus (Download) +4. push.pem wird auf Server kopiert: /opt/nanomdm/certs/push.pem +5. nanomdm via -apns-cert oder Umgebungsvariable konfigurieren +6. docker compose restart nanomdm +``` + +Wichtig: Bei Renewal (jährlich) den GLEICHEN push.key verwenden. Wenn ein neuer Key generiert wird, müssen alle enrollten Geräte re-enrollen. + +## Trust-Modell + +``` +Chahine (Device-Owner) + - enrolled freiwillig + - hat KEINEN MDM-Admin-Zugriff + - kann Profil NICHT selbst entfernen + +Olfa (Co-Admin) + - hat SSH-Zugriff auf rebreak-mdm + - kennt MDM-Admin-API-Key (nach Phase E generiert) + - kann Profil entfernen via nanomdm API + +Ina Wittek (Trustee, ina.wittek@gmx.de) + - bekommt Notfall-Credentials per Email (Phase E) + - kann Profil entfernen wenn weder Chahine noch Olfa erreichbar + - hat kein Server-Zugriff, nur Credentials für nanomdm-Endpoint +``` + +## Recovery-Szenarien + +| Szenario | Lösung | +|-----------------------------------|-------------------------------------------------------------| +| Profil-Entfernung nötig | Olfa oder Ina nutzen MDM-API oder nanomdm-UI | +| Server down | `ssh rebreak-mdm` → `docker compose -f /opt/nanomdm/docker-compose.yml up -d` | +| Apple Push Cert abgelaufen | Neues Push Cert via identity.apple.com, gleicher push.key | +| DB korrupt | Backup einspielen (pg_dump), dann nanomdm restart | +| Server kompromittiert | Apple Push Cert revoken auf identity.apple.com, neuer Server, neues Enrollment | +| Device verloren (gestohlen) | MDM-remote-wipe triggern (löscht Gerät), nicht MDM-Profil | +| Factory-Reset vom User | Nuclear option: alle Daten weg, aber MDM-Profil auch weg. Dann re-enroll. | diff --git a/ops/mdm/PHASES.md b/ops/mdm/PHASES.md new file mode 100644 index 0000000..ce1083c --- /dev/null +++ b/ops/mdm/PHASES.md @@ -0,0 +1,259 @@ +# MDM Setup — Phasen + +## Phase A ✅ Server-Bootstrap + +Erledigt vor 2026-05-10. + +- apt-update + apt-upgrade +- Pakete installiert: nginx, postgresql, docker.io, certbot, python3-certbot-nginx, ufw, fail2ban +- UFW konfiguriert: 22/tcp, 80/tcp, 443/tcp erlaubt, default-deny +- fail2ban aktiv (SSH-Brute-Force-Schutz) +- DNS: IONOS A-Record `mdm.rebreak.org` → 178.105.101.137 + +## Phase B ✅ TLS-Zertifikat + +Erledigt vor 2026-05-10. + +- `certbot --nginx -d mdm.rebreak.org` ausgeführt +- Cert liegt in `/etc/letsencrypt/live/mdm.rebreak.org/` +- certbot.timer (systemd) erneuert automatisch + +## Phase C ✅ NanoMDM Container + nginx-Vhost + +Erledigt 2026-05-10. + +**Was gemacht wurde:** + +1. PostgreSQL-Datenbank `nanomdm` mit User `nanomdm` und Passwort aus `/root/.nanomdm_db_pass` angelegt +2. `ALTER USER nanomdm WITH PASSWORD '...'` explizit gesetzt (scram-sha-256 braucht explizites Passwort) +3. `pg_hba.conf` ergänzt für Docker-Netze (172.17.0.0/16, 172.18.0.0/16) +4. `listen_addresses` in `postgresql.conf` auf `localhost,172.17.0.1,172.18.0.1` erweitert +5. MDM CA generiert: `ca.key` + `ca.crt` in `/opt/nanomdm/certs/` +6. `/opt/nanomdm/.env` mit `NANOMDM_DB_PASS` geschrieben (chmod 600) +7. `/opt/nanomdm/docker-compose.yml` mit `network_mode: host` (kritisch, sonst postgres nicht erreichbar wegen NAT-Masquerade) +8. `docker compose up -d` — Container läuft, `starting server listen=127.0.0.1:9000` bestätigt +9. nginx-Vhost `/etc/nginx/sites-available/mdm.rebreak.org` geschrieben + in sites-enabled symlinkt +10. `nginx -t && systemctl reload nginx` +11. Externer Verify: `curl -sI https://mdm.rebreak.org/` → `HTTP/2 404` von nanomdm (korrekt, kein 502) + +**Bekannte Tücken aus diesem Setup:** + +- `micromdm/nanomdm` auf Docker Hub existiert nicht. Korrektes Image: `ghcr.io/micromdm/nanomdm:latest` +- nanomdm v0.9 kennt `-storage postgres` nicht. Korrekt: `-storage pgsql` (bzw. `NANOMDM_STORAGE=pgsql`) +- Docker-Compose-Netzwerk (172.18.x) geht via NAT durch Host — Postgres sieht externe IP als Source. Lösung: `network_mode: host` im Compose, dann verbindet nanomdm direkt zu `127.0.0.1:5432` +- nginx 1.24 kennt `http2 on;` nicht (das ist nginx 1.25+). Korrekt: `listen 443 ssl http2;` + +## Phase D ✅ Apple Push CSR generiert + +Erledigt 2026-05-10. + +``` +openssl req -newkey rsa:2048 -nodes \ + -keyout /opt/nanomdm/certs/push.key \ + -out /opt/nanomdm/certs/push.csr \ + -subj '/CN=ReBreak MDM Push/O=Raynis/C=DE' +chmod 600 /opt/nanomdm/certs/push.key +``` + +CSR-Content liegt in `/opt/nanomdm/certs/push.csr`. Der private Key `push.key` verlässt den Server nie. + +## Phase D.0.5 ✅ mdmcert.download Signing-Request + +Erledigt 2026-05-10. + +**Warum dieser Schritt notwendig ist:** + +Apple Push Notification Service (APNS) für MDM akzeptiert keine rohen CSRs von Self-Hostern direkt im Apple Push Portal. Apple verlangt, dass die CSR von einem akkreditierten MDM-Vendor signiert wird. Self-Hoster ohne Apple-MDM-Vendor-Status nutzen `mdmcert.download` — ein Service des MicroMDM-Teams, der die CSR mit einem akzeptierten Vendor-Key gegen-signiert und encrypted per Email zurückschickt. + +**Was passiert:** +1. Wir schicken unseren CSR base64-encoded + eine Encryption-Cert an `https://mdmcert.download/api/v1/signrequest` +2. mdmcert.download signiert ihn mit ihrem Apple-akkreditierten Vendor-Key +3. Sie verschlüsseln das Ergebnis mit unserer Encryption-Cert (PKCS7) und senden es per Email an `hello@chahine-brini.com` +4. Das entschlüsselte Ergebnis (nicht der raw CSR, nicht das `.b64.p7`) wird im Apple Push Portal hochgeladen + +**Was gemacht wurde:** + +1. Encryption-Keypair auf dem MDM-Server generiert: + - Cert: `/opt/nanomdm/certs/mdmcert-encryption.crt` (public, wird an mdmcert.download geschickt) + - Key: `/opt/nanomdm/certs/mdmcert-encryption.key` (chmod 600, verlässt Server nie) + + ```bash + openssl req -new -newkey rsa:2048 -nodes \ + -keyout /opt/nanomdm/certs/mdmcert-encryption.key \ + -x509 -days 365 \ + -out /opt/nanomdm/certs/mdmcert-encryption.crt \ + -subj '/CN=ReBreak mdmcert encryption' + chmod 600 /opt/nanomdm/certs/mdmcert-encryption.key + ``` + +2. Signing-Request an mdmcert.download abgeschickt (shared public API-Key aus micromdm-Source, öffentlich dokumentiert): + + ```bash + PUSH_CSR_B64=$(base64 -w0 /opt/nanomdm/certs/push.csr) + ENC_CRT_B64=$(base64 -w0 /opt/nanomdm/certs/mdmcert-encryption.crt) + + curl -X POST https://mdmcert.download/api/v1/signrequest \ + -H "Content-Type: application/json" \ + -H "User-Agent: micromdm/certhelper" \ + -d "{\"csr\":\"$PUSH_CSR_B64\",\"email\":\"hello@chahine-brini.com\",\"key\":\"\",\"encrypt\":\"$ENC_CRT_B64\"}" + ``` + + Antwort: `{"result":"success"}` + +**Naechster Schritt:** Email von mdmcert.download bei `hello@chahine-brini.com` prüfen. Anhang-Name hat Format `mdm_signed_request.YYYYMMDD_HHMMSS_NNN.plist.b64.p7`. Dann weiter mit Phase D.0.7. + +**Technische Details (wichtig fuer Decrypt):** +- Der Dateiname endet auf `.b64.p7` — irreführend. Der tatsächliche Inhalt ist **hex-encoded PKCS7**, nicht base64. (Quelle: micromdm/micromdm cmd/mdmctl/mdmcert.download.go, Decrypt-Pfad) +- Der Decrypt-Befehl (`openssl cms` oder PKCS7-Tooling) muss zuerst hex→binary decodieren, dann PKCS7 mit dem mdmcert-encryption.key entschlüsseln + +## Phase D.0.7 ⏳ Signed CSR entschlüsseln + +**Voraussetzung:** Email von mdmcert.download mit Anhang empfangen (Phase D.0.5 abgeschlossen) + +**Wer:** Chahine schickt den Anhang per `scp` auf den MDM-Server. Oder Backyard entschlüsselt wenn Anhang auf den Server kopiert wurde. + +**Schritte:** + +1. Anhang von Email speichern (z.B. `mdm_signed_request.20260510_XXXXXX.plist.b64.p7`) + +2. Datei auf Server kopieren: + ```bash + scp ~/Downloads/mdm_signed_request.*.plist.b64.p7 rebreak-mdm:/opt/nanomdm/certs/signed_request.p7 + ``` + +3. Hex→Binary dekodieren + PKCS7 entschlüsseln (micromdm-Tooling macht beides intern): + ```bash + # Hex-String aus der Datei zu Binary konvertieren + xxd -r -p /opt/nanomdm/certs/signed_request.p7 > /opt/nanomdm/certs/signed_request.der + + # PKCS7 mit unserem Encryption-Key entschlüsseln + openssl cms -decrypt \ + -in /opt/nanomdm/certs/signed_request.der \ + -inform DER \ + -inkey /opt/nanomdm/certs/mdmcert-encryption.key \ + -recip /opt/nanomdm/certs/mdmcert-encryption.crt \ + -out /opt/nanomdm/certs/push_request.plist + ``` + +4. Ergebnis `/opt/nanomdm/certs/push_request.plist` prüfen — sollte eine Apple Plist-Datei sein. + ```bash + head -5 /opt/nanomdm/certs/push_request.plist + # Erwartete Ausgabe: https://mdm.rebreak.org/version` → `{"version":"v0.9.0"}` ✅ + +**Bekannte Tücke:** Initial-setup hat das postgres-schema nicht angewendet. NanoMDM-Container hat keine eingebaute migrate-step. Schema muss manuell via `psql -f schema.sql` geladen werden bevor erster API-call funktioniert. + +## Phase E ⏸ Email-Distribution an Ina — geparkt (User-Decision 2026-05-10) + +**Status: PARKED — alles server-side ready, Versand verschoben.** + +User-Entscheidung: PIN-Versand an Ina jetzt nicht — wird später nachgeholt. iPhone-Enrollment kann ohne laufen (MASTER-PIN ist Recovery-Backup, nicht Voraussetzung für enrollment). + +Server-Status: +- ✅ MASTER-Recovery-PIN auf Server: `/root/.nanomdm_master_pin` (chmod 600) +- ✅ Ina-Email-Draft auf Server: `/root/INA_EMAIL_DRAFT.md` (chmod 600) +- ✅ Resend-API-Key auf Server: `/root/.resend_api_key` (chmod 600) +- ⏸ Resend-Domain-Verify ungetan — Versand würde fehlschlagen ohne `chahine-brini.com` oder `rebreak.org` verified + +Reaktivierung: User sagt „Phase E GO", wir verifizieren Domain in Resend, senden, fertig. Files bleiben bis dahin auf Server. + +## Phase F ⏳ Device-Enrollment + +Wartet auf Phase E. + +Was passiert: +1. iPhone auf Werkseinstellungen zurücksetzen (Backup vorher!) +2. Während Setup: iPhone via USB-C mit Mac verbinden, Apple Configurator 2 öffnen +3. In Apple Configurator 2: Gerät preparieren (Supervised Mode aktivieren) +4. MDM-Enrollment-Profil von `https://mdm.rebreak.org/enroll` auf Gerät installieren +5. Verifyieren dass Profil als "nicht entfernbar" markiert ist +6. Apps installieren (ReBreak, etc.) + +**Hinweis zum Supervised Mode:** Ohne Supervision kann das MDM-Profil vom User entfernt werden. Supervision braucht einmalig USB + Apple Configurator. Danach ist OTA-MDM-Update möglich. + +**Scope-Constraint (User-bestätigt 2026-05-10):** Profil enthält NUR `allowAppRemoval=false` für Bundle-ID `org.rebreak.app` + `allowMDMProfileRemoval=false`. KEIN App-Store-Block, keine weiteren Restrictions. iOS-App-Store hat keine Echtgeld-Casino-Apps (Apple-Policy), Browser-Casinos werden von ReBreak's NEFilter geblockt. + +## Phase G ⏳ iPad-Enrollment (optional, später) + +Identisch zu Phase F, gleicher flow: +1. iPad via USB-C mit Mac verbinden +2. Apple Configurator 2 → Supervised-Mode → factory-reset +3. MDM-enrollment-profile von `https://mdm.rebreak.org/enroll` +4. ReBreak-iOS app installieren (läuft nativ auf iPad) +5. Verifyieren: ReBreak nicht entfernbar, MDM-profile nicht entfernbar + +**Aufwand:** ~30min nach Phase F. Apple Push Cert deckt iPad mit ab (kein zusätzlicher cert nötig). + +**Voraussetzung:** Phase F erfolgreich getestet auf iPhone. + +## Phase H ⏳ MacBook-Enrollment (optional, später) + +Anders als iPhone/iPad weil: +- **Kein ReBreak-Mac-app** existiert → MDM-profile muss eigene Blocking-Mechanik mitbringen +- Lösung: **Web-Content-Filter-Payload** im profile (DNS/URL-blocklist auf OS-Ebene) +- Mac-Supervised-Mode: factory-reset des MacBook nötig (analog iPad), via Apple Configurator 2 + USB-C + +**Schritte:** + +1. ReBreak-Blocklist (~208k domains) als Web-Content-Filter-Payload formattieren + - Payload-type: `com.apple.webcontent-filter` + - oder `com.apple.dnsSettings.managed` für DNS-level-block +2. MDM-profile assemblen mit: + - `allowMDMProfileRemoval=false` (braucht supervised-mode) + - Web-Content-Filter mit Casino-Blocklist + - Optional: `allowSafariAutoFill=false` (verhindert auto-login auf bekannten casino-sites) +3. MacBook factory-reset → Apple Configurator 2 → supervised-mode → MDM-enrollment +4. Verify: Casino-domain im Browser → blocked + +**Aufwand:** ~1 Tag (blocklist-conversion + profile-assembly + test). Plus factory-reset-zeit. + +**Voraussetzung:** +- Phase F+G erfolgreich +- User explizites GO (factory-reset MacBook = großer Schritt) +- Backup von wichtigen MacBook-Daten + +**Tradeoff:** Kein ReBreak-Mac-app = nur URL-blocking, keine SOS-features, kein Lyra, keine Community auf Mac. Wer ReBreak-features auf Mac will, braucht später entweder native Mac-app (s. `ops/mac-version-research.md`) oder Browser-Extension. diff --git a/ops/mdm/README.md b/ops/mdm/README.md new file mode 100644 index 0000000..f3fa0b3 --- /dev/null +++ b/ops/mdm/README.md @@ -0,0 +1,76 @@ +# ReBreak MDM — Projektübersicht + +## Was ist das + +MDM steht für Mobile Device Management. Dieses Projekt setzt einen selbst-gehosteten MDM-Server (NanoMDM) auf, der ein iPhone dauerhaft unter Supervision halten kann — mit dem Ziel, dass der Nutzer (Chahine) eine Spiel-Blockade nicht ohne Aufwand umgehen kann. + +Das Szenario: Chahine enrolled sein iPhone freiwillig in das MDM-Profil (self-binding). Das Profil kann er nicht selbst entfernen, weil dafür ein Admin-PIN oder die Zustimmung eines Trustees nötig ist. Die Trustees sind Olfa und Ina Wittek. + +**Kein Enterprise-MDM.** Kein Firmenzweck. Kein App-Store-Management. Ausschließlich: Entfernung des MDM-Profils blockieren. + +## Warum getrennter VPS + +Der MDM-Server läuft auf einem separaten Hetzner-VPS (`rebreak-mdm`, 178.105.101.137), getrennt von `rebreak-server` (49.13.55.22, Nuxt-App). Gründe: + +- Kein Crossover-Risiko: ein Deploy-Fehler auf dem App-Server betrifft nicht den MDM-Server +- Unabhängige Uptime: MDM muss laufen auch wenn die App deployed wird +- Klarere Verantwortung: MDM-Server hat keine App-Logik, nur nanomdm + postgres + nginx + +## Architektur + +``` +[Chahines iPhone] + | + |-- NEFilter (ReBreak iOS App, anderer Scope) + | Blockiert Gambling-Domains via Network Extension + | + |-- MDM-Profil (dieser Server) + Verhindert Entfernung der App ohne Admin-Zustimmung + | + v +[mdm.rebreak.org] (178.105.101.137) + | + +-- nginx (443 SSL) --> nanomdm (127.0.0.1:9000) + | + v + postgres (127.0.0.1:5432) + DB: nanomdm, User: nanomdm +``` + +Apple-Push-Zertifikat-Flow: +``` +[Server: push.csr] --> [identity.apple.com] --> [push.pem download] + | + [scp push.pem to server] + | + [nanomdm benutzt push.pem + um Apple APNS zu erreichen + = MDM-Befehle ans Gerät] +``` + +## Trust-Modell + +- **Chahine**: Gerät-Owner, enrolled sich selbst. Hat keinen MDM-Admin-Zugriff (Sinn der Sache). +- **Olfa**: Co-Admin. Hat Zugriff zu MDM-Credentials (in `/opt/nanomdm/` auf dem Server). +- **Ina Wittek** (`ina.wittek@gmx.de`): Trustee. Bekommt per Email einen Notfall-Schlüssel, mit dem sie das MDM-Profil entfernen kann falls Chahine z.B. das Gerät für dringende Arbeit braucht und weder er noch Olfa erreichbar sind. + +Factory-Reset = nuclear option. Zerstört alle Daten. Sollte nur als letztes Mittel genutzt werden. + +## Status + +- Phase A ✅ Server-Bootstrap +- Phase B ✅ TLS-Zertifikat +- Phase C ✅ NanoMDM container + nginx +- Phase D ✅ Apple Push CSR generiert — Benutzeraktion ausstehend +- Phase E ⏳ Email an Ina (blocked: Apple-cert + Resend-key fehlen) +- Phase F ⏳ Device-Enrollment (factory-reset + USB-Supervision + Profil-Installation) + +Details in `PHASES.md`. + +## Quick Links + +- SSH: `ssh rebreak-mdm` (178.105.101.137) +- NanoMDM: https://mdm.rebreak.org +- Apple Push Portal: https://identity.apple.com/pushcert/ +- Resend (Email-Service): https://resend.com +- NanoMDM Docs: https://github.com/micromdm/nanomdm diff --git a/ops/mdm/RUNBOOK.md b/ops/mdm/RUNBOOK.md new file mode 100644 index 0000000..e4036d8 --- /dev/null +++ b/ops/mdm/RUNBOOK.md @@ -0,0 +1,190 @@ +# MDM Server — Operations Runbook + +## SSH-Zugriff + +```bash +ssh rebreak-mdm +# entspricht: ssh root@178.105.101.137 +``` + +## NanoMDM Container + +### Status prüfen +```bash +ssh rebreak-mdm "docker ps | grep nanomdm" +ssh rebreak-mdm "cd /opt/nanomdm && docker compose ps" +``` + +### Logs anschauen +```bash +ssh rebreak-mdm "cd /opt/nanomdm && docker compose logs -f" +# Nur letzte 50 Zeilen: +ssh rebreak-mdm "cd /opt/nanomdm && docker compose logs --tail=50" +``` + +### Restart +```bash +ssh rebreak-mdm "cd /opt/nanomdm && docker compose restart" +``` + +### Stop + Start (hard restart) +```bash +ssh rebreak-mdm "cd /opt/nanomdm && docker compose down && docker compose up -d" +``` + +### Auf neue Version updaten +```bash +ssh rebreak-mdm "cd /opt/nanomdm && docker compose pull && docker compose up -d" +``` + +## PostgreSQL + +### Zugriff auf nanomdm-DB +```bash +ssh rebreak-mdm "sudo -u postgres psql nanomdm" +``` + +### DB-Passwort abrufen +```bash +ssh rebreak-mdm "cat /root/.nanomdm_db_pass" +``` + +### Tabellen-Übersicht +```bash +ssh rebreak-mdm "sudo -u postgres psql nanomdm -c '\dt'" +``` + +### DB-Backup +```bash +ssh rebreak-mdm "sudo -u postgres pg_dump nanomdm > /tmp/nanomdm-$(date +%Y%m%d).sql" +# Lokal kopieren: +scp rebreak-mdm:/tmp/nanomdm-*.sql ./backups/ +``` + +### DB-Restore (nach Backup) +```bash +# Achtung: destructive — nur nach User-Bestätigung +ssh rebreak-mdm "sudo -u postgres psql nanomdm < /path/to/backup.sql" +``` + +## nginx + +### Config testen +```bash +ssh rebreak-mdm "nginx -t" +``` + +### Reload (nach Config-Änderung) +```bash +ssh rebreak-mdm "systemctl reload nginx" +``` + +### Vhost-Config +```bash +ssh rebreak-mdm "cat /etc/nginx/sites-available/mdm.rebreak.org" +``` + +### Logs +```bash +ssh rebreak-mdm "tail -f /var/log/nginx/access.log" +ssh rebreak-mdm "tail -f /var/log/nginx/error.log" +``` + +## TLS-Zertifikat (Let's Encrypt) + +### Status prüfen +```bash +ssh rebreak-mdm "certbot certificates" +ssh rebreak-mdm "systemctl status certbot.timer" +``` + +### Manuelle Renewal (Notfall) +```bash +# ACHTUNG: Rate-Limit bei --force-renewal. Nur wenn wirklich nötig. +# Erst ohne force testen: +ssh rebreak-mdm "certbot renew --dry-run" +# Dann renewal: +ssh rebreak-mdm "certbot renew" +``` + +### Cert-Expiry prüfen +```bash +ssh rebreak-mdm "openssl x509 -in /etc/letsencrypt/live/mdm.rebreak.org/cert.pem -noout -dates" +``` + +## Apple Push Zertifikat + +### Expiry prüfen +```bash +# Nach Phase D.1 (wenn push.pem vorhanden): +ssh rebreak-mdm "openssl x509 -in /opt/nanomdm/certs/push.pem -noout -dates" +``` + +### Jährliche Renewal +1. CSR-File ist noch da: `/opt/nanomdm/certs/push.csr` +2. Gleichen CSR auf identity.apple.com hochladen (neues Cert, gleicher Key) +3. Neues `.pem` auf Server kopieren: `scp ./MDMCertificate.pem rebreak-mdm:/opt/nanomdm/certs/push.pem` +4. `chmod 600 /opt/nanomdm/certs/push.pem` +5. `docker compose restart` auf Server + +### Neues CSR generieren (nur wenn push.key verloren!) +```bash +# ACHTUNG: Neuer Key = alle Geräte müssen re-enrollen +ssh rebreak-mdm "cd /opt/nanomdm/certs && openssl req -newkey rsa:2048 -nodes \ + -keyout push.key -out push.csr \ + -subj '/CN=ReBreak MDM Push/O=Raynis/C=DE' && chmod 600 push.key" +ssh rebreak-mdm "cat /opt/nanomdm/certs/push.csr" +``` + +## Externer Health-Check + +```bash +# Erwartet: HTTP 404 von nanomdm (normales Verhalten auf /) +curl -sI https://mdm.rebreak.org/ +# Erwartet: "Bad Request" (MDM-Endpoint ohne gültigen Apple-Payload) +curl -s https://mdm.rebreak.org/mdm +``` + +## Firewall (UFW) + +```bash +ssh rebreak-mdm "ufw status numbered" +# Regel hinzufügen (Beispiel SSH von spezifischer IP): +ssh rebreak-mdm "ufw allow from 1.2.3.4 to any port 22" +``` + +## System-Ressourcen + +```bash +ssh rebreak-mdm "df -h && free -h && docker stats --no-stream" +``` + +## Troubleshooting + +### nanomdm startet nicht + +```bash +ssh rebreak-mdm "cd /opt/nanomdm && docker compose logs --tail=50" +``` + +Häufige Ursachen: +- DB-Verbindung: `postgres://nanomdm:PASS@127.0.0.1:5432/nanomdm` — postgres läuft? `systemctl is-active postgresql@16-main` +- CA-Cert fehlt: `/opt/nanomdm/certs/ca.crt` vorhanden? +- .env-File: `cat /opt/nanomdm/.env` — NANOMDM_DB_PASS gesetzt? +- network_mode host nötig: in docker-compose.yml prüfen + +### 502 Bad Gateway von nginx + +Bedeutet nanomdm läuft nicht oder antwortet nicht auf 127.0.0.1:9000. + +```bash +ssh rebreak-mdm "curl -sv http://127.0.0.1:9000/" +ssh rebreak-mdm "cd /opt/nanomdm && docker compose up -d" +``` + +### Postgres startet nicht + +```bash +ssh rebreak-mdm "journalctl -u postgresql@16-main -n 50" +ssh rebreak-mdm "pg_lsclusters" +``` diff --git a/ops/mdm/SECURITY.md b/ops/mdm/SECURITY.md new file mode 100644 index 0000000..f19a167 --- /dev/null +++ b/ops/mdm/SECURITY.md @@ -0,0 +1,67 @@ +# MDM Server — Security + +## Was muss geheim bleiben + +| Secret | Wo es liegt | Permissions | Wer hat Zugriff | +|-------------------------------------|--------------------------------------|-------------|---------------------| +| PostgreSQL-Passwort | `/root/.nanomdm_db_pass` | 600 (root) | Chahine, Olfa | +| MDM CA Private Key | `/opt/nanomdm/certs/ca.key` | 600 (root) | Chahine, Olfa | +| Apple Push Private Key | `/opt/nanomdm/certs/push.key` | 600 (root) | Chahine, Olfa | +| nanomdm .env (enthält DB-Pass) | `/opt/nanomdm/.env` | 600 (root) | Chahine, Olfa | +| MDM-Admin-API-Key (nach Phase E) | Wird nach Phase E generiert | - | Chahine, Olfa, Ina* | +| Ina-Notfall-Credentials | Per Email (Phase E) + evt. Kopie | - | Ina | + +(*) Ina bekommt nur den API-Key, keinen SSH-Zugriff. + +## Was NICHT geheim sein muss + +- `push.csr` — CSR ist öffentlich (geht ans Apple-Portal) +- `ca.crt` — CA-Zertifikat ist öffentlich (wird ans Gerät übertragen) +- nginx-Config, docker-compose.yml (ohne Passwörter) + +## Threat-Modelle + +### Server-Kompromittierung + +Was der Angreifer bekommt: +- DB-Pass → Zugriff auf nanomdm-DB (Device-Liste, Enrollment-Daten) +- push.key → Kann eigene MDM-Befehle an Geräte senden (mit Apple-Cert) +- ca.key → Kann eigene Device-Identity-Certs ausstellen + +Was zu tun ist: +1. Apple Push Cert sofort auf identity.apple.com revoken +2. Neuen VPS aufsetzen (Phase A-D wiederholen) +3. Geräte re-enrollen mit neuem Push-Cert + neuem CA-Cert +4. Neues DB-Passwort aus `/root/.nanomdm_db_pass` (von Chahine neu generiert) + +### Device-Verlust (gestohlen) + +- MDM-Remote-Wipe triggern: löscht alle Daten auf Gerät +- Apple-ID-basiertes "Find My" als zusätzliche Schicht (unabhängig vom MDM) +- MDM-Profil ist nach Factory-Reset weg → Gerät ist dann nicht mehr enrollt + +### Angreifer hat Zugriff auf Ina's Email-Account + +Ina's Notfall-Credentials (Phase E) geben nur Zugriff auf nanomdm-API um das Profil zu entfernen, keinen Server-SSH-Zugriff. Worst-case: Angreifer entfernt MDM-Profil vom Gerät. Das MDM kann dann re-enrollen wenn Chahine zustimmt. + +### Abgelaufenes Apple Push Cert + +Wenn push.pem abläuft (nach 1 Jahr): nanomdm kann keine Befehle mehr ans Gerät schicken. Gerät ist aber noch enrollt (Profil ist drauf). Nach Cert-Renewal (gleicher push.key) funktioniert Kommunikation wieder. + +## Geheimhaltungs-Regeln + +1. `push.key`, `ca.key`, DB-Passwort werden NIEMALS in Git committed +2. `/opt/nanomdm/.env` hat chmod 600 — Änderung würde nanomdm-Container-Restart erfordern +3. Keine Passwörter in Docker-Logs (env-vars sind als values gesetzt, nicht als --env in command-line args) +4. SSH-Zugriff nur via Key-Auth (kein Password-SSH auf dem Server) + +## Audit-Trail + +Relevante Events zum Dokumentieren in `/opt/nanomdm/SETUP-LOG.md` auf dem Server: + +- Wann wurde welcher Container deployed +- Wann wurde Apple Push Cert erneuert (Datum + Apple-ID die es ausgestellt hat) +- Wann wurde Enrollment durchgeführt (Gerät, Datum) +- Wann wurde Profil entfernt (wer hat entfernt, warum) + +Format: `[DATUM] [WER] [WAS]` — plain text, kein JSON. diff --git a/ops/mdm/USER-ACTIONS-PENDING.md b/ops/mdm/USER-ACTIONS-PENDING.md new file mode 100644 index 0000000..d901077 --- /dev/null +++ b/ops/mdm/USER-ACTIONS-PENDING.md @@ -0,0 +1,75 @@ +# Ausstehende Benutzeraktionen + +Zuletzt aktualisiert: 2026-05-10 + +--- + +## Sofort (Phase D.1) — Apple Push Zertifikat + +- [ ] **Apple Portal öffnen:** https://identity.apple.com/pushcert/ + - Mit der Apple-ID einloggen, die als MDM-Zertifikats-Eigentümer gelten soll + - Empfehlung: dieselbe Apple-ID, die auch für den Apple Developer Account genutzt wird + +- [ ] **CSR herunterladen** (oder Inhalt kopieren): + ```bash + scp rebreak-mdm:/opt/nanomdm/certs/push.csr ~/Desktop/push.csr + ``` + Alternativ Inhalt anzeigen: `ssh rebreak-mdm "cat /opt/nanomdm/certs/push.csr"` + +- [ ] **Im Apple Portal:** "Create a Certificate" → CSR hochladen → Cert herunterladen (`.pem` oder `.cer`) + +- [ ] **Cert auf Server kopieren:** + ```bash + scp ~/Downloads/MDMCertificate.pem rebreak-mdm:/opt/nanomdm/certs/push.pem + ssh rebreak-mdm "chmod 600 /opt/nanomdm/certs/push.pem" + ``` + +- [ ] **Backyard-Agent neu starten** für Phase E (mit Hinweis: "Apple-cert ist da") + +--- + +## Danach (Phase E-Vorbereitung) — Resend API-Key + +- [ ] **Resend-Account erstellen:** https://resend.com (Free-Plan reicht für eine Email) +- [ ] **API-Key generieren:** Settings → API Keys → Create API Key +- [ ] **API-Key an Backyard weitergeben** (in der nächsten Session) + +--- + +## Ina vorwarnen (optional, aber empfohlen) + +- [ ] **Kurze Info an Ina** (`ina.wittek@gmx.de`) per WhatsApp/Signal/Telefon: + "Du bekommst demnächst eine Email von mir bezüglich ReBreak MDM. Das ist eine Art Notfall-Treuhänder-Funktion. Die Email erklärt alles." + + Damit die Email nicht als Spam landet und Ina nicht überrascht wird. + +--- + +## Später (Phase F) — Device Enrollment + +- [ ] **Apple Configurator 2** auf Mac installieren (kostenlos im Mac App Store) +- [ ] **USB-C-Kabel** bereithalten (iPhone-zu-Mac) +- [ ] **Backup vom iPhone** erstellen (iCloud oder Finder-Backup) +- [ ] **Koordination mit Chahine** — Factory-Reset ist nötig, alle lokalen Daten gehen verloren + + Reihenfolge: + 1. Backup verifizieren + 2. Factory-Reset iPhone + 3. Bei Setup: USB-Verbindung zu Mac mit Apple Configurator + 4. Supervision aktivieren + 5. MDM-Profil enrollen + 6. Backup wiederherstellen + +--- + +## Status-Übersicht + +| Phase | Status | Warte auf | +|------------|------------|-----------------------------------| +| A (Server) | ✅ Done | - | +| B (TLS) | ✅ Done | - | +| C (NanoMDM)| ✅ Done | - | +| D (CSR) | ✅ Done | - | +| D.1 (Cert) | ⏳ User | Apple Portal Upload (diese Liste) | +| E (Email) | ⏳ Blocked | Apple-cert + Resend-Key | +| F (Device) | ⏳ Later | Phase E abgeschlossen | diff --git a/ops/mdm/rebreak-mac-dns-filter.mobileconfig b/ops/mdm/rebreak-mac-dns-filter.mobileconfig new file mode 100644 index 0000000..224d2c0 --- /dev/null +++ b/ops/mdm/rebreak-mac-dns-filter.mobileconfig @@ -0,0 +1,48 @@ + + + + + PayloadContent + + + PayloadDisplayName + ReBreak DNS-Filter + PayloadDescription + Leitet DNS-Anfragen über dns.rebreak.org. Glücksspiel-Domains werden blockiert. + PayloadIdentifier + org.rebreak.protection.dns.filter + PayloadType + com.apple.dnsSettings.managed + PayloadUUID + 7D2E8B1A-C3D4-4E76-8B23-A4B5C6D7E8F0 + PayloadVersion + 1 + DNSSettings + + DNSProtocol + HTTPS + ServerURL + https://dns.rebreak.org/dns-query + + + + PayloadDisplayName + ReBreak Schutz + PayloadDescription + Aktiviert den ReBreak-DNS-Filter auf diesem Mac. Glücksspiel-Domains werden auf System-Ebene blockiert — gilt für alle Browser, alle Apps. Kann via Systemeinstellungen → Allgemein → Geräteverwaltung entfernt werden (Admin-Passwort erforderlich). + PayloadIdentifier + org.rebreak.protection.profile + PayloadOrganization + ReBreak + PayloadType + Configuration + PayloadUUID + 8C3F9A2B-D4E5-4F87-9A12-B5C6D7E8F901 + PayloadVersion + 1 + PayloadScope + System + PayloadRemovalDisallowed + + + diff --git a/ops/strategy/mdm-productization-roadmap.md b/ops/strategy/mdm-productization-roadmap.md new file mode 100644 index 0000000..6c3b2d9 --- /dev/null +++ b/ops/strategy/mdm-productization-roadmap.md @@ -0,0 +1,116 @@ +# MDM-Productization Roadmap + +**Stand:** 2026-05-10 (nach Phase F: persönliches Self-Binding für Chahine erfolgreich) + +## Vision + +ReBreak bietet als optionales Add-On (3€/mo on top auf Pro/Legend) ein vollständiges MDM-Lock-Setup für motivierte Recovery-User. Voraussetzung: Mac + USB + Bereitschaft zum factory-reset. + +## Ziel-Audience + +Schmaler aber motivierter Markt: +- Recovery-Community-Members nach 100000 Verzweiflungen +- DiGA-Patienten in aktiver Relapse-Prevention mit Therapie-Begleitung +- Users die "alles andere probiert haben" und maximalen Lock wollen + +User-Insight (Chahine, 2026-05-10): "wenn keine nachfrage da ist haben wir nicht viel verloren — server steht eh." + +## Was schon steht (Phase F done) + +- NanoMDM-Server auf rebreak-mdm (178.105.101.137) +- Apple-Push-Cert via mdmcert.download +- AdGuard Home DoH @ dns.rebreak.org mit ReBreak-Blocklist +- DNS-MDM-Profile non-removable (supervised-only) +- Backend-Endpoint `/api/url-filter/blocklist.txt` als single source of truth + +## Productization-Phase G (~1-2 Wochen dev) + +### G.1 Enrollment-Profile-Generator + +Backend-Endpoint `POST /api/mdm/enroll-profile` (Pro/Legend gated): +- Generiert per-user device-identity-cert (signed by NanoMDM CA) +- Wrapped als PKCS12 +- Build .mobileconfig mit MDM-payload pointing zu `https://mdm.rebreak.org/mdm` +- DNS-payload pointing zu `https://dns.rebreak.org/dns-query` +- Returns als download + +Heute manuell gemacht in `/opt/nanomdm/enrollment/` — automatisieren. + +### G.2 User-Device-Link in DB + +NanoMDM speichert devices in eigener DB (table `devices`). Brauchen mapping zu rebreak users: +- Neue table `rebreak.mdm_enrollments(user_id, device_id, enrolled_at, status)` +- Backend-API: `GET /api/mdm/my-status` returns enrollment-status für UI + +### G.3 Lyra-Onboarding-Flow + +In-App "Stärkster Schutz" Button (Pro/Legend): +1. Lyra-conversation: "Bist du sicher? Bedeutet factory-reset deines iPhones..." +2. Risiko-Aufklärung: Apps + lokale Daten (außer iCloud-Backup) verloren +3. **7-Tage-Cooldown** wie andere Schutze — User muss 7 Tage drüber schlafen +4. Nach Cooldown: Step-by-step Anleitung +5. Web-link öffnet `mdm.rebreak.org/onboarding/` +6. Apple Configurator Wizard (Markdown-formatted instructions + screenshots) +7. Profile-Download +8. Wenn enrolled: NanoMDM pushed DNS-Profile + Restriction-Profile automatisch + +### G.4 Onboarding-Web-Page + +Static page (Nuxt marketing app) `mdm.rebreak.org/onboarding/`: +- Step-1: Mac-requirement check +- Step-2: Apple Configurator install (App Store link) +- Step-3: factory-reset Anleitung (Settings-Pfad screenshot) +- Step-4: USB-connect + Configurator-Prepare-wizard (mit Screenshots) +- Step-5: .mobileconfig download + install via Apple Configurator +- Step-6: Bestätigung dass enrollment erfolgreich (backend-callback) + +### G.5 Stripe-Add-On-Tier + +- Pro: 3.99€ → mit MDM 6.99€ +- Legend: 7.99€ → mit MDM 10.99€ +- Stripe-Subscription-Modification API + +### G.6 Per-User-Blocklist (later) + +Aktuell: AdGuard pulled GLOBAL `getActiveBlocklistDomains()`. +Phase G.6: extend zu user-specific (custom-domains pro User). +Optionen: +- AdGuard-multi-DNS-server (1 pro User) — overkill +- Custom DoH-server der per-Token user-spezifische blocklist serviert +- Nicht-Priority — global blocklist ist 99% der Use-Cases + +## Out-of-Scope (Apple-Hard-Limits) + +- **Windows-User-Support**: Apple Configurator nur auf macOS. Windows-Pfad bräuchte custom Apple-Configurator-clone = monate dev. Skip. +- **DEP/ABM-Enrollment**: Wäre "ohne factory-reset enrollable", aber braucht DUNS + Apple-Business-Manager-Approval + nur Neu-Geräte via Reseller. Out of scope für consumer. +- **Per-App-Family-Controls-Toggle-Lock**: Apple-Platform-Limit (siehe Research Mai 2026). DNS-Layer kompensiert. + +## Marginal Cost pro neuem User + +- 1 row in nanomdm.devices: ~1KB +- APNS-connection: shared-pool, kosten gegen 0 +- DoH-queries: paar 100 pro Tag pro User → AdGuard handhabt easy +- Storage/Bandwidth: vernachlässigbar +- **Effektiv: ~0€/mo pro MDM-User** + +Bei 3€/mo Add-On = ~95% Marge. + +## Risk-Assessment + +- Apple könnte mdmcert.download-shared-key revoken (wenn auffällig viele Personal-MDM-Users) → fallback DNS funktioniert weiter, MDM-Push-commands brechen. Mitigation: eigener mdmcert-Account-Apply (kostenlos) +- Support-burden: jeder MDM-User wird ggf. Hilfe beim Setup brauchen. Initial-Beta: max 10-20 User, manueller Support, Lyra-led +- Liability: User locked sich aus → Recovery-Pfad via Lyra + Chahine-Manual-Override (admin-API). Cooldown verhindert impulsive enrollment. + +## Decision-Points (User entscheidet) + +- [ ] Phase G bauen oder warten bis 5+ User explizit nachfragen? +- [ ] Beta-Launch: stille Mail an existierende Legend-Users oder offen? +- [ ] Preisbestätigung: 3€ Add-On bestätigt oder 5-9€ wie Strategist eher empfehlen würde? + +Strategist hat Pricing-Analysis pending (Task #58) — abwarten bevor finale Preis-Entscheidung. + +## Source-of-Truth-Files + +- Personal-Setup-Doku: `ops/mdm/PHASES.md` (Phase A-F) +- Architektur: `ops/mdm/ARCHITECTURE.md` +- Pricing-Strategy (pending): output von Strategist Task #58 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9c959a1..574f649 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,10 +24,10 @@ importers: version: 14.3.0(vue@3.5.34(typescript@5.9.3)) '@vueuse/nuxt': specifier: ^14.2.1 - version: 14.3.0(magicast@0.5.2)(nuxt@4.1.3(@electric-sql/pglite@0.4.1)(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@parcel/watcher@2.5.6)(@types/node@22.19.17)(@vue/compiler-sfc@3.5.34)(cac@6.7.14)(db0@0.3.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.2)(mysql2@3.15.3)(rollup@4.60.3)(terser@5.46.2)(typescript@5.9.3)(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4))(yaml@2.8.4))(vue@3.5.34(typescript@5.9.3)) + version: 14.3.0(magicast@0.5.2)(nuxt@4.1.3(@electric-sql/pglite@0.4.1)(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@parcel/watcher@2.5.6)(@types/node@22.19.17)(@vue/compiler-sfc@3.5.34)(cac@6.7.14)(db0@0.3.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3))(eslint@10.3.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.2)(mysql2@3.15.3)(optionator@0.9.4)(rollup@4.60.3)(terser@5.46.2)(typescript@5.9.3)(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4))(yaml@2.8.4))(vue@3.5.34(typescript@5.9.3)) nuxt: specifier: 4.1.3 - version: 4.1.3(@electric-sql/pglite@0.4.1)(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@parcel/watcher@2.5.6)(@types/node@22.19.17)(@vue/compiler-sfc@3.5.34)(cac@6.7.14)(db0@0.3.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.2)(mysql2@3.15.3)(rollup@4.60.3)(terser@5.46.2)(typescript@5.9.3)(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4))(yaml@2.8.4) + version: 4.1.3(@electric-sql/pglite@0.4.1)(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@parcel/watcher@2.5.6)(@types/node@22.19.17)(@vue/compiler-sfc@3.5.34)(cac@6.7.14)(db0@0.3.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3))(eslint@10.3.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.2)(mysql2@3.15.3)(optionator@0.9.4)(rollup@4.60.3)(terser@5.46.2)(typescript@5.9.3)(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4))(yaml@2.8.4) tailwindcss: specifier: ^4.1.18 version: 4.2.4 @@ -48,6 +48,58 @@ importers: specifier: ^5.9.3 version: 5.9.3 + apps/marketing: + dependencies: + '@iconify-json/heroicons': + specifier: ^1.2.3 + version: 1.2.3 + '@nuxt/fonts': + specifier: ^0.11.4 + version: 0.11.4(db0@0.3.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3))(ioredis@5.10.1)(magicast@0.5.2)(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4)) + '@nuxt/icon': + specifier: ^1.10.0 + version: 1.15.0(magicast@0.5.2)(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4))(vue@3.5.34(typescript@5.9.3)) + '@nuxt/image': + specifier: ^1.11.0 + version: 1.11.0(db0@0.3.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3))(ioredis@5.10.1)(magicast@0.5.2) + '@nuxt/ui': + specifier: ^4.5.1 + version: 4.7.1(@internationalized/date@3.12.1)(@internationalized/number@3.6.6)(@tiptap/extensions@3.23.1(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1))(@tiptap/y-tiptap@3.0.3(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.8)(y-protocols@1.0.7(yjs@13.6.30))(yjs@13.6.30))(db0@0.3.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3))(embla-carousel@8.6.0)(ioredis@5.10.1)(magicast@0.5.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(tailwindcss@4.2.4)(typescript@5.9.3)(valibot@1.4.0(typescript@5.9.3))(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4))(vue-router@4.6.4(vue@3.5.34(typescript@5.9.3)))(vue@3.5.34(typescript@5.9.3))(yjs@13.6.30)(zod@3.25.76) + '@nuxtjs/i18n': + specifier: ^9.5.6 + version: 9.5.6(@vue/compiler-dom@3.5.34)(eslint@10.3.0(jiti@2.7.0))(magicast@0.5.2)(rollup@4.60.3)(vue@3.5.34(typescript@5.9.3)) + '@vueuse/motion': + specifier: ^3.0.3 + version: 3.0.3(magicast@0.5.2)(vue@3.5.34(typescript@5.9.3)) + '@vueuse/nuxt': + specifier: ^14.2.1 + version: 14.3.0(magicast@0.5.2)(nuxt@4.1.3(@electric-sql/pglite@0.4.1)(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@parcel/watcher@2.5.6)(@types/node@22.19.17)(@vue/compiler-sfc@3.5.34)(cac@6.7.14)(db0@0.3.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3))(eslint@10.3.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.2)(mysql2@3.15.3)(optionator@0.9.4)(rollup@4.60.3)(terser@5.46.2)(typescript@5.9.3)(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4))(yaml@2.8.4))(vue@3.5.34(typescript@5.9.3)) + chart.js: + specifier: ^4.5.1 + version: 4.5.1 + nuxt: + specifier: 4.1.3 + version: 4.1.3(@electric-sql/pglite@0.4.1)(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@parcel/watcher@2.5.6)(@types/node@22.19.17)(@vue/compiler-sfc@3.5.34)(cac@6.7.14)(db0@0.3.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3))(eslint@10.3.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.2)(mysql2@3.15.3)(optionator@0.9.4)(rollup@4.60.3)(terser@5.46.2)(typescript@5.9.3)(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4))(yaml@2.8.4) + tailwindcss: + specifier: ^4.1.18 + version: 4.2.4 + vue: + specifier: ^3.5.22 + version: 3.5.34(typescript@5.9.3) + vue-chartjs: + specifier: ^5.3.3 + version: 5.3.3(chart.js@4.5.1)(vue@3.5.34(typescript@5.9.3)) + vue-router: + specifier: ^4.5.1 + version: 4.6.4(vue@3.5.34(typescript@5.9.3)) + devDependencies: + '@nuxt/devtools': + specifier: latest + version: 4.0.0-alpha.4(@pnpm/logger@1001.0.1)(db0@0.3.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3))(ioredis@5.10.1)(typescript@5.9.3)(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4))(vue@3.5.34(typescript@5.9.3)) + typescript: + specifier: ^5.9.3 + version: 5.9.3 + apps/rebreak-native: dependencies: '@expo-google-fonts/nunito': @@ -179,6 +231,9 @@ importers: react-native-gesture-handler: specifier: ~2.28.0 version: 2.28.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0) + react-native-keyboard-controller: + specifier: ^1.21.7 + version: 1.21.7(react-native-reanimated@4.1.7(react-native-worklets@0.5.1(@babel/core@7.29.0)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0) react-native-mmkv: specifier: ^3.1.0 version: 3.3.3(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0) @@ -832,6 +887,12 @@ packages: commander: optional: true + '@capsizecss/metrics@3.7.0': + resolution: {integrity: sha512-NHdEMrl/zd2XgiSv2xHRF/FxGc2OTBKjhPzr9SgbHzqmoTVn8BbRK88Dtq0m65idX/RMD7ptyVdbGHFcvlErSw==} + + '@capsizecss/unpack@2.4.0': + resolution: {integrity: sha512-GrSU71meACqcmIUxPYOJvGKF0yryjN/L1aCuE9DViCTJI7bfkjgYDPD1zbNDcINJwSSP6UaBZY9GAbYDO7re0Q==} + '@capsizecss/unpack@4.0.0': resolution: {integrity: sha512-VERIM64vtTP1C4mxQ5thVT9fK0apjPFobqybMtA1UdUujWka24ERHbRHFGmpbbhp73MhV+KSsHQH9C6uOTdEQA==} engines: {node: '>=18'} @@ -1484,6 +1545,36 @@ packages: cpu: [x64] os: [win32] + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.23.5': + resolution: {integrity: sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/config-helpers@0.5.5': + resolution: {integrity: sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/core@1.2.1': + resolution: {integrity: sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/object-schema@3.0.5': + resolution: {integrity: sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/plugin-kit@0.7.1': + resolution: {integrity: sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + '@expo-google-fonts/nunito@0.2.3': resolution: {integrity: sha512-z+Bx3IuT0t3jguoMxiyWuC7pW3wDVNHgYko/G9V23QhR/yDSjEsT+Kx+VGDT/hu9TXSxw3CtpQ5MFHikqSVDYw==} @@ -1616,6 +1707,10 @@ packages: resolution: {integrity: sha512-wC562eD3gS6vO2tWHToFhlFnmHKfKHgF1oyvojeSkLK/ZYop1bMU+7cOMiF9Sq70CzcsLy/EMRy/uRc76QmNRw==} hasBin: true + '@fastify/accept-negotiator@1.1.0': + resolution: {integrity: sha512-OIHZrb2ImZ7XG85HXOONLcJWGosv7sIvM2ifAPQVhg9Lv7qdmMBNVaai4QTdyuaqbKM5eO6sLSQOYI7wEQeCJQ==} + engines: {node: '>=14'} + '@floating-ui/core@1.7.5': resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} @@ -1638,6 +1733,26 @@ packages: peerDependencies: hono: ^4 + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} + + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + '@iconify-json/heroicons@1.2.3': resolution: {integrity: sha512-n+vmCEgTesRsOpp5AB5ILB6srsgsYK+bieoQBNlafvoEhjVXLq8nIGN4B0v/s4DUfa0dOrjwE/cKJgIKdJXOEg==} @@ -1667,6 +1782,81 @@ packages: '@internationalized/number@3.6.6': resolution: {integrity: sha512-iFgmQaXHE0vytNfpLZWOC2mEJCBRzcUxt53Xf/yCXG93lRvqas237i3r7X4RKMwO3txiyZD4mQjKAByFv6UGSQ==} + '@intlify/bundle-utils@10.0.1': + resolution: {integrity: sha512-WkaXfSevtpgtUR4t8K2M6lbR7g03mtOxFeh+vXp5KExvPqS12ppaRj1QxzwRuRI5VUto54A22BjKoBMLyHILWQ==} + engines: {node: '>= 18'} + peerDependencies: + petite-vue-i18n: '*' + vue-i18n: '*' + peerDependenciesMeta: + petite-vue-i18n: + optional: true + vue-i18n: + optional: true + + '@intlify/core-base@10.0.8': + resolution: {integrity: sha512-FoHslNWSoHjdUBLy35bpm9PV/0LVI/DSv9L6Km6J2ad8r/mm0VaGg06C40FqlE8u2ADcGUM60lyoU7Myo4WNZQ==} + engines: {node: '>= 16'} + + '@intlify/core@10.0.8': + resolution: {integrity: sha512-2BbgN0aeuYHOHe7kVlTr2XxyrnLQZ/4/Y0Pw8luU67723+AqVYqxB7ZG1FzLCVNwAmzdVZMjKzFpgOzdUSdBfw==} + engines: {node: '>= 16'} + + '@intlify/h3@0.6.1': + resolution: {integrity: sha512-hFMcqWXCoFNZkraa+JF7wzByGdE0vGi8rUs7CTFrE4hE3X2u9QcelH8VRO8mPgJDH+TgatzvrVp6iZsWVluk2A==} + engines: {node: '>= 18'} + + '@intlify/message-compiler@10.0.8': + resolution: {integrity: sha512-DV+sYXIkHVd5yVb2mL7br/NEUwzUoLBsMkV3H0InefWgmYa34NLZUvMCGi5oWX+Hqr2Y2qUxnVrnOWF4aBlgWg==} + engines: {node: '>= 16'} + + '@intlify/message-compiler@11.4.2': + resolution: {integrity: sha512-a6CDSGSMTGrg0BjD97x8TBYPf7qQMDlZipJ6UDfv/pd4OIym8TMlHu3MsH0bTNnRdAG2D6EFEykIgiQPqvtTkA==} + engines: {node: '>= 16'} + + '@intlify/shared@10.0.8': + resolution: {integrity: sha512-BcmHpb5bQyeVNrptC3UhzpBZB/YHHDoEREOUERrmF2BRxsyOEuRrq+Z96C/D4+2KJb8kuHiouzAei7BXlG0YYw==} + engines: {node: '>= 16'} + + '@intlify/shared@11.4.2': + resolution: {integrity: sha512-NzpHbguRCsOHDwxmlBa9qu/imc+/QWgsYUaK6FZeNC0wK8QfAbhqrktEp/haVzxU1aikH8IX4ytD+mfFEMi/9A==} + engines: {node: '>= 16'} + + '@intlify/unplugin-vue-i18n@6.0.8': + resolution: {integrity: sha512-Vvm3KhjE6TIBVUQAk37rBiaYy2M5OcWH0ZcI1XKEsOTeN1o0bErk+zeuXmcrcMc/73YggfI8RoxOUz9EB/69JQ==} + engines: {node: '>= 18'} + peerDependencies: + petite-vue-i18n: '*' + vue: ^3.2.25 + vue-i18n: '*' + peerDependenciesMeta: + petite-vue-i18n: + optional: true + vue-i18n: + optional: true + + '@intlify/utils@0.13.0': + resolution: {integrity: sha512-8i3uRdAxCGzuHwfmHcVjeLQBtysQB2aXl/ojoagDut5/gY5lvWCQ2+cnl2TiqE/fXj/D8EhWG/SLKA7qz4a3QA==} + engines: {node: '>= 18'} + + '@intlify/vue-i18n-extensions@8.0.0': + resolution: {integrity: sha512-w0+70CvTmuqbskWfzeYhn0IXxllr6mU+IeM2MU0M+j9OW64jkrvqY+pYFWrUnIIC9bEdij3NICruicwd5EgUuQ==} + engines: {node: '>= 18'} + peerDependencies: + '@intlify/shared': ^9.0.0 || ^10.0.0 || ^11.0.0 + '@vue/compiler-dom': ^3.0.0 + vue: ^3.0.0 + vue-i18n: ^9.0.0 || ^10.0.0 || ^11.0.0 + peerDependenciesMeta: + '@intlify/shared': + optional: true + '@vue/compiler-dom': + optional: true + vue: + optional: true + vue-i18n: + optional: true + '@ioredis/commands@1.5.1': resolution: {integrity: sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==} @@ -1766,6 +1956,14 @@ packages: engines: {node: '>=18'} hasBin: true + '@miyaneee/rollup-plugin-json5@1.2.0': + resolution: {integrity: sha512-JjTIaXZp9WzhUHpElrqPnl1AzBi/rvRs065F71+aTmlqvTMVkdbjZ8vfFl4nRlgJy+TPBw69ZK4pwFdmOAt4aA==} + peerDependencies: + rollup: ^1.20.0 || ^2.0.0 || ^3.0.0 || ^4.0.0 + + '@napi-rs/wasm-runtime@0.2.12': + resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} + '@napi-rs/wasm-runtime@1.1.4': resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==} peerDependencies: @@ -1827,6 +2025,9 @@ packages: peerDependencies: vite: '>=6.0' + '@nuxt/fonts@0.11.4': + resolution: {integrity: sha512-GbLavsC+9FejVwY+KU4/wonJsKhcwOZx/eo4EuV57C4osnF/AtEmev8xqI0DNlebMEhEGZbu1MGwDDDYbeR7Bw==} + '@nuxt/fonts@0.14.0': resolution: {integrity: sha512-4uXQl9fa5F4ibdgU8zomoOcyMdnwgdem+Pi8JEqeDYI5yPR32Kam6HnuRr47dTb97CstaepAvXPWQUUHMtjsFQ==} @@ -1836,6 +2037,10 @@ packages: '@nuxt/icon@2.2.2': resolution: {integrity: sha512-K9wINW21M9x5GcKF5JEXzPKAT/Kfxl/vdnEyppw54hh5qoLcdi5HmsYoTfDP9gbJ6Z1T6IdH5JxBWk72HMe1Zg==} + '@nuxt/image@1.11.0': + resolution: {integrity: sha512-4kzhvb2tJfxMsa/JZeYn1sMiGbx2J/S6BQrQSdXNsHgSvywGVkFhTiQGjoP6O49EsXyAouJrer47hMeBcTcfXQ==} + engines: {node: '>=18.20.6'} + '@nuxt/kit@3.21.4': resolution: {integrity: sha512-XDWhQJsA5hpdFpVSmImQIVXcsANJI07TjT1LZC/AUKJxl/dcM52Rq4uU+b3uqyVl4LZR1fODSDEzLxcdXq4Rmg==} engines: {node: '>=18.12.0'} @@ -1915,6 +2120,10 @@ packages: '@nuxtjs/color-mode@3.5.2': resolution: {integrity: sha512-cC6RfgZh3guHBMLLjrBB2Uti5eUoGM9KyauOaYS9ETmxNWBMTvpgjvSiSJp1OFljIXPIqVTJ3xtJpSNZiO3ZaA==} + '@nuxtjs/i18n@9.5.6': + resolution: {integrity: sha512-PhrQtJT6Di9uoslL5BTrBFqntFlfCaUKlO3T9ORJwmWFdowPqQeFjQ9OjVbKA6TNWr3kQhDqLbIcGlhbuG1USQ==} + engines: {node: '>=18.12.0'} + '@nuxtjs/supabase@2.0.6': resolution: {integrity: sha512-w0KSh4OKOxAkX5Dg6RfwyrG71HbCqXMfELHSKgOcl1MdO1Okwv+SItWD3mezc6q4J0tKtb0pw2KZDQ0F3FPIdA==} @@ -2031,6 +2240,12 @@ packages: cpu: [arm64] os: [darwin] + '@oxc-parser/binding-darwin-arm64@0.70.0': + resolution: {integrity: sha512-pIi7L9PnsBctS/ruW6JQVSYRJkh76PblBN46uQxpBfVsM57c1s4HGZlmGysQWbdmQTFDZW+SmH3u0JpmDLF0+A==} + engines: {node: '>=14.0.0'} + cpu: [arm64] + os: [darwin] + '@oxc-parser/binding-darwin-arm64@0.94.0': resolution: {integrity: sha512-uYyeMH9vMfb0JAdm6ZwHTgcTv53030elQKMnUbux9K5rxOCWbHUyeVACEv86V+E/Ft6RtkvWDIqUY4sYZRmcuQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2043,6 +2258,12 @@ packages: cpu: [x64] os: [darwin] + '@oxc-parser/binding-darwin-x64@0.70.0': + resolution: {integrity: sha512-EbKqtOHzZR56ZFC5HHg6XrYneFAJmpLC1Z6FSgbI061Ley1atAViQg7S6Agm9wAcPpns+BeFJqXEBx/y3MKa2w==} + engines: {node: '>=14.0.0'} + cpu: [x64] + os: [darwin] + '@oxc-parser/binding-darwin-x64@0.94.0': resolution: {integrity: sha512-Ek1fh8dw6b+/hzLo5jjPuxkshRxekjtTfhfWZ4RehMYiApT8Rj4k+7kcQ+zV1ZaF+1+yLgNqNja2RMRqx3MHzQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2055,6 +2276,12 @@ packages: cpu: [x64] os: [freebsd] + '@oxc-parser/binding-freebsd-x64@0.70.0': + resolution: {integrity: sha512-MVUaOMEUVE8q3nsWtEo589h++V5wAdqTbCRa9WY4Yuyxska4xcuJQk/kDNCx+n92saS7Luk+b20O9+VCI03c+A==} + engines: {node: '>=14.0.0'} + cpu: [x64] + os: [freebsd] + '@oxc-parser/binding-freebsd-x64@0.94.0': resolution: {integrity: sha512-81bE/8F252Ew179uVo9FU67dmRc+n8QSMhj6mmMxisdI3ao5MjCI5jDL19mH3UeQ9uRUBSPFILmHBDQYNZ9oKw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2067,6 +2294,12 @@ packages: cpu: [arm] os: [linux] + '@oxc-parser/binding-linux-arm-gnueabihf@0.70.0': + resolution: {integrity: sha512-8N4JTYTgKiRHlMUDAdzKs6iEC57a8ex408VgKoLD/Fl+Un79qOti3S9sotdnWSdH/BsDQeO5NW+PKaqFBTw+hA==} + engines: {node: '>=14.0.0'} + cpu: [arm] + os: [linux] + '@oxc-parser/binding-linux-arm-gnueabihf@0.94.0': resolution: {integrity: sha512-aGOU8IYXVYGN2aRrvcU5+UdM7BzIVlm4m0REQzjpblQKRdZfWFtDBRJez+fK/F10g0H1AU5DQVgbW5aeko49Jw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2079,6 +2312,12 @@ packages: cpu: [arm] os: [linux] + '@oxc-parser/binding-linux-arm-musleabihf@0.70.0': + resolution: {integrity: sha512-Bsu+YvtgWuSfSDJTHMF5APZBOtvddR0GiHyrL0yaXDwaYvAL/E7XcoSK2GdmKTpw+J8nk5IlejEXlQliPo52pQ==} + engines: {node: '>=14.0.0'} + cpu: [arm] + os: [linux] + '@oxc-parser/binding-linux-arm-musleabihf@0.94.0': resolution: {integrity: sha512-69/ZuYSZ4dd7UWoEOyf+pXYPtvUZguDQqjhxMx8fI0J30sEEqs1d/DBLLnog/afHmaapPEIEr6rp9jF6bYcgNw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2091,6 +2330,12 @@ packages: cpu: [arm64] os: [linux] + '@oxc-parser/binding-linux-arm64-gnu@0.70.0': + resolution: {integrity: sha512-tDzHWKexJPHR+qSiuAFoZ1v8EgCd4ggBNbjJHkcIHsoYKnsKaT1+uE9xfW9UhI1mhv2lo1JJ9n9og2yDTGxSeA==} + engines: {node: '>=14.0.0'} + cpu: [arm64] + os: [linux] + '@oxc-parser/binding-linux-arm64-gnu@0.94.0': resolution: {integrity: sha512-u55PGVVfZF/frpEcv/vowfuqsCd5VKz3wta8KZ3MBxboat7XxgRIMS8VQEBiJ3aYE80taACu5EfPN1y9DhiU0Q==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2103,6 +2348,12 @@ packages: cpu: [arm64] os: [linux] + '@oxc-parser/binding-linux-arm64-musl@0.70.0': + resolution: {integrity: sha512-BJ+N25UWmHU624558ojSTnht3uFL00jV1c8qk1hnKf4cl6+ovFcoktRWAWSBlgLEP8tLlu8qgIhz875tMj2PkQ==} + engines: {node: '>=14.0.0'} + cpu: [arm64] + os: [linux] + '@oxc-parser/binding-linux-arm64-musl@0.94.0': resolution: {integrity: sha512-Qm2SEU7/f2b2Rg76Pj49BdMFF7Vv7+2qLPxaae4aH1515kzVv6nZW0bqCo4fPDDyiE4bryF7Jr+WKhllBxvXPw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2121,6 +2372,12 @@ packages: cpu: [riscv64] os: [linux] + '@oxc-parser/binding-linux-riscv64-gnu@0.70.0': + resolution: {integrity: sha512-nxu22nVuPA2xy1cxvBC0D5mVl0myqStOw3XBkVkDViNL01iPyuEFJd5VsM0GqsgrXvF95H/jrbMd+XWnto924g==} + engines: {node: '>=14.0.0'} + cpu: [riscv64] + os: [linux] + '@oxc-parser/binding-linux-riscv64-gnu@0.94.0': resolution: {integrity: sha512-bZO3QAt0lsZjk351mVM85obMivbXG+tDiah5XmmOaGO8k4vEYmoiKr2YHJoA2eNpKhPJF8dNyIS7U+XAvirr9g==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2139,6 +2396,12 @@ packages: cpu: [s390x] os: [linux] + '@oxc-parser/binding-linux-s390x-gnu@0.70.0': + resolution: {integrity: sha512-AQ6Xj97lYRxHZl94cZIHJxT5M1qkeEi+vQe+e7M2lAtjcURl8cwhZmWKSv4rt4BQRVfO3ys0bY8AgIh4eFJiqw==} + engines: {node: '>=14.0.0'} + cpu: [s390x] + os: [linux] + '@oxc-parser/binding-linux-s390x-gnu@0.94.0': resolution: {integrity: sha512-IdbJ/rwsaEPQx11mQwGoClqhAmVaAF9+3VmDRYVmfsYsrhX1Ue1HvBdVHDvtHzJDuumC/X/codkVId9Ss+7fVg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2151,6 +2414,12 @@ packages: cpu: [x64] os: [linux] + '@oxc-parser/binding-linux-x64-gnu@0.70.0': + resolution: {integrity: sha512-RIxaVsIxtG90CoX6/Okij8itaMrJp4SEJm1pSL0pz3hGo0yur3Il9M1mmGvOpW+avY8uHdwXIvf2qMnnTKZuoQ==} + engines: {node: '>=14.0.0'} + cpu: [x64] + os: [linux] + '@oxc-parser/binding-linux-x64-gnu@0.94.0': resolution: {integrity: sha512-TbtpRdViF3aPCQBKuEo+TcucwW3KFa6bMHVakgaJu12RZrFpO4h1IWppBbuuBQ9X7SfvpgC1YgCDGve9q6fpEA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2163,6 +2432,12 @@ packages: cpu: [x64] os: [linux] + '@oxc-parser/binding-linux-x64-musl@0.70.0': + resolution: {integrity: sha512-B3S0G4TlZ+WLdQq4mSQtt2ZW0MAkKWc8dla17tZY86kcXvvCWwACvj7I27Z/nSlb7uJOdRZS9/r6Gw0uAARNVQ==} + engines: {node: '>=14.0.0'} + cpu: [x64] + os: [linux] + '@oxc-parser/binding-linux-x64-musl@0.94.0': resolution: {integrity: sha512-hlfoDmWvgSbexoJ9u3KwAJwpeu91FfJR6++fQjeYXD2InK4gZow9o3DRoTpN/kslZwzUNpiRURqxey/RvWh8JQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2180,6 +2455,11 @@ packages: engines: {node: '>=14.0.0'} cpu: [wasm32] + '@oxc-parser/binding-wasm32-wasi@0.70.0': + resolution: {integrity: sha512-QN8yxH7eHXTqed8Oo7ZUzOWn6hixXa8EVINLy21eLU9isoifSPKMswSmCXHxsM2L5rIIvzoaKfghGOru1mMQbw==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + '@oxc-parser/binding-wasm32-wasi@0.94.0': resolution: {integrity: sha512-VoCtQZIsRZN8mszbdizh+5MwzbgbMxsPgT2hOzzILQLNY2o2OXG3xSiFNFakVhbWc9qSTaZ/MRDsqR+IM3fLFw==} engines: {node: '>=14.0.0'} @@ -2191,6 +2471,12 @@ packages: cpu: [arm64] os: [win32] + '@oxc-parser/binding-win32-arm64-msvc@0.70.0': + resolution: {integrity: sha512-6k8/s78g0GQKqrxk4F0wYj32NBF9oSP6089e6BeuIRQ9l+Zh0cuI6unJeLzXNszxmlqq84xmf/tmP3MSDG43Uw==} + engines: {node: '>=14.0.0'} + cpu: [arm64] + os: [win32] + '@oxc-parser/binding-win32-arm64-msvc@0.94.0': resolution: {integrity: sha512-3wsbMqV8V7WaLdiQ2oawdgKkCgMHXJ7VDuo6uIcXauU3wK6CG0QyDXRV9bPWzorGLRBUHndu/2VB1+9dgT9fvg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2209,15 +2495,31 @@ packages: cpu: [x64] os: [win32] + '@oxc-parser/binding-win32-x64-msvc@0.70.0': + resolution: {integrity: sha512-nd9o1QtEvupaJZ3Wn7PfsuC00n31NNRQZ5+Mui6Q0ZyDzp+obqPUSbSt7xh9Dy0c5zgtYMk8WY4n/VBJY2VvTQ==} + engines: {node: '>=14.0.0'} + cpu: [x64] + os: [win32] + '@oxc-parser/binding-win32-x64-msvc@0.94.0': resolution: {integrity: sha512-UTQQ1576Nzhh4jr/YmvzqnuwTPOauB/TPzsnWzT+w8InHxL5JA1fmy01wB1F2BWT9AD6YV4BTB1ozRICYdAgjw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] + '@oxc-parser/wasm@0.60.0': + resolution: {integrity: sha512-Dkf9/D87WGBCW3L0+1DtpAfL4SrNsgeRvxwjpKCtbH7Kf6K+pxrT0IridaJfmWKu1Ml+fDvj+7HEyBcfUC/TXQ==} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + '@oxc-project/types@0.126.0': resolution: {integrity: sha512-oGfVtjAgwQVVpfBrbtk4e1XDyWHRFta6BS3GWVzrF8xYBT2VGQAk39yJS/wFSMrZqoiCU4oghT3Ch0HaHGIHcQ==} + '@oxc-project/types@0.60.0': + resolution: {integrity: sha512-prhfNnb3ATFHOCv7mzKFfwLij5RzoUz6Y1n525ZhCEqfq5wreCXL+DyVoq3ShukPo7q45ZjYIdjFUgjj+WKzng==} + + '@oxc-project/types@0.70.0': + resolution: {integrity: sha512-ngyLUpUjO3dpqygSRQDx7nMx8+BmXbWOU4oIwTJFV2MVIDG7knIZwgdwXlQWLg3C3oxg1lS7ppMtPKqKFb7wzw==} + '@oxc-project/types@0.94.0': resolution: {integrity: sha512-+UgQT/4o59cZfH6Cp7G0hwmqEQ0wE+AdIwhikdwnhWI9Dp8CgSY081+Q3O67/wq3VJu8mgUEB93J9EHHn70fOw==} @@ -2983,6 +3285,15 @@ packages: rollup: optional: true + '@rollup/plugin-yaml@4.1.2': + resolution: {integrity: sha512-RpupciIeZMUqhgFE97ba0s98mOFS7CWzN3EJNhJkqSv9XLlWYtwVdtE6cDw6ASOF/sZVFS7kRJXftaqM2Vakdw==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^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'} @@ -3535,6 +3846,9 @@ packages: '@types/babel__traverse@7.28.0': resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/esrecurse@4.3.1': + resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} + '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} @@ -3558,6 +3872,9 @@ packages: '@types/istanbul-reports@3.0.4': resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==} + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/node-fetch@2.6.13': resolution: {integrity: sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==} @@ -3594,6 +3911,36 @@ packages: '@types/yargs@17.0.35': resolution: {integrity: sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==} + '@typescript-eslint/project-service@8.59.2': + resolution: {integrity: sha512-+2hqvEkeyf/0FBor67duF0Ll7Ot8jyKzDQOSrxazF/danillRq2DwR9dLptsXpoZQqxE1UisSmoZewrlPas9Vw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/scope-manager@8.59.2': + resolution: {integrity: sha512-JzfyEpEtOU89CcFSwyNS3mu4MLvLSXqnmX05+aKBDM+TdR5jzcGOEBwxwGNxrEQ7p/z6kK2WyioCGBf2zZBnvg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.59.2': + resolution: {integrity: sha512-BKK4alN7oi4C/zv4VqHQ+uRU+lTa6JGIZ7s1juw7b3RHo9OfKB+bKX3u0iVZetdsUCBBkSbdWbarJbmN0fTeSw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/types@8.59.2': + resolution: {integrity: sha512-e82GVOE8Ps3E++Egvb6Y3Dw0S10u8NkQ9KXmtRhCWJJ8kDhOJTvtMAWnFL16kB1583goCWXsr0NieKCZMs2/0Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.59.2': + resolution: {integrity: sha512-o0XPGNwcWw+FIwStOWn+BwBuEmL6QXP0rsvAFg7ET1dey1Nr6Wb1ac8p5HEsK0ygO/6mUxlk+YWQD9xcb/nnXg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/visitor-keys@8.59.2': + resolution: {integrity: sha512-NwjLUnGy8/Zfx23fl50tRC8rYaYnM52xNRYFAXvmiil9yh1+K6aRVQMnzW6gQB/1DLgWt977lYQn7C+wtgXZiA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@ungap/structured-clone@1.3.1': resolution: {integrity: sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==} @@ -3692,6 +4039,15 @@ packages: '@volar/source-map@2.4.28': resolution: {integrity: sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==} + '@vue-macros/common@1.16.1': + resolution: {integrity: sha512-Pn/AWMTjoMYuquepLZP813BIcq8DTZiNCoaceuNlvaYuOTd8DqBZWc5u0uOMQZMInwME1mdSmmBAcTluiV9Jtg==} + engines: {node: '>=16.14.0'} + peerDependencies: + vue: ^2.7.0 || ^3.2.25 + peerDependenciesMeta: + vue: + optional: true + '@vue-macros/common@3.0.0-beta.16': resolution: {integrity: sha512-8O2gWxWFiaoNkk7PGi0+p7NPGe/f8xJ3/INUufvje/RZOs7sJvlI1jnR4lydtRFa/mU0ylMXUXXjSK0fHDEYTA==} engines: {node: '>=20.18.0'} @@ -3777,6 +4133,11 @@ packages: '@vueuse/core@10.11.1': resolution: {integrity: sha512-guoy26JQktXPcz+0n3GukWIy/JDNKti9v6VEMu6kV2sYBsWuGiTU8OWdg+ADfUbHg3/3DlqySDe7JmdHrktiww==} + '@vueuse/core@13.9.0': + resolution: {integrity: sha512-ts3regBQyURfCE2BcytLqzm8+MmLlo5Ln/KLoxDVcsZ2gzIwVNnQpQOL/UKV8alUqjSZOlpFZcRNsLRqj+OzyA==} + peerDependencies: + vue: ^3.5.0 + '@vueuse/core@14.3.0': resolution: {integrity: sha512-aHfz47g0ZhMtTVHmIzMVpJy8ePhhOy68GY5bv110+5DVtZ+W7BsOx+m61UNQqfrWyPztIHIanWa3E2tib3NFIw==} peerDependencies: @@ -3827,9 +4188,17 @@ packages: '@vueuse/metadata@10.11.1': resolution: {integrity: sha512-IGa5FXd003Ug1qAZmyE8wF3sJ81xGLSqTqtQ6jaVfkeZ4i5kS2mwQF61yhVqojRnenVew5PldLyRgvdl4YYuSw==} + '@vueuse/metadata@13.9.0': + resolution: {integrity: sha512-1AFRvuiGphfF7yWixZa0KwjYH8ulyjDCC0aFgrGRz8+P4kvDFSdXLVfTk5xAN9wEuD1J6z4/myMoYbnHoX07zg==} + '@vueuse/metadata@14.3.0': resolution: {integrity: sha512-BwxmbAzwAVF50+MW57GXOUEV61nFBGnlBvrTqj49PqWJu3uw7hdu72ztXeZ33RdZtDY6kO+bfCAE1PCn88Tktw==} + '@vueuse/motion@3.0.3': + resolution: {integrity: sha512-4B+ITsxCI9cojikvrpaJcLXyq0spj3sdlzXjzesWdMRd99hhtFI6OJ/1JsqwtF73YooLe0hUn/xDR6qCtmn5GQ==} + peerDependencies: + vue: '>=3.0.0' + '@vueuse/nuxt@14.3.0': resolution: {integrity: sha512-Uxaz/DsNa3i7vHTSjZin5R17R5pt+MtpAifsfqhV1qiBZti1wYv+/S3xysCMHuuiWyLIbbignKxIsgG9ul5kEA==} peerDependencies: @@ -3839,6 +4208,11 @@ packages: '@vueuse/shared@10.11.1': resolution: {integrity: sha512-LHpC8711VFZlDaYUXEBbFBCQ7GS3dVU9mjOhhMhXP6txTV4EhYQg/KGnQuvt/sPAtoUKq7VVUnL6mVtFoL42sA==} + '@vueuse/shared@13.9.0': + resolution: {integrity: sha512-e89uuTLMh0U5cZ9iDpEI2senqPGfbPRTHM/0AaQkcxnpqjkZqDYP8rpfm7edOz8s+pOCOROEy1PIveSW8+fL5g==} + peerDependencies: + vue: ^3.5.0 + '@vueuse/shared@14.3.0': resolution: {integrity: sha512-bZpge9eSXwa4ToSiqJ7j6KRwhAsneMFoSz3LMWKQDkqimm3D/tbFlrklrs/IOqC8tEcYmXQZJ6N0UrjhBirVCg==} peerDependencies: @@ -3872,6 +4246,11 @@ packages: peerDependencies: acorn: ^8 + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + acorn@8.16.0: resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} engines: {node: '>=0.4.0'} @@ -3885,6 +4264,9 @@ packages: resolution: {integrity: sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==} engines: {node: '>= 8.0.0'} + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + ajv@8.20.0: resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} @@ -3971,10 +4353,18 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + ast-kit@1.4.3: + resolution: {integrity: sha512-MdJqjpodkS5J149zN0Po+HPshkTdUyrvF7CKTafUgv69vBSPtncrj+3IiUgqdd7ElIEkbeXCsEouBUwLrw9Ilg==} + engines: {node: '>=16.14.0'} + ast-kit@2.2.0: resolution: {integrity: sha512-m1Q/RaVOnTp9JxPX+F+Zn7IcLYMzM8kZofDImfsKZd8MbR+ikdOzTeztStWqfrqIxZnYWryyI9ePm3NGjnZgGw==} engines: {node: '>=20.19.0'} + ast-walker-scope@0.6.2: + resolution: {integrity: sha512-1UWOyC50xI3QZkRuDj6PqDtpm1oHWtYs+NQGwqL/2R11eN3Q81PHAHPM0SWW3BNQm53UDwS//Jv8L4CCVLM1bQ==} + engines: {node: '>=16.14.0'} + ast-walker-scope@0.8.3: resolution: {integrity: sha512-cbdCP0PGOBq0ASG+sjnKIoYkWMKhhz+F/h9pRexUdX2Hd38+WOlBkRKlqkGOSm0YQpcFMQBJeK4WspUAkwsEdg==} engines: {node: '>=20.19.0'} @@ -4165,6 +4555,12 @@ packages: birpc@4.0.0: resolution: {integrity: sha512-LShSxJP0KTmd101b6DRyGBj57LZxSDYWKitQNW/mi8GRMvZb078Uf9+pveax1DrVL89vm7mWe+TovdI/UDOuPw==} + bl@4.1.0: + resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + + blob-to-buffer@1.2.9: + resolution: {integrity: sha512-BF033y5fN6OCofD3vgHmNtwZWRcq9NLyyxyILx9hfMy1sXYy4ojFl765hJ2lP0YaN2fuxPaLO2Vzzoxy0FLFFA==} + bole@5.0.29: resolution: {integrity: sha512-eYR9i2ubLv5/4TFGyZsQ1cVH4jF9+qLJA72Aow+E7ZZQfqHqQNUZeX3w+pVWF76PQyjl5eDKf2xylyOOX76ozA==} @@ -4196,6 +4592,9 @@ packages: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} + brotli@1.3.3: + resolution: {integrity: sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==} + browserslist@4.28.2: resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} @@ -4303,6 +4702,9 @@ packages: resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} engines: {node: '>= 20.19.0'} + chownr@1.1.4: + resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + chownr@3.0.0: resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} engines: {node: '>=18'} @@ -4351,6 +4753,10 @@ packages: resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} engines: {node: '>=0.8'} + clone@2.1.2: + resolution: {integrity: sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==} + engines: {node: '>=0.8'} + cluster-key-slot@1.1.2: resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} engines: {node: '>=0.10.0'} @@ -4479,6 +4885,9 @@ packages: resolution: {integrity: sha512-ixNtAJndqh173VQ4KodSdJEI6nuioBWI0V1ITNKhZZsO0pEMoDxz539T4FTTbSZ/xIOSuDnzxLVRqBVSvPNE2g==} engines: {node: '>=18.0'} + cross-fetch@3.2.0: + resolution: {integrity: sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -4503,6 +4912,10 @@ packages: resolution: {integrity: sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==} engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} + css-tree@2.3.1: + resolution: {integrity: sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + css-tree@3.2.1: resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} @@ -4516,6 +4929,9 @@ packages: engines: {node: '>=4'} hasBin: true + cssfilter@0.0.10: + resolution: {integrity: sha512-FAaLDaplstoRsDR8XGYH51znUN0UY7nMc6Z9/fvE8EXGwvJE9hu7W2vHwx1+bd6gCYnln9nLbzxFTrcO9YQDZw==} + cssnano-preset-default@7.0.17: resolution: {integrity: sha512-11qO63A+czwguQFJCaTdICvbaxn0pJzz/XghLlv+OT7WyToDxAMR0Xb3/26/l0y0hQJywwNbj/SLSQlGBHE1OA==} engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} @@ -4601,6 +5017,10 @@ packages: resolution: {integrity: sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==} engines: {node: '>=0.10'} + decompress-response@6.0.0: + resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} + engines: {node: '>=10'} + deep-eql@5.0.2: resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} engines: {node: '>=6'} @@ -4609,6 +5029,9 @@ packages: resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} engines: {node: '>=4.0.0'} + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + deepmerge-ts@7.1.5: resolution: {integrity: sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==} engines: {node: '>=16.0.0'} @@ -4689,6 +5112,9 @@ packages: '@modelcontextprotocol/sdk': optional: true + dfa@1.2.0: + resolution: {integrity: sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==} + didyoumean@1.2.2: resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} @@ -4820,6 +5246,9 @@ packages: resolution: {integrity: sha512-EuJWwlHPZ1LbADuKTClvHtwbaFn4rOD+dRAbWysqEOXRc2Uui0hJInNJrsdH0c+OhJA4nrCBdSkW4DD5YxAo6A==} engines: {node: '>=8.10.0'} + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + enhanced-resolve@5.21.2: resolution: {integrity: sha512-xe9vQb5kReirPUxgQrXA3ihgbCqssmTiM7cOZ+Gzu+VeGWgpV98lLZvp0dl4yriyAePcewxGUs9UpKD8PET9KQ==} engines: {node: '>=10.13.0'} @@ -4917,17 +5346,68 @@ packages: resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} engines: {node: '>=12'} + escodegen@2.1.0: + resolution: {integrity: sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==} + engines: {node: '>=6.0'} + hasBin: true + + eslint-scope@9.1.2: + resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@10.3.0: + resolution: {integrity: sha512-XbEXaRva5cF0ZQB8w6MluHA0kZZfV2DuCMJ3ozyEOHLwDpZX2Lmm/7Pp0xdJmI0GL1W05VH5VwIFHEm1Vcw2gw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@11.2.0: + resolution: {integrity: sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + espree@9.6.1: + resolution: {integrity: sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + esprima@4.0.1: resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} engines: {node: '>=4'} hasBin: true + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + estree-walker@2.0.2: resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + etag@1.8.1: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} @@ -4947,6 +5427,10 @@ packages: resolution: {integrity: sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==} engines: {node: '>=16.17'} + expand-template@2.0.3: + resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} + engines: {node: '>=6'} + expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} @@ -5197,6 +5681,9 @@ packages: fast-json-stable-stringify@2.1.0: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-npm-meta@0.4.8: resolution: {integrity: sha512-ybZVlDZ2PkO79dosM+6CLZfKWRH8MF0PiWlw8M4mVWJl8IEJrPfxYc7Tsu830Dwj/R96LKXfePGTSzKWbPJ08w==} @@ -5234,6 +5721,10 @@ packages: picomatch: optional: true + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + file-uri-to-path@1.0.0: resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} @@ -5253,9 +5744,23 @@ packages: resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} engines: {node: '>=8'} + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + flow-enums-runtime@0.0.6: resolution: {integrity: sha512-3PYnM29RFXwvAN6Pc/scUfkI7RwhQ/xqyLUyPNlXUp9S40zI8nup9tUSrTLSVnWGBN38FNiGWbwZOB6uR4OGdw==} + fontaine@0.6.0: + resolution: {integrity: sha512-cfKqzB62GmztJhwJ0YXtzNsmpqKAcFzTqsakJ//5COTzbou90LU7So18U+4D8z+lDXr4uztaAUZBonSoPDcj1w==} + fontaine@0.8.0: resolution: {integrity: sha512-eek1GbzOdWIj9FyQH/emqW1aEdfC3lYRCHepzwlFCm5T77fBSRSyNRKE6/antF1/B1M+SfJXVRQTY9GAr7lnDg==} engines: {node: '>=18.12.0'} @@ -5263,6 +5768,9 @@ packages: fontfaceobserver@2.3.0: resolution: {integrity: sha512-6FPvD/IVyT4ZlNe7Wcn5Fb/4ChigpucKYSvD6a+0iMoLn2inpo711eyIcKjmDtE5XNcgAkSH9uN/nfAeZzHEfg==} + fontkit@2.0.4: + resolution: {integrity: sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==} + fontkitten@1.0.3: resolution: {integrity: sha512-Wp1zXWPVUPBmfoa3Cqc9ctaKuzKAV6uLstRqlR56kSjplf5uAce+qeyYym7F+PHbGTk+tCEdkCW6RD7DX/gBZw==} engines: {node: '>=20'} @@ -5312,6 +5820,9 @@ packages: react-dom: optional: true + framesync@6.1.2: + resolution: {integrity: sha512-jBTqhX6KaQVDyus8muwZbBeGGP0XgujBRbQ7gM7BRdS3CadCZIHiawyzYLnafYcvZIh5j8WE7cxZKFn7dXhu9g==} + freeport-async@2.0.0: resolution: {integrity: sha512-K7od3Uw45AJg00XUmy15+Hae2hOcgKcmN3/EF6Y7i01O0gaqiRx8sUSpsb9+BRNL8RPBrhzPsVfy8q9ADlJuWQ==} engines: {node: '>=8'} @@ -5324,6 +5835,9 @@ packages: resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} engines: {node: '>= 0.8'} + fs-constants@1.0.0: + resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} @@ -5392,6 +5906,9 @@ packages: resolution: {integrity: sha512-GvHTWcykIR/fP8cj8dMpuMMkvaeJfPvYnhq0oW+chSeIr+ldX21ifU2Ms6KBoyKZQZmVaUAAhQ2EZ68KJF8a7A==} hasBin: true + github-from-package@0.0.0: + resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} + glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -5615,6 +6132,10 @@ packages: resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} engines: {node: '>= 12'} + ipx@2.1.1: + resolution: {integrity: sha512-XuM9FEGOT+/45mfAWZ5ykwkZ/oE7vWpd1iWjRffMWlwAYIRzb/xD6wZhQ4BzmPMX6Ov5dqK0wUyD0OEN9oWT6g==} + hasBin: true + iron-webcrypto@1.2.1: resolution: {integrity: sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==} @@ -5848,17 +6369,33 @@ packages: engines: {node: '>=6'} hasBin: true + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + json-parse-even-better-errors@2.3.1: resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + json5@2.2.3: resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} engines: {node: '>=6'} hasBin: true + jsonc-eslint-parser@2.4.2: + resolution: {integrity: sha512-1e4qoRgnn448pRuMvKGsFFymUCquZV0mpGgOyIKNgD3JVDTsVJyRBGH/Fm0tBb8WsWGgmB1mDe6/yJMQM37DUA==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + kleur@3.0.3: resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} engines: {node: '>=6'} @@ -5895,6 +6432,10 @@ packages: resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} engines: {node: '>=6'} + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + lib0@0.2.117: resolution: {integrity: sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw==} engines: {node: '>=16'} @@ -6068,6 +6609,10 @@ packages: 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==} @@ -6136,6 +6681,10 @@ packages: magic-regexp@0.10.0: resolution: {integrity: sha512-Uly1Bu4lO1hwHUW0CQeSWuRtzCMNO00CmXtS8N6fyvB3B979GOEEeAkiTUDsmbYLAbvpUS/Kt5c4ibosAzVyVg==} + magic-string-ast@0.7.1: + resolution: {integrity: sha512-ub9iytsEbT7Yw/Pd29mSo/cNQpaEu67zR1VVcXDiYjSFwzeBxNdTd0FMnSslLQXiRj8uGPzwsaoefrMD5XAmdw==} + engines: {node: '>=16.14.0'} + magic-string-ast@1.0.3: resolution: {integrity: sha512-CvkkH1i81zl7mmb94DsRiFeG9V2fR2JeuK8yDgS8oiZSFa++wWLEgZ5ufEOyLHbvSbD1gTRKv9NdX69Rnvr9JA==} engines: {node: '>=20.19.0'} @@ -6174,6 +6723,9 @@ packages: mdn-data@2.0.28: resolution: {integrity: sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==} + mdn-data@2.0.30: + resolution: {integrity: sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==} + mdn-data@2.27.1: resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} @@ -6287,6 +6839,10 @@ packages: resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==} engines: {node: '>=12'} + mimic-response@3.1.0: + resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} + engines: {node: '>=10'} + minimatch@10.2.5: resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} engines: {node: 18 || 20 || >=22} @@ -6316,6 +6872,9 @@ packages: mitt@3.0.1: resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==} + mkdirp-classic@0.5.3: + resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} + mkdirp@1.0.4: resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} engines: {node: '>=10'} @@ -6380,12 +6939,18 @@ packages: nanotar@0.2.1: resolution: {integrity: sha512-MUrzzDUcIOPbv7ubhDV/L4CIfVTATd9XhDE2ixFeCrM5yp9AlzUpn91JrnN0HD6hksdxvz9IW9aKANz0Bta0GA==} + napi-build-utils@2.0.0: + resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} + nativewind@4.2.3: resolution: {integrity: sha512-HglF1v6A8CqBFpXWs0d3yf4qQGurrreLuyE8FTRI/VDH8b0npZa2SDG5tviTkLiBg0s5j09mQALZOjxuocgMLA==} engines: {node: '>=16'} peerDependencies: tailwindcss: '>3.3.0' + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + negotiator@0.6.3: resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} engines: {node: '>= 0.6'} @@ -6407,6 +6972,13 @@ packages: xml2js: optional: true + node-abi@3.92.0: + resolution: {integrity: sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==} + engines: {node: '>=10'} + + node-addon-api@6.1.0: + resolution: {integrity: sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==} + node-addon-api@7.1.1: resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} @@ -6592,6 +7164,10 @@ packages: zod: optional: true + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + ora@3.4.0: resolution: {integrity: sha512-eNwHudNbO1folBP3JsZ19v9azXWtQZjICdr3Q0TDPIaeBQ3mXLrh54wM+er0+hSp+dWKf+Z8KM58CYzEyIYxYg==} engines: {node: '>=6'} @@ -6607,6 +7183,10 @@ packages: resolution: {integrity: sha512-FktCvLby/mOHyuijZt22+nOt10dS24gGUZE3XwIbUg7Kf4+rer3/5T7RgwzazlNuVsCjPloZ3p8E+4ONT3A8Kw==} engines: {node: ^20.19.0 || >=22.12.0} + oxc-parser@0.70.0: + resolution: {integrity: sha512-YbqTuQDDIYwQF/li0VFK5uTbmHV4jWFeQQONkPdf77vz+JMiq7SusmcSVZ4hBrGM+3WyLdKH5S7spnvz4XVVzQ==} + engines: {node: '>=14.0.0'} + oxc-parser@0.94.0: resolution: {integrity: sha512-refms9HQoAlTYIazONYkuX5A3rFGPddbD6Otyc+A0/pj1WTttR8TsZRlMzQxCfhexxfrbinqd7ebkEoYNuCmLQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -6636,6 +7216,10 @@ packages: 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'} @@ -6646,6 +7230,9 @@ packages: package-manager-detector@1.6.0: resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} + pako@0.2.9: + resolution: {integrity: sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==} + parse-json@5.2.0: resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} engines: {node: '>=8'} @@ -6787,6 +7374,9 @@ packages: resolution: {integrity: sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w==} engines: {node: '>=4.0.0'} + popmotion@11.0.5: + resolution: {integrity: sha512-la8gPM1WYeFznb/JqF4GiTkRRPZsfaj2+kCxqQgr2MJylMmIKUwBfWW8Wa5fml/8gmtlD5yI01MP1QCZPWmppA==} + possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} @@ -7036,6 +7626,16 @@ packages: resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==} engines: {node: '>=20'} + prebuild-install@7.1.3: + resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} + engines: {node: '>=10'} + deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available. + hasBin: true + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + prettier@3.8.3: resolution: {integrity: sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==} engines: {node: '>=14'} @@ -7135,6 +7735,9 @@ packages: engines: {node: '>=18'} hasBin: true + pump@3.0.4: + resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -7268,6 +7871,13 @@ packages: react: '*' react-native: '*' + react-native-keyboard-controller@1.21.7: + resolution: {integrity: sha512-gs+8nI8HYnRdDt4NWbk1iVuS6kDLf2taJvp+h/TjM1FBdtnQmlYLJ6buNiUqSnkIH4OFEAxdNr3/GOOYdLfkUQ==} + peerDependencies: + react: '*' + react-native: '*' + react-native-reanimated: '>=3.0.0' + react-native-mmkv@3.3.3: resolution: {integrity: sha512-GMsfOmNzx0p5+CtrCFRVtpOOMYNJXuksBVARSQrCFaZwjUyHJdQzcN900GGaFFNTxw2fs8s5Xje//RDKj9+PZA==} peerDependencies: @@ -7376,6 +7986,10 @@ packages: readable-stream@2.3.8: resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + readable-stream@4.7.0: resolution: {integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -7479,6 +8093,9 @@ packages: resolution: {integrity: sha512-6IzJLuGi4+R14vwagDHX+JrXmPVtPpn4mffDJ1UdR7/Edm87fl6yi8mMBIVvFtJaNTUvjughmW4hwLhRG7gC1Q==} engines: {node: '>=4'} + restructure@3.0.2: + resolution: {integrity: sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==} + retry@0.12.0: resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} engines: {node: '>= 4'} @@ -7646,6 +8263,10 @@ packages: shallowequal@1.1.0: resolution: {integrity: sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==} + sharp@0.32.6: + resolution: {integrity: sha512-KyLTWwgcR9Oe4d9HwCwNM2l7+J0dUQwn/yf7S0EnTtb0eVS4RxO0eUSvxPtzT4F3SY+C4K6fqdv/DO27sJ/v/w==} + engines: {node: '>=14.15.0'} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -7684,6 +8305,12 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + simple-concat@1.0.1: + resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} + + simple-get@4.0.1: + resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} + simple-git@3.36.0: resolution: {integrity: sha512-cGQjLjK8bxJw4QuYT7gxHw3/IouVESbhahSsHrX97MzCL1gu2u7oy38W6L2ZIGECEfIBG4BabsWDPjBxJENv9Q==} @@ -7873,6 +8500,9 @@ packages: structured-headers@0.4.1: resolution: {integrity: sha512-0MP/Cxx5SzeeZ10p/bZI0S6MpgD+yxAhi1BOQ34jgnMXsCq3j1t6tQnZu+KdlL7dvJTLT3g9xN8tl10TqgFMcg==} + style-value-types@5.1.2: + resolution: {integrity: sha512-Vs9fNreYF9j6W2VvuDTP7kepALi7sk0xtk2Tu8Yxi9UoajJdEVpNpCov0HsLTqXvNGKX+Uv09pkozVITi1jf3Q==} + stylehacks@7.0.11: resolution: {integrity: sha512-iODNfhXVLqc5LADs+Y6Oh5wJuK5ZcHbVng8aiK3y9pjMQdc5hLrBW0eFU6FtnpNrE6PoEg/MmFTU4waotj5WNg==} engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} @@ -7912,6 +8542,11 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + svgo@3.3.3: + resolution: {integrity: sha512-+wn7I4p7YgJhHs38k2TNjy1vCfPIfLIJWR5MnCStsN8WuuTcBnRKcMHQLMM2ijxGZmDoZwNv8ipl5aTTen62ng==} + engines: {node: '>=14.0.0'} + hasBin: true + svgo@4.0.1: resolution: {integrity: sha512-XDpWUOPC6FEibaLzjfe0ucaV0YrOjYotGJO1WpF0Zd+n6ZGEQUsSugaoLq9QkEZtAfQIxT42UChcssDVPP3+/w==} engines: {node: '>=16'} @@ -7946,6 +8581,16 @@ packages: resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} engines: {node: '>=6'} + tar-fs@2.1.4: + resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==} + + tar-fs@3.1.2: + resolution: {integrity: sha512-QGxxTxxyleAdyM3kpFs14ymbYmNFrfY+pHj7Z8FgtbZ7w2//VAgLMac7sT6nRpIHjppXO2AwwEOg0bPFVRcmXw==} + + tar-stream@2.2.0: + resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} + engines: {node: '>=6'} + tar-stream@3.2.0: resolution: {integrity: sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==} @@ -8037,6 +8682,10 @@ packages: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} + tosource@2.0.0-alpha.3: + resolution: {integrity: sha512-KAB2lrSS48y91MzFPFuDg4hLbvDiyTjOVgaK7Erw+5AmZXNq4sFRVn8r6yxSLuNs15PaokrDRpS61ERY9uZOug==} + engines: {node: '>=10'} + totalist@3.0.1: resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} engines: {node: '>=6'} @@ -8044,12 +8693,28 @@ packages: tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + tslib@2.4.0: + resolution: {integrity: sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tunnel-agent@0.6.0: + resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + type-detect@4.0.8: resolution: {integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==} engines: {node: '>=4'} @@ -8128,10 +8793,16 @@ packages: resolution: {integrity: sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg==} engines: {node: '>=4'} + unicode-properties@1.4.1: + resolution: {integrity: sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==} + unicode-property-aliases-ecmascript@2.2.0: resolution: {integrity: sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==} engines: {node: '>=4'} + unicode-trie@2.0.0: + resolution: {integrity: sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==} + unicorn-magic@0.3.0: resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} engines: {node: '>=18'} @@ -8140,6 +8811,9 @@ packages: resolution: {integrity: sha512-wH590V9VNgYH9g3lH9wWjTrUoKsjLF6sGLjhR4sH1LWpLmCOH0Zf7PukhDA8BiS7KHe4oPNkcTHqYkj7SOGUOw==} engines: {node: '>=20'} + unifont@0.4.1: + resolution: {integrity: sha512-zKSY9qO8svWYns+FGKjyVdLvpGPwqmsCjeJLN1xndMiqxHWBAhoWDMYMG960MxeV48clBmG+fDP59dHY1VoZvg==} + unifont@0.7.4: resolution: {integrity: sha512-oHeis4/xl42HUIeHuNZRGEvxj5AaIKR+bHPNegRq5LV1gdc3jundpONbjglKpihmJf+dswygdMJn3eftGIMemg==} @@ -8190,6 +8864,15 @@ packages: '@nuxt/kit': optional: true + unplugin-vue-router@0.12.0: + resolution: {integrity: sha512-xjgheKU0MegvXQcy62GVea0LjyOdMxN0/QH+ijN29W62ZlMhG7o7K+0AYqfpprvPwpWtuRjiyC5jnV2SxWye2w==} + deprecated: 'Merged into vuejs/router. Migrate: https://router.vuejs.org/guide/migration/v4-to-v5.html' + peerDependencies: + vue-router: ^4.4.0 + peerDependenciesMeta: + vue-router: + optional: true + unplugin-vue-router@0.15.0: resolution: {integrity: sha512-PyGehCjd9Ny9h+Uer4McbBjjib3lHihcyUEILa7pHKl6+rh8N7sFyw4ZkV+N30Oq2zmIUG7iKs3qpL0r+gXAaQ==} deprecated: 'Merged into vuejs/router. Migrate: https://router.vuejs.org/guide/migration/v4-to-v5.html' @@ -8200,6 +8883,10 @@ packages: vue-router: optional: true + unplugin@1.16.1: + resolution: {integrity: sha512-4/u/j4FrCKdi17jaxuJA0jClGxB1AvU2hw/IuayPc4ay1XGaJs/rbb4v5WKwAjNifjmXK9PIFyuPiaK8azyR9w==} + engines: {node: '>=14.0.0'} + unplugin@2.3.11: resolution: {integrity: sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==} engines: {node: '>=18.12.0'} @@ -8290,6 +8977,9 @@ packages: uqr@0.1.3: resolution: {integrity: sha512-0rjE8iEJe4YmT9TOhwsZtqCMRLc5DXZUI2UEYUUg63ikBkqqE5EYWaI0etFe/5KUcmcYwLih2RND1kq+hrUJXA==} + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + use-callback-ref@1.3.3: resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} engines: {node: '>=10'} @@ -8563,6 +9253,12 @@ packages: vue-bundle-renderer@2.2.0: resolution: {integrity: sha512-sz/0WEdYH1KfaOm0XaBmRZOWgYTEvUDt6yPYaUzl4E52qzgWLlknaPPTTZmp6benaPTlQAI/hN1x3tAzZygycg==} + vue-chartjs@5.3.3: + resolution: {integrity: sha512-jqxtL8KZ6YJ5NTv6XzrzLS7osyegOi28UGNZW0h9OkDL7Sh1396ht4Dorh04aKrl2LiSalQ84WtqiG0RIJb0tA==} + peerDependencies: + chart.js: ^4.1.1 + vue: ^3.0.0-0 || ^2.7.0 + vue-component-type-helpers@3.2.8: resolution: {integrity: sha512-9689efAXhN/EV86plgkL/XFiJSXhGtWPG6JDboZ+QnjlUWUUQrQ0ILKQtw4iQsuwIwu5k6Aw+JnehDe7161e7A==} @@ -8580,6 +9276,13 @@ packages: vue-devtools-stub@0.1.0: resolution: {integrity: sha512-RutnB7X8c5hjq39NceArgXg28WZtZpGc3+J16ljMiYnFhKvd8hITxSWQSQ5bvldxMDU6gG5mkxl1MTQLXckVSQ==} + vue-i18n@10.0.8: + resolution: {integrity: sha512-mIjy4utxMz9lMMo6G9vYePv7gUFt4ztOMhY9/4czDJxZ26xPeJ49MAGa9wBAE3XuXbYCrtVPmPxNjej7JJJkZQ==} + engines: {node: '>= 16'} + deprecated: v9 and v10 no longer supported. please migrate to v11. about maintenance status, see https://vue-i18n.intlify.dev/guide/maintenance.html + peerDependencies: + vue: ^3.0.0 + vue-router@4.6.4: resolution: {integrity: sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==} peerDependencies: @@ -8665,6 +9368,10 @@ packages: wonka@6.3.6: resolution: {integrity: sha512-MXH+6mDHAZ2GuMpgKS055FR6v0xVP3XwquxIMYXgiW+FejHQlMGlvVRZT4qMCxR+bEo/FCtIdKxwej9WV3YQag==} + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -8751,6 +9458,11 @@ packages: resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==} engines: {node: '>=8.0'} + xss@1.0.15: + resolution: {integrity: sha512-FVdlVVC67WOIPvfOwhoMETV72f6GbW7aOabBC3WxN/oUdoEMDyLz4OgRv5/gck2ZeNqEQu+Tb0kloovXOfpYVg==} + engines: {node: '>= 0.10.0'} + hasBin: true + xtend@4.0.2: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} @@ -8772,6 +9484,10 @@ packages: resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} engines: {node: '>=18'} + yaml-eslint-parser@1.3.2: + resolution: {integrity: sha512-odxVsHAkZYYglR30aPYRY4nUGJnoJ2y1ww2HDvZALo0BDETv9kWbi16J52eHs+PWRNmF4ub6nZqfVOeesOvntg==} + engines: {node: ^14.17.0 || >=16.0.0} + yaml@2.8.4: resolution: {integrity: sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==} engines: {node: '>= 14.6'} @@ -9474,6 +10190,16 @@ snapshots: cac: 6.7.14 citty: 0.2.2 + '@capsizecss/metrics@3.7.0': {} + + '@capsizecss/unpack@2.4.0': + dependencies: + blob-to-buffer: 1.2.9 + cross-fetch: 3.2.0 + fontkit: 2.0.4 + transitivePeerDependencies: + - encoding + '@capsizecss/unpack@4.0.0': dependencies: fontkitten: 1.0.3 @@ -9827,6 +10553,36 @@ snapshots: '@esbuild/win32-x64@0.28.0': optional: true + '@eslint-community/eslint-utils@4.9.1(eslint@10.3.0(jiti@2.7.0))': + dependencies: + eslint: 10.3.0(jiti@2.7.0) + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.23.5': + dependencies: + '@eslint/object-schema': 3.0.5 + debug: 4.4.3 + minimatch: 10.2.5 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.5.5': + dependencies: + '@eslint/core': 1.2.1 + + '@eslint/core@1.2.1': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/object-schema@3.0.5': {} + + '@eslint/plugin-kit@0.7.1': + dependencies: + '@eslint/core': 1.2.1 + levn: 0.4.1 + '@expo-google-fonts/nunito@0.2.3': {} '@expo/cli@54.0.24(expo-router@6.0.23)(expo@54.0.34)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))(typescript@5.8.3)': @@ -10147,6 +10903,9 @@ snapshots: chalk: 4.1.2 js-yaml: 4.1.1 + '@fastify/accept-negotiator@1.1.0': + optional: true + '@floating-ui/core@1.7.5': dependencies: '@floating-ui/utils': 0.2.11 @@ -10173,6 +10932,22 @@ snapshots: dependencies: hono: 4.12.17 + '@humanfs/core@0.19.2': + dependencies: + '@humanfs/types': 0.15.0 + + '@humanfs/node@0.16.8': + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 + '@humanwhocodes/retry': 0.4.3 + + '@humanfs/types@0.15.0': {} + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + '@iconify-json/heroicons@1.2.3': dependencies: '@iconify/types': 2.0.0 @@ -10217,6 +10992,87 @@ snapshots: dependencies: '@swc/helpers': 0.5.21 + '@intlify/bundle-utils@10.0.1(vue-i18n@10.0.8(vue@3.5.34(typescript@5.9.3)))': + dependencies: + '@intlify/message-compiler': 11.4.2 + '@intlify/shared': 11.4.2 + acorn: 8.16.0 + escodegen: 2.1.0 + estree-walker: 2.0.2 + jsonc-eslint-parser: 2.4.2 + mlly: 1.8.2 + source-map-js: 1.2.1 + yaml-eslint-parser: 1.3.2 + optionalDependencies: + vue-i18n: 10.0.8(vue@3.5.34(typescript@5.9.3)) + + '@intlify/core-base@10.0.8': + dependencies: + '@intlify/message-compiler': 10.0.8 + '@intlify/shared': 10.0.8 + + '@intlify/core@10.0.8': + dependencies: + '@intlify/core-base': 10.0.8 + '@intlify/shared': 10.0.8 + + '@intlify/h3@0.6.1': + dependencies: + '@intlify/core': 10.0.8 + '@intlify/utils': 0.13.0 + + '@intlify/message-compiler@10.0.8': + dependencies: + '@intlify/shared': 10.0.8 + source-map-js: 1.2.1 + + '@intlify/message-compiler@11.4.2': + dependencies: + '@intlify/shared': 11.4.2 + source-map-js: 1.2.1 + + '@intlify/shared@10.0.8': {} + + '@intlify/shared@11.4.2': {} + + '@intlify/unplugin-vue-i18n@6.0.8(@vue/compiler-dom@3.5.34)(eslint@10.3.0(jiti@2.7.0))(rollup@4.60.3)(typescript@5.9.3)(vue-i18n@10.0.8(vue@3.5.34(typescript@5.9.3)))(vue@3.5.34(typescript@5.9.3))': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.3.0(jiti@2.7.0)) + '@intlify/bundle-utils': 10.0.1(vue-i18n@10.0.8(vue@3.5.34(typescript@5.9.3))) + '@intlify/shared': 11.4.2 + '@intlify/vue-i18n-extensions': 8.0.0(@intlify/shared@11.4.2)(@vue/compiler-dom@3.5.34)(vue-i18n@10.0.8(vue@3.5.34(typescript@5.9.3)))(vue@3.5.34(typescript@5.9.3)) + '@rollup/pluginutils': 5.3.0(rollup@4.60.3) + '@typescript-eslint/scope-manager': 8.59.2 + '@typescript-eslint/typescript-estree': 8.59.2(typescript@5.9.3) + debug: 4.4.3 + fast-glob: 3.3.3 + js-yaml: 4.1.1 + json5: 2.2.3 + pathe: 1.1.2 + picocolors: 1.1.1 + source-map-js: 1.2.1 + unplugin: 1.16.1 + vue: 3.5.34(typescript@5.9.3) + optionalDependencies: + vue-i18n: 10.0.8(vue@3.5.34(typescript@5.9.3)) + transitivePeerDependencies: + - '@vue/compiler-dom' + - eslint + - rollup + - supports-color + - typescript + + '@intlify/utils@0.13.0': {} + + '@intlify/vue-i18n-extensions@8.0.0(@intlify/shared@11.4.2)(@vue/compiler-dom@3.5.34)(vue-i18n@10.0.8(vue@3.5.34(typescript@5.9.3)))(vue@3.5.34(typescript@5.9.3))': + dependencies: + '@babel/parser': 7.29.3 + optionalDependencies: + '@intlify/shared': 11.4.2 + '@vue/compiler-dom': 3.5.34 + vue: 3.5.34(typescript@5.9.3) + vue-i18n: 10.0.8(vue@3.5.34(typescript@5.9.3)) + '@ioredis/commands@1.5.1': {} '@isaacs/cliui@8.0.2': @@ -10353,6 +11209,19 @@ snapshots: - encoding - supports-color + '@miyaneee/rollup-plugin-json5@1.2.0(rollup@4.60.3)': + dependencies: + '@rollup/pluginutils': 5.3.0(rollup@4.60.3) + json5: 2.2.3 + rollup: 4.60.3 + + '@napi-rs/wasm-runtime@0.2.12': + dependencies: + '@emnapi/core': 1.9.2 + '@emnapi/runtime': 1.9.2 + '@tybys/wasm-util': 0.10.2 + optional: true + '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)': dependencies: '@emnapi/core': 1.9.2 @@ -10556,6 +11425,52 @@ snapshots: - utf-8-validate - vue + '@nuxt/fonts@0.11.4(db0@0.3.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3))(ioredis@5.10.1)(magicast@0.5.2)(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4))': + dependencies: + '@nuxt/devtools-kit': 2.7.0(magicast@0.5.2)(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4)) + '@nuxt/kit': 3.21.4(magicast@0.5.2) + consola: 3.4.2 + css-tree: 3.2.1 + defu: 6.1.7 + esbuild: 0.25.12 + fontaine: 0.6.0 + h3: 1.15.11 + jiti: 2.7.0 + magic-regexp: 0.10.0 + magic-string: 0.30.21 + node-fetch-native: 1.6.7 + ohash: 2.0.11 + pathe: 2.0.3 + sirv: 3.0.2 + tinyglobby: 0.2.16 + ufo: 1.6.4 + unifont: 0.4.1 + unplugin: 2.3.11 + unstorage: 1.17.5(db0@0.3.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3))(ioredis@5.10.1) + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - db0 + - encoding + - idb-keyval + - ioredis + - magicast + - uploadthing + - vite + '@nuxt/fonts@0.14.0(db0@0.3.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3))(ioredis@5.10.1)(magicast@0.5.2)(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4))': dependencies: '@nuxt/devtools-kit': 3.2.4(magicast@0.5.2)(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4)) @@ -10639,6 +11554,45 @@ snapshots: - vite - vue + '@nuxt/image@1.11.0(db0@0.3.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3))(ioredis@5.10.1)(magicast@0.5.2)': + dependencies: + '@nuxt/kit': 3.21.4(magicast@0.5.2) + consola: 3.4.2 + defu: 6.1.7 + h3: 1.15.11 + image-meta: 0.2.2 + knitwork: 1.3.0 + ohash: 2.0.11 + pathe: 2.0.3 + std-env: 3.10.0 + ufo: 1.6.4 + optionalDependencies: + ipx: 2.1.1(db0@0.3.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3))(ioredis@5.10.1) + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - bare-abort-controller + - bare-buffer + - db0 + - idb-keyval + - ioredis + - magicast + - react-native-b4a + - uploadthing + '@nuxt/kit@3.21.4(magicast@0.3.5)': dependencies: c12: 3.3.4(magicast@0.3.5) @@ -10884,7 +11838,7 @@ snapshots: - vue - yjs - '@nuxt/vite-builder@4.1.3(@types/node@22.19.17)(lightningcss@1.32.0)(magicast@0.5.2)(rollup@4.60.3)(terser@5.46.2)(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3))(yaml@2.8.4)': + '@nuxt/vite-builder@4.1.3(@types/node@22.19.17)(eslint@10.3.0(jiti@2.7.0))(lightningcss@1.32.0)(magicast@0.5.2)(optionator@0.9.4)(rollup@4.60.3)(terser@5.46.2)(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3))(yaml@2.8.4)': dependencies: '@nuxt/kit': 4.1.3(magicast@0.5.2) '@rollup/plugin-replace': 6.0.3(rollup@4.60.3) @@ -10913,7 +11867,7 @@ snapshots: unenv: 2.0.0-rc.24 vite: 7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4) vite-node: 3.2.4(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4) - vite-plugin-checker: 0.11.0(typescript@5.9.3)(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4)) + vite-plugin-checker: 0.11.0(eslint@10.3.0(jiti@2.7.0))(optionator@0.9.4)(typescript@5.9.3)(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4)) vue: 3.5.34(typescript@5.9.3) vue-bundle-renderer: 2.2.0 transitivePeerDependencies: @@ -10950,6 +11904,42 @@ snapshots: transitivePeerDependencies: - magicast + '@nuxtjs/i18n@9.5.6(@vue/compiler-dom@3.5.34)(eslint@10.3.0(jiti@2.7.0))(magicast@0.5.2)(rollup@4.60.3)(vue@3.5.34(typescript@5.9.3))': + dependencies: + '@intlify/h3': 0.6.1 + '@intlify/shared': 10.0.8 + '@intlify/unplugin-vue-i18n': 6.0.8(@vue/compiler-dom@3.5.34)(eslint@10.3.0(jiti@2.7.0))(rollup@4.60.3)(typescript@5.9.3)(vue-i18n@10.0.8(vue@3.5.34(typescript@5.9.3)))(vue@3.5.34(typescript@5.9.3)) + '@intlify/utils': 0.13.0 + '@miyaneee/rollup-plugin-json5': 1.2.0(rollup@4.60.3) + '@nuxt/kit': 3.21.4(magicast@0.5.2) + '@oxc-parser/wasm': 0.60.0 + '@rollup/plugin-yaml': 4.1.2(rollup@4.60.3) + '@vue/compiler-sfc': 3.5.34 + debug: 4.4.3 + defu: 6.1.7 + esbuild: 0.25.12 + estree-walker: 3.0.3 + h3: 1.15.11 + knitwork: 1.3.0 + magic-string: 0.30.21 + mlly: 1.8.2 + oxc-parser: 0.70.0 + pathe: 2.0.3 + typescript: 5.9.3 + ufo: 1.6.4 + unplugin: 2.3.11 + unplugin-vue-router: 0.12.0(vue-router@4.6.4(vue@3.5.34(typescript@5.9.3)))(vue@3.5.34(typescript@5.9.3)) + vue-i18n: 10.0.8(vue@3.5.34(typescript@5.9.3)) + vue-router: 4.6.4(vue@3.5.34(typescript@5.9.3)) + transitivePeerDependencies: + - '@vue/compiler-dom' + - eslint + - magicast + - petite-vue-i18n + - rollup + - supports-color + - vue + '@nuxtjs/supabase@2.0.6': dependencies: '@supabase/ssr': 0.10.3(@supabase/supabase-js@2.105.3) @@ -11022,42 +12012,63 @@ snapshots: '@oxc-parser/binding-darwin-arm64@0.126.0': optional: true + '@oxc-parser/binding-darwin-arm64@0.70.0': + optional: true + '@oxc-parser/binding-darwin-arm64@0.94.0': optional: true '@oxc-parser/binding-darwin-x64@0.126.0': optional: true + '@oxc-parser/binding-darwin-x64@0.70.0': + optional: true + '@oxc-parser/binding-darwin-x64@0.94.0': optional: true '@oxc-parser/binding-freebsd-x64@0.126.0': optional: true + '@oxc-parser/binding-freebsd-x64@0.70.0': + optional: true + '@oxc-parser/binding-freebsd-x64@0.94.0': optional: true '@oxc-parser/binding-linux-arm-gnueabihf@0.126.0': optional: true + '@oxc-parser/binding-linux-arm-gnueabihf@0.70.0': + optional: true + '@oxc-parser/binding-linux-arm-gnueabihf@0.94.0': optional: true '@oxc-parser/binding-linux-arm-musleabihf@0.126.0': optional: true + '@oxc-parser/binding-linux-arm-musleabihf@0.70.0': + optional: true + '@oxc-parser/binding-linux-arm-musleabihf@0.94.0': optional: true '@oxc-parser/binding-linux-arm64-gnu@0.126.0': optional: true + '@oxc-parser/binding-linux-arm64-gnu@0.70.0': + optional: true + '@oxc-parser/binding-linux-arm64-gnu@0.94.0': optional: true '@oxc-parser/binding-linux-arm64-musl@0.126.0': optional: true + '@oxc-parser/binding-linux-arm64-musl@0.70.0': + optional: true + '@oxc-parser/binding-linux-arm64-musl@0.94.0': optional: true @@ -11067,6 +12078,9 @@ snapshots: '@oxc-parser/binding-linux-riscv64-gnu@0.126.0': optional: true + '@oxc-parser/binding-linux-riscv64-gnu@0.70.0': + optional: true + '@oxc-parser/binding-linux-riscv64-gnu@0.94.0': optional: true @@ -11076,18 +12090,27 @@ snapshots: '@oxc-parser/binding-linux-s390x-gnu@0.126.0': optional: true + '@oxc-parser/binding-linux-s390x-gnu@0.70.0': + optional: true + '@oxc-parser/binding-linux-s390x-gnu@0.94.0': optional: true '@oxc-parser/binding-linux-x64-gnu@0.126.0': optional: true + '@oxc-parser/binding-linux-x64-gnu@0.70.0': + optional: true + '@oxc-parser/binding-linux-x64-gnu@0.94.0': optional: true '@oxc-parser/binding-linux-x64-musl@0.126.0': optional: true + '@oxc-parser/binding-linux-x64-musl@0.70.0': + optional: true + '@oxc-parser/binding-linux-x64-musl@0.94.0': optional: true @@ -11101,6 +12124,11 @@ snapshots: '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2) optional: true + '@oxc-parser/binding-wasm32-wasi@0.70.0': + dependencies: + '@napi-rs/wasm-runtime': 0.2.12 + optional: true + '@oxc-parser/binding-wasm32-wasi@0.94.0(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)': dependencies: '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2) @@ -11112,6 +12140,9 @@ snapshots: '@oxc-parser/binding-win32-arm64-msvc@0.126.0': optional: true + '@oxc-parser/binding-win32-arm64-msvc@0.70.0': + optional: true + '@oxc-parser/binding-win32-arm64-msvc@0.94.0': optional: true @@ -11121,11 +12152,22 @@ snapshots: '@oxc-parser/binding-win32-x64-msvc@0.126.0': optional: true + '@oxc-parser/binding-win32-x64-msvc@0.70.0': + optional: true + '@oxc-parser/binding-win32-x64-msvc@0.94.0': optional: true + '@oxc-parser/wasm@0.60.0': + dependencies: + '@oxc-project/types': 0.60.0 + '@oxc-project/types@0.126.0': {} + '@oxc-project/types@0.60.0': {} + + '@oxc-project/types@0.70.0': {} + '@oxc-project/types@0.94.0': {} '@oxc-transform/binding-android-arm64@0.94.0': @@ -11901,6 +12943,14 @@ snapshots: optionalDependencies: rollup: 4.60.3 + '@rollup/plugin-yaml@4.1.2(rollup@4.60.3)': + dependencies: + '@rollup/pluginutils': 5.3.0(rollup@4.60.3) + js-yaml: 4.1.1 + tosource: 2.0.0-alpha.3 + optionalDependencies: + rollup: 4.60.3 + '@rollup/pluginutils@5.3.0(rollup@4.60.3)': dependencies: '@types/estree': 1.0.8 @@ -12409,6 +13459,8 @@ snapshots: dependencies: '@babel/types': 7.29.0 + '@types/esrecurse@4.3.1': {} + '@types/estree@1.0.8': {} '@types/graceful-fs@4.1.9': @@ -12432,6 +13484,8 @@ snapshots: dependencies: '@types/istanbul-lib-report': 3.0.3 + '@types/json-schema@7.0.15': {} + '@types/node-fetch@2.6.13': dependencies: '@types/node': 22.19.17 @@ -12473,6 +13527,46 @@ snapshots: dependencies: '@types/yargs-parser': 21.0.3 + '@typescript-eslint/project-service@8.59.2(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.59.2(typescript@5.9.3) + '@typescript-eslint/types': 8.59.2 + debug: 4.4.3 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.59.2': + dependencies: + '@typescript-eslint/types': 8.59.2 + '@typescript-eslint/visitor-keys': 8.59.2 + + '@typescript-eslint/tsconfig-utils@8.59.2(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@typescript-eslint/types@8.59.2': {} + + '@typescript-eslint/typescript-estree@8.59.2(typescript@5.9.3)': + dependencies: + '@typescript-eslint/project-service': 8.59.2(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.59.2(typescript@5.9.3) + '@typescript-eslint/types': 8.59.2 + '@typescript-eslint/visitor-keys': 8.59.2 + debug: 4.4.3 + minimatch: 10.2.5 + semver: 7.7.4 + tinyglobby: 0.2.16 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.59.2': + dependencies: + '@typescript-eslint/types': 8.59.2 + eslint-visitor-keys: 5.0.1 + '@ungap/structured-clone@1.3.1': {} '@unhead/vue@2.1.13(vue@3.5.34(typescript@5.9.3))': @@ -12721,6 +13815,17 @@ snapshots: '@volar/source-map@2.4.28': {} + '@vue-macros/common@1.16.1(vue@3.5.34(typescript@5.9.3))': + dependencies: + '@vue/compiler-sfc': 3.5.34 + ast-kit: 1.4.3 + local-pkg: 1.1.2 + magic-string-ast: 0.7.1 + pathe: 2.0.3 + picomatch: 4.0.4 + optionalDependencies: + vue: 3.5.34(typescript@5.9.3) + '@vue-macros/common@3.0.0-beta.16(vue@3.5.34(typescript@5.9.3))': dependencies: '@vue/compiler-sfc': 3.5.34 @@ -12877,6 +13982,13 @@ snapshots: - '@vue/composition-api' - vue + '@vueuse/core@13.9.0(vue@3.5.34(typescript@5.9.3))': + dependencies: + '@types/web-bluetooth': 0.0.21 + '@vueuse/metadata': 13.9.0 + '@vueuse/shared': 13.9.0(vue@3.5.34(typescript@5.9.3)) + vue: 3.5.34(typescript@5.9.3) + '@vueuse/core@14.3.0(vue@3.5.34(typescript@5.9.3))': dependencies: '@types/web-bluetooth': 0.0.21 @@ -12894,15 +14006,31 @@ snapshots: '@vueuse/metadata@10.11.1': {} + '@vueuse/metadata@13.9.0': {} + '@vueuse/metadata@14.3.0': {} - '@vueuse/nuxt@14.3.0(magicast@0.5.2)(nuxt@4.1.3(@electric-sql/pglite@0.4.1)(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@parcel/watcher@2.5.6)(@types/node@22.19.17)(@vue/compiler-sfc@3.5.34)(cac@6.7.14)(db0@0.3.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.2)(mysql2@3.15.3)(rollup@4.60.3)(terser@5.46.2)(typescript@5.9.3)(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4))(yaml@2.8.4))(vue@3.5.34(typescript@5.9.3))': + '@vueuse/motion@3.0.3(magicast@0.5.2)(vue@3.5.34(typescript@5.9.3))': + dependencies: + '@vueuse/core': 13.9.0(vue@3.5.34(typescript@5.9.3)) + '@vueuse/shared': 13.9.0(vue@3.5.34(typescript@5.9.3)) + defu: 6.1.7 + framesync: 6.1.2 + popmotion: 11.0.5 + style-value-types: 5.1.2 + vue: 3.5.34(typescript@5.9.3) + optionalDependencies: + '@nuxt/kit': 3.21.4(magicast@0.5.2) + transitivePeerDependencies: + - magicast + + '@vueuse/nuxt@14.3.0(magicast@0.5.2)(nuxt@4.1.3(@electric-sql/pglite@0.4.1)(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@parcel/watcher@2.5.6)(@types/node@22.19.17)(@vue/compiler-sfc@3.5.34)(cac@6.7.14)(db0@0.3.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3))(eslint@10.3.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.2)(mysql2@3.15.3)(optionator@0.9.4)(rollup@4.60.3)(terser@5.46.2)(typescript@5.9.3)(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4))(yaml@2.8.4))(vue@3.5.34(typescript@5.9.3))': dependencies: '@nuxt/kit': 4.4.4(magicast@0.5.2) '@vueuse/core': 14.3.0(vue@3.5.34(typescript@5.9.3)) '@vueuse/metadata': 14.3.0 local-pkg: 1.1.2 - nuxt: 4.1.3(@electric-sql/pglite@0.4.1)(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@parcel/watcher@2.5.6)(@types/node@22.19.17)(@vue/compiler-sfc@3.5.34)(cac@6.7.14)(db0@0.3.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.2)(mysql2@3.15.3)(rollup@4.60.3)(terser@5.46.2)(typescript@5.9.3)(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4))(yaml@2.8.4) + nuxt: 4.1.3(@electric-sql/pglite@0.4.1)(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@parcel/watcher@2.5.6)(@types/node@22.19.17)(@vue/compiler-sfc@3.5.34)(cac@6.7.14)(db0@0.3.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3))(eslint@10.3.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.2)(mysql2@3.15.3)(optionator@0.9.4)(rollup@4.60.3)(terser@5.46.2)(typescript@5.9.3)(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4))(yaml@2.8.4) vue: 3.5.34(typescript@5.9.3) transitivePeerDependencies: - magicast @@ -12914,6 +14042,10 @@ snapshots: - '@vue/composition-api' - vue + '@vueuse/shared@13.9.0(vue@3.5.34(typescript@5.9.3))': + dependencies: + vue: 3.5.34(typescript@5.9.3) + '@vueuse/shared@14.3.0(vue@3.5.34(typescript@5.9.3))': dependencies: vue: 3.5.34(typescript@5.9.3) @@ -12943,6 +14075,10 @@ snapshots: dependencies: acorn: 8.16.0 + acorn-jsx@5.3.2(acorn@8.16.0): + dependencies: + acorn: 8.16.0 + acorn@8.16.0: {} agent-base@7.1.4: {} @@ -12951,6 +14087,13 @@ snapshots: dependencies: humanize-ms: 1.2.1 + ajv@6.15.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + ajv@8.20.0: dependencies: fast-deep-equal: 3.1.3 @@ -13043,11 +14186,21 @@ snapshots: assertion-error@2.0.1: {} + ast-kit@1.4.3: + dependencies: + '@babel/parser': 7.29.3 + pathe: 2.0.3 + ast-kit@2.2.0: dependencies: '@babel/parser': 7.29.3 pathe: 2.0.3 + ast-walker-scope@0.6.2: + dependencies: + '@babel/parser': 7.29.3 + ast-kit: 1.4.3 + ast-walker-scope@0.8.3: dependencies: '@babel/parser': 7.29.3 @@ -13267,6 +14420,15 @@ snapshots: birpc@4.0.0: {} + bl@4.1.0: + dependencies: + buffer: 5.7.1 + inherits: 2.0.4 + readable-stream: 3.6.2 + optional: true + + blob-to-buffer@1.2.9: {} + bole@5.0.29: dependencies: fast-safe-stringify: 2.1.1 @@ -13303,6 +14465,10 @@ snapshots: dependencies: fill-range: 7.1.1 + brotli@1.3.3: + dependencies: + base64-js: 1.5.1 + browserslist@4.28.2: dependencies: baseline-browser-mapping: 2.10.27 @@ -13450,6 +14616,9 @@ snapshots: dependencies: readdirp: 5.0.0 + chownr@1.1.4: + optional: true + chownr@3.0.0: {} chrome-launcher@0.15.2: @@ -13504,6 +14673,8 @@ snapshots: clone@1.0.4: {} + clone@2.1.2: {} + cluster-key-slot@1.1.2: {} color-convert@1.9.3: @@ -13623,6 +14794,12 @@ snapshots: croner@10.0.1: {} + cross-fetch@3.2.0: + dependencies: + node-fetch: 2.7.0 + transitivePeerDependencies: + - encoding + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -13655,6 +14832,12 @@ snapshots: mdn-data: 2.0.28 source-map-js: 1.2.1 + css-tree@2.3.1: + dependencies: + mdn-data: 2.0.30 + source-map-js: 1.2.1 + optional: true + css-tree@3.2.1: dependencies: mdn-data: 2.27.1 @@ -13664,6 +14847,9 @@ snapshots: cssesc@3.0.0: {} + cssfilter@0.0.10: + optional: true + cssnano-preset-default@7.0.17(postcss@8.5.14): dependencies: browserslist: 4.28.2 @@ -13739,10 +14925,17 @@ snapshots: decode-uri-component@0.2.2: {} + decompress-response@6.0.0: + dependencies: + mimic-response: 3.1.0 + optional: true + deep-eql@5.0.2: {} deep-extend@0.6.0: {} + deep-is@0.1.4: {} + deepmerge-ts@7.1.5: {} deepmerge@4.3.1: {} @@ -13815,6 +15008,8 @@ snapshots: - typescript - utf-8-validate + dfa@1.2.0: {} + didyoumean@1.2.2: {} diff@8.0.4: {} @@ -13923,6 +15118,11 @@ snapshots: encoding-japanese@2.2.0: {} + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + optional: true + enhanced-resolve@5.21.2: dependencies: graceful-fs: 4.2.11 @@ -14092,14 +15292,94 @@ snapshots: escape-string-regexp@5.0.0: {} + escodegen@2.1.0: + dependencies: + esprima: 4.0.1 + estraverse: 5.3.0 + esutils: 2.0.3 + optionalDependencies: + source-map: 0.6.1 + + eslint-scope@9.1.2: + dependencies: + '@types/esrecurse': 4.3.1 + '@types/estree': 1.0.8 + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@10.3.0(jiti@2.7.0): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.3.0(jiti@2.7.0)) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.23.5 + '@eslint/config-helpers': 0.5.5 + '@eslint/core': 1.2.1 + '@eslint/plugin-kit': 0.7.1 + '@humanfs/node': 0.16.8 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.8 + ajv: 6.15.0 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 9.1.2 + eslint-visitor-keys: 5.0.1 + espree: 11.2.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + minimatch: 10.2.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + optionalDependencies: + jiti: 2.7.0 + transitivePeerDependencies: + - supports-color + + espree@11.2.0: + dependencies: + acorn: 8.16.0 + acorn-jsx: 5.3.2(acorn@8.16.0) + eslint-visitor-keys: 5.0.1 + + espree@9.6.1: + dependencies: + acorn: 8.16.0 + acorn-jsx: 5.3.2(acorn@8.16.0) + eslint-visitor-keys: 3.4.3 + esprima@4.0.1: {} + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + estree-walker@2.0.2: {} estree-walker@3.0.3: dependencies: '@types/estree': 1.0.8 + esutils@2.0.3: {} + etag@1.8.1: {} event-target-shim@5.0.1: {} @@ -14124,6 +15404,9 @@ snapshots: signal-exit: 4.1.0 strip-final-newline: 3.0.0 + expand-template@2.0.3: + optional: true + expect-type@1.3.0: {} expo-apple-authentication@8.0.8(expo@54.0.34)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0)): @@ -14419,6 +15702,8 @@ snapshots: fast-json-stable-stringify@2.1.0: {} + fast-levenshtein@2.0.6: {} + fast-npm-meta@0.4.8: {} fast-npm-meta@1.5.1: {} @@ -14449,6 +15734,10 @@ snapshots: optionalDependencies: picomatch: 4.0.4 + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + file-uri-to-path@1.0.0: {} fill-range@7.1.1: @@ -14474,8 +15763,33 @@ snapshots: 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 + + flat-cache@4.0.1: + dependencies: + flatted: 3.4.2 + keyv: 4.5.4 + + flatted@3.4.2: {} + flow-enums-runtime@0.0.6: {} + fontaine@0.6.0: + dependencies: + '@capsizecss/metrics': 3.7.0 + '@capsizecss/unpack': 2.4.0 + css-tree: 3.2.1 + magic-regexp: 0.10.0 + magic-string: 0.30.21 + pathe: 2.0.3 + ufo: 1.6.4 + unplugin: 2.3.11 + transitivePeerDependencies: + - encoding + fontaine@0.8.0: dependencies: '@capsizecss/unpack': 4.0.0 @@ -14488,6 +15802,18 @@ snapshots: fontfaceobserver@2.3.0: {} + fontkit@2.0.4: + dependencies: + '@swc/helpers': 0.5.21 + brotli: 1.3.3 + clone: 2.1.2 + dfa: 1.2.0 + fast-deep-equal: 3.1.3 + restructure: 3.0.2 + tiny-inflate: 1.0.3 + unicode-properties: 1.4.1 + unicode-trie: 2.0.0 + fontkitten@1.0.3: dependencies: tiny-inflate: 1.0.3 @@ -14565,12 +15891,19 @@ snapshots: react: 19.1.0 react-dom: 19.1.0(react@19.1.0) + framesync@6.1.2: + dependencies: + tslib: 2.4.0 + freeport-async@2.0.0: {} fresh@0.5.2: {} fresh@2.0.0: {} + fs-constants@1.0.0: + optional: true + fs.realpath@1.0.0: {} fsevents@2.3.3: @@ -14624,6 +15957,9 @@ snapshots: giget@3.2.0: {} + github-from-package@0.0.0: + optional: true + glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -14882,6 +16218,49 @@ snapshots: ip-address@10.2.0: {} + ipx@2.1.1(db0@0.3.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3))(ioredis@5.10.1): + dependencies: + '@fastify/accept-negotiator': 1.1.0 + citty: 0.1.6 + consola: 3.4.2 + defu: 6.1.7 + destr: 2.0.5 + etag: 1.8.1 + h3: 1.15.11 + image-meta: 0.2.2 + listhen: 1.10.0 + ofetch: 1.5.1 + pathe: 1.1.2 + sharp: 0.32.6 + svgo: 3.3.3 + ufo: 1.6.4 + unstorage: 1.17.5(db0@0.3.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3))(ioredis@5.10.1) + xss: 1.0.15 + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - bare-abort-controller + - bare-buffer + - db0 + - idb-keyval + - ioredis + - react-native-b4a + - uploadthing + optional: true + iron-webcrypto@1.2.1: {} is-arguments@1.2.0: @@ -15124,12 +16503,29 @@ snapshots: jsesc@3.1.0: {} + json-buffer@3.0.1: {} + json-parse-even-better-errors@2.3.1: {} + json-schema-traverse@0.4.1: {} + json-schema-traverse@1.0.0: {} + json-stable-stringify-without-jsonify@1.0.1: {} + json5@2.2.3: {} + jsonc-eslint-parser@2.4.2: + dependencies: + acorn: 8.16.0 + eslint-visitor-keys: 3.4.3 + espree: 9.6.1 + semver: 7.7.4 + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + kleur@3.0.3: {} kleur@4.1.5: {} @@ -15155,6 +16551,11 @@ snapshots: leven@3.1.0: {} + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + lib0@0.2.117: dependencies: isomorphic.js: 0.2.5 @@ -15308,6 +16709,10 @@ snapshots: 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: {} @@ -15365,6 +16770,10 @@ snapshots: ufo: 1.6.4 unplugin: 2.3.11 + magic-string-ast@0.7.1: + dependencies: + magic-string: 0.30.21 + magic-string-ast@1.0.3: dependencies: magic-string: 0.30.21 @@ -15403,6 +16812,9 @@ snapshots: mdn-data@2.0.28: {} + mdn-data@2.0.30: + optional: true + mdn-data@2.27.1: {} memoize-one@5.2.1: {} @@ -15615,6 +17027,9 @@ snapshots: mimic-fn@4.0.0: {} + mimic-response@3.1.0: + optional: true + minimatch@10.2.5: dependencies: brace-expansion: 5.0.5 @@ -15641,6 +17056,9 @@ snapshots: mitt@3.0.1: {} + mkdirp-classic@0.5.3: + optional: true + mkdirp@1.0.4: {} mlly@1.8.2: @@ -15709,6 +17127,9 @@ snapshots: nanotar@0.2.1: {} + napi-build-utils@2.0.0: + optional: true + nativewind@4.2.3(react-native-reanimated@4.1.7(react-native-worklets@0.5.1(@babel/core@7.29.0)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0))(react-native-svg@15.12.1(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0)(tailwindcss@3.4.19(yaml@2.8.4)): dependencies: comment-json: 4.6.2 @@ -15723,6 +17144,8 @@ snapshots: - react-native-svg - supports-color + natural-compare@1.4.0: {} + negotiator@0.6.3: {} negotiator@0.6.4: {} @@ -15937,6 +17360,14 @@ snapshots: - supports-color - uploadthing + node-abi@3.92.0: + dependencies: + semver: 7.7.4 + optional: true + + node-addon-api@6.1.0: + optional: true + node-addon-api@7.1.1: {} node-domexception@1.0.0: {} @@ -15987,7 +17418,7 @@ snapshots: nullthrows@1.1.1: {} - nuxt@4.1.3(@electric-sql/pglite@0.4.1)(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@parcel/watcher@2.5.6)(@types/node@22.19.17)(@vue/compiler-sfc@3.5.34)(cac@6.7.14)(db0@0.3.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.2)(mysql2@3.15.3)(rollup@4.60.3)(terser@5.46.2)(typescript@5.9.3)(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4))(yaml@2.8.4): + nuxt@4.1.3(@electric-sql/pglite@0.4.1)(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@parcel/watcher@2.5.6)(@types/node@22.19.17)(@vue/compiler-sfc@3.5.34)(cac@6.7.14)(db0@0.3.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3))(eslint@10.3.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.2)(mysql2@3.15.3)(optionator@0.9.4)(rollup@4.60.3)(terser@5.46.2)(typescript@5.9.3)(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4))(yaml@2.8.4): dependencies: '@nuxt/cli': 3.35.1(@nuxt/schema@4.1.3)(cac@6.7.14)(magicast@0.5.2) '@nuxt/devalue': 2.0.2 @@ -15995,7 +17426,7 @@ snapshots: '@nuxt/kit': 4.1.3(magicast@0.5.2) '@nuxt/schema': 4.1.3 '@nuxt/telemetry': 2.8.0(@nuxt/kit@4.1.3(magicast@0.5.2)) - '@nuxt/vite-builder': 4.1.3(@types/node@22.19.17)(lightningcss@1.32.0)(magicast@0.5.2)(rollup@4.60.3)(terser@5.46.2)(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3))(yaml@2.8.4) + '@nuxt/vite-builder': 4.1.3(@types/node@22.19.17)(eslint@10.3.0(jiti@2.7.0))(lightningcss@1.32.0)(magicast@0.5.2)(optionator@0.9.4)(rollup@4.60.3)(terser@5.46.2)(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3))(yaml@2.8.4) '@unhead/vue': 2.1.13(vue@3.5.34(typescript@5.9.3)) '@vue/shared': 3.5.34 c12: 3.3.4(magicast@0.5.2) @@ -16230,6 +17661,15 @@ snapshots: transitivePeerDependencies: - encoding + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + ora@3.4.0: dependencies: chalk: 2.4.2 @@ -16287,6 +17727,25 @@ snapshots: '@oxc-parser/binding-win32-ia32-msvc': 0.126.0 '@oxc-parser/binding-win32-x64-msvc': 0.126.0 + oxc-parser@0.70.0: + dependencies: + '@oxc-project/types': 0.70.0 + optionalDependencies: + '@oxc-parser/binding-darwin-arm64': 0.70.0 + '@oxc-parser/binding-darwin-x64': 0.70.0 + '@oxc-parser/binding-freebsd-x64': 0.70.0 + '@oxc-parser/binding-linux-arm-gnueabihf': 0.70.0 + '@oxc-parser/binding-linux-arm-musleabihf': 0.70.0 + '@oxc-parser/binding-linux-arm64-gnu': 0.70.0 + '@oxc-parser/binding-linux-arm64-musl': 0.70.0 + '@oxc-parser/binding-linux-riscv64-gnu': 0.70.0 + '@oxc-parser/binding-linux-s390x-gnu': 0.70.0 + '@oxc-parser/binding-linux-x64-gnu': 0.70.0 + '@oxc-parser/binding-linux-x64-musl': 0.70.0 + '@oxc-parser/binding-wasm32-wasi': 0.70.0 + '@oxc-parser/binding-win32-arm64-msvc': 0.70.0 + '@oxc-parser/binding-win32-x64-msvc': 0.70.0 + oxc-parser@0.94.0(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2): dependencies: '@oxc-project/types': 0.94.0 @@ -16352,12 +17811,18 @@ snapshots: 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: {} package-manager-detector@1.6.0: {} + pako@0.2.9: {} + parse-json@5.2.0: dependencies: '@babel/code-frame': 7.29.0 @@ -16495,6 +17960,13 @@ snapshots: pngjs@3.4.0: {} + popmotion@11.0.5: + dependencies: + framesync: 6.1.2 + hey-listen: 1.0.8 + style-value-types: 5.1.2 + tslib: 2.4.0 + possible-typed-array-names@1.1.0: {} postcss-calc@10.1.1(postcss@8.5.14): @@ -16713,6 +18185,24 @@ snapshots: powershell-utils@0.1.0: {} + prebuild-install@7.1.3: + dependencies: + detect-libc: 2.1.2 + expand-template: 2.0.3 + github-from-package: 0.0.0 + minimist: 1.2.8 + mkdirp-classic: 0.5.3 + napi-build-utils: 2.0.0 + node-abi: 3.92.0 + pump: 3.0.4 + rc: 1.2.8 + simple-get: 4.0.1 + tar-fs: 2.1.4 + tunnel-agent: 0.6.0 + optional: true + + prelude-ls@1.2.1: {} + prettier@3.8.3: {} pretty-bytes@5.6.0: {} @@ -16843,6 +18333,12 @@ snapshots: picocolors: 1.1.1 sade: 1.8.1 + pump@3.0.4: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + optional: true + punycode@2.3.1: {} pure-rand@6.1.0: {} @@ -16972,6 +18468,13 @@ snapshots: react: 19.1.0 react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0) + react-native-keyboard-controller@1.21.7(react-native-reanimated@4.1.7(react-native-worklets@0.5.1(@babel/core@7.29.0)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0): + dependencies: + react: 19.1.0 + react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0) + react-native-is-edge-to-edge: 1.3.1(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0) + react-native-reanimated: 4.1.7(react-native-worklets@0.5.1(@babel/core@7.29.0)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0) + react-native-mmkv@3.3.3(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0): dependencies: react: 19.1.0 @@ -17133,6 +18636,13 @@ snapshots: string_decoder: 1.1.1 util-deprecate: 1.0.2 + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + optional: true + readable-stream@4.7.0: dependencies: abort-controller: 3.0.0 @@ -17243,6 +18753,8 @@ snapshots: onetime: 2.0.1 signal-exit: 3.0.7 + restructure@3.0.2: {} + retry@0.12.0: {} reusify@1.1.0: {} @@ -17432,6 +18944,22 @@ snapshots: shallowequal@1.1.0: {} + sharp@0.32.6: + dependencies: + color: 4.2.3 + detect-libc: 2.1.2 + node-addon-api: 6.1.0 + prebuild-install: 7.1.3 + semver: 7.7.4 + simple-get: 4.0.1 + tar-fs: 3.1.2 + tunnel-agent: 0.6.0 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + optional: true + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -17474,6 +19002,16 @@ snapshots: signal-exit@4.1.0: {} + simple-concat@1.0.1: + optional: true + + simple-get@4.0.1: + dependencies: + decompress-response: 6.0.0 + once: 1.4.0 + simple-concat: 1.0.1 + optional: true + simple-git@3.36.0: dependencies: '@kwsites/file-exists': 1.1.1 @@ -17642,6 +19180,11 @@ snapshots: structured-headers@0.4.1: {} + style-value-types@5.1.2: + dependencies: + hey-listen: 1.0.8 + tslib: 2.4.0 + stylehacks@7.0.11(postcss@8.5.14): dependencies: browserslist: 4.28.2 @@ -17683,6 +19226,17 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + svgo@3.3.3: + dependencies: + commander: 7.2.0 + css-select: 5.2.2 + css-tree: 2.3.1 + css-what: 6.2.2 + csso: 5.0.5 + picocolors: 1.1.1 + sax: 1.6.0 + optional: true + svgo@4.0.1: dependencies: commander: 11.1.0 @@ -17735,6 +19289,36 @@ snapshots: tapable@2.3.3: {} + tar-fs@2.1.4: + dependencies: + chownr: 1.1.4 + mkdirp-classic: 0.5.3 + pump: 3.0.4 + tar-stream: 2.2.0 + optional: true + + tar-fs@3.1.2: + dependencies: + pump: 3.0.4 + tar-stream: 3.2.0 + optionalDependencies: + bare-fs: 4.7.1 + bare-path: 3.0.0 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + optional: true + + tar-stream@2.2.0: + dependencies: + bl: 4.1.0 + end-of-stream: 1.4.5 + fs-constants: 1.0.0 + inherits: 2.0.4 + readable-stream: 3.6.2 + optional: true + tar-stream@3.2.0: dependencies: b4a: 1.8.1 @@ -17836,14 +19420,31 @@ snapshots: toidentifier@1.0.1: {} + tosource@2.0.0-alpha.3: {} + totalist@3.0.1: {} tr46@0.0.3: {} + ts-api-utils@2.5.0(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + ts-interface-checker@0.1.13: {} + tslib@2.4.0: {} + tslib@2.8.1: {} + tunnel-agent@0.6.0: + dependencies: + safe-buffer: 5.2.1 + optional: true + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + type-detect@4.0.8: {} type-fest@0.21.3: {} @@ -17911,12 +19512,27 @@ snapshots: unicode-match-property-value-ecmascript@2.2.1: {} + unicode-properties@1.4.1: + dependencies: + base64-js: 1.5.1 + unicode-trie: 2.0.0 + unicode-property-aliases-ecmascript@2.2.0: {} + unicode-trie@2.0.0: + dependencies: + pako: 0.2.9 + tiny-inflate: 1.0.3 + unicorn-magic@0.3.0: {} unicorn-magic@0.4.0: {} + unifont@0.4.1: + dependencies: + css-tree: 3.2.1 + ohash: 2.0.11 + unifont@0.7.4: dependencies: css-tree: 3.2.1 @@ -18017,6 +19633,28 @@ snapshots: optionalDependencies: '@nuxt/kit': 4.4.4(magicast@0.5.2) + unplugin-vue-router@0.12.0(vue-router@4.6.4(vue@3.5.34(typescript@5.9.3)))(vue@3.5.34(typescript@5.9.3)): + dependencies: + '@babel/types': 7.29.0 + '@vue-macros/common': 1.16.1(vue@3.5.34(typescript@5.9.3)) + ast-walker-scope: 0.6.2 + chokidar: 4.0.3 + fast-glob: 3.3.3 + json5: 2.2.3 + local-pkg: 1.1.2 + magic-string: 0.30.21 + micromatch: 4.0.8 + mlly: 1.8.2 + pathe: 2.0.3 + scule: 1.3.0 + unplugin: 2.3.11 + unplugin-utils: 0.2.5 + yaml: 2.8.4 + optionalDependencies: + vue-router: 4.6.4(vue@3.5.34(typescript@5.9.3)) + transitivePeerDependencies: + - vue + unplugin-vue-router@0.15.0(@vue/compiler-sfc@3.5.34)(vue-router@4.6.4(vue@3.5.34(typescript@5.9.3)))(vue@3.5.34(typescript@5.9.3)): dependencies: '@vue-macros/common': 3.0.0-beta.16(vue@3.5.34(typescript@5.9.3)) @@ -18041,6 +19679,11 @@ snapshots: transitivePeerDependencies: - vue + unplugin@1.16.1: + dependencies: + acorn: 8.16.0 + webpack-virtual-modules: 0.6.2 + unplugin@2.3.11: dependencies: '@jridgewell/remapping': 2.3.5 @@ -18099,6 +19742,10 @@ snapshots: uqr@0.1.3: {} + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + use-callback-ref@1.3.3(@types/react@19.2.14)(react@19.1.0): dependencies: react: 19.1.0 @@ -18218,7 +19865,7 @@ snapshots: - tsx - yaml - vite-plugin-checker@0.11.0(typescript@5.9.3)(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4)): + vite-plugin-checker@0.11.0(eslint@10.3.0(jiti@2.7.0))(optionator@0.9.4)(typescript@5.9.3)(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4)): dependencies: '@babel/code-frame': 7.29.0 chokidar: 4.0.3 @@ -18230,6 +19877,8 @@ snapshots: vite: 7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4) vscode-uri: 3.1.0 optionalDependencies: + eslint: 10.3.0(jiti@2.7.0) + optionator: 0.9.4 typescript: 5.9.3 vite-plugin-inspect@11.3.3(@nuxt/kit@3.21.4(magicast@0.3.5))(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4)): @@ -18351,6 +20000,11 @@ snapshots: dependencies: ufo: 1.6.4 + vue-chartjs@5.3.3(chart.js@4.5.1)(vue@3.5.34(typescript@5.9.3)): + dependencies: + chart.js: 4.5.1 + vue: 3.5.34(typescript@5.9.3) + vue-component-type-helpers@3.2.8: {} vue-demi@0.14.10(vue@3.5.34(typescript@5.9.3)): @@ -18359,6 +20013,13 @@ snapshots: vue-devtools-stub@0.1.0: {} + vue-i18n@10.0.8(vue@3.5.34(typescript@5.9.3)): + dependencies: + '@intlify/core-base': 10.0.8 + '@intlify/shared': 10.0.8 + '@vue/devtools-api': 6.6.4 + vue: 3.5.34(typescript@5.9.3) + vue-router@4.6.4(vue@3.5.34(typescript@5.9.3)): dependencies: '@vue/devtools-api': 6.6.4 @@ -18442,6 +20103,8 @@ snapshots: wonka@6.3.6: {} + word-wrap@1.2.5: {} + wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 @@ -18508,6 +20171,12 @@ snapshots: xmlbuilder@15.1.1: {} + xss@1.0.15: + dependencies: + commander: 2.20.3 + cssfilter: 0.0.10 + optional: true + xtend@4.0.2: {} y-protocols@1.0.7(yjs@13.6.30): @@ -18521,6 +20190,11 @@ snapshots: yallist@5.0.0: {} + yaml-eslint-parser@1.3.2: + dependencies: + eslint-visitor-keys: 3.4.3 + yaml: 2.8.4 + yaml@2.8.4: {} yargs-parser@21.1.1: {}