From 6afffdbb186edc8eae1e80cf059e3e4930bb886b Mon Sep 17 00:00:00 2001 From: chahinebrini Date: Sun, 10 May 2026 23:58:05 +0200 Subject: [PATCH] fix(mail): clear connect-error on re-connect + return error fields in status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - upsertMailConnection: bei Update lastConnectError + lastConnectErrorAt auf null — User aktualisiert App-Passwort → UI zeigt sofort wieder Live (statt stale Auth-Fehler-Status bis nächstem IDLE/Scan-Cycle) - /api/mail/status: liefert lastConnectError, lastConnectErrorAt, lastIdleHeartbeatAt mit (waren bisher nicht im Response → Frontend hat den Status nie korrekt rendern können) Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/rebreak-native/app/settings.tsx | 22 +- .../components/games/GameOverScreen.tsx | 634 ++++++++++++------ .../components/urge/UrgeGames.tsx | 42 +- backend/server/api/mail/status.get.ts | 7 + backend/server/db/mail.ts | 4 + 5 files changed, 475 insertions(+), 234 deletions(-) diff --git a/apps/rebreak-native/app/settings.tsx b/apps/rebreak-native/app/settings.tsx index 136d0eb..10b9edc 100644 --- a/apps/rebreak-native/app/settings.tsx +++ b/apps/rebreak-native/app/settings.tsx @@ -13,6 +13,7 @@ import { Ionicons } from '@expo/vector-icons'; import { MenuView, type MenuAction } from '@react-native-menu/menu'; import { TrueSheet } from '@lodev09/react-native-true-sheet'; import { useTranslation } from 'react-i18next'; +import { LanguageIcon } from '../components/icons/LanguageIcon'; import { useColors } from '../lib/theme'; import { useAuthStore } from '../stores/auth'; import { useThemeStore, type ThemeMode } from '../stores/theme'; @@ -25,7 +26,8 @@ import { AppHeader } from '../components/AppHeader'; type PickerOption = { value: T; label: string }; type SectionRow = { - icon: React.ComponentProps['name']; + /** Ionicons-name ODER eigenes SVG-Component (für custom icons wie LanguageIcon) */ + icon: React.ComponentProps['name'] | React.ComponentType<{ size?: number; color?: string }>; label: string; sublabel: string; soon?: boolean; @@ -132,7 +134,7 @@ export default function SettingsScreen() { }, }, { - icon: 'language-outline', + icon: LanguageIcon, label: t('settings.language'), sublabel: t('settings.language_desc'), value: language === 'de' ? t('settings.language_de') : t('settings.language_en'), @@ -288,13 +290,19 @@ export default function SettingsScreen() { > {section.rows.map((row, i) => { // Visual content of the row (icon + label + sublabel) + const iconColor = row.destructive ? colors.error : colors.textMuted; + const IconComponent = typeof row.icon === 'string' ? null : row.icon; const rowLeft = ( <> - + {IconComponent ? ( + + ) : ( + ['name']} + size={18} + color={iconColor} + /> + )} void; onExit: () => void; isNewBest?: boolean; }; -const MOTIVATIONAL_KEYS = [ - 'gameOver.motivational_0', - 'gameOver.motivational_1', - 'gameOver.motivational_2', - 'gameOver.motivational_3', - 'gameOver.motivational_4', -]; +function lyraMsg( + gameName: string, + score: number, + goodScore: number, + isNewBest: boolean, + t: (k: string) => string +): { title: string; body: string } { + if (isNewBest) return { title: t('gameOver.lyra_title_record'), body: t('gameOver.lyra_body_record') }; + if (score >= goodScore) return { title: t('gameOver.lyra_title_good'), body: t('gameOver.lyra_body_good') }; + if (score > 0) return { title: t('gameOver.lyra_title_ok'), body: t('gameOver.lyra_body_ok') }; + return { title: t('gameOver.lyra_title_low'), body: t('gameOver.lyra_body_low') }; +} export function GameOverScreen({ score, bestScore, gameName, + scoreLabel, + goodScore = 5, onRetry, onExit, isNewBest = false, }: GameOverScreenProps) { const { t } = useTranslation(); const colors = useColors(); + const insets = useSafeAreaInsets(); - const slideAnim = useRef(new Animated.Value(40)).current; - const fadeAnim = useRef(new Animated.Value(0)).current; + const slideAnim = useRef(new Animated.Value(500)).current; + + const [rating, setRating] = useState(0); + const [feedback, setFeedback] = useState(''); + const [saving, setSaving] = useState(false); + const [saved, setSaved] = useState(false); + + const [shareSectionOpen, setShareSectionOpen] = useState(false); + const [shareText, setShareText] = useState(''); + const [shareTextLoading, setShareTextLoading] = useState(false); + const [sharing, setSharing] = useState(false); + 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; + const displayBest = Math.max(score, bestScore); useEffect(() => { Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch(() => {}); - Animated.parallel([ - Animated.spring(slideAnim, { toValue: 0, useNativeDriver: true, tension: 60, friction: 10 }), - Animated.timing(fadeAnim, { toValue: 1, duration: 220, useNativeDriver: true }), - ]).start(); + Animated.spring(slideAnim, { + toValue: 0, + useNativeDriver: true, + damping: 22, + stiffness: 200, + mass: 0.8, + }).start(); }, []); - const motivationalKey = MOTIVATIONAL_KEYS[score % MOTIVATIONAL_KEYS.length]!; - const fmt = (n: number) => String(n).padStart(5, '0'); + function handleExit() { + Animated.timing(slideAnim, { + toValue: 500, + duration: 220, + useNativeDriver: true, + }).start(() => onExit()); + } + + async function submitRating() { + setSaving(true); + try { + await apiFetch('/api/games/rating', { + method: 'POST', + body: { + gameName: gameName.toLowerCase(), + stars: rating, + feedback: feedback.trim() || null, + score, + }, + }); + setSaved(true); + } catch { + // endpoint not yet live — silent + } finally { + setSaving(false); + } + } + + async function openShareSection() { + setShareTextLoading(true); + setShareSectionOpen(true); + try { + const data = await apiFetch<{ text: string }>('/api/games/share-text', { + method: 'POST', + body: { + gameName: gameName.toLowerCase(), + score, + scoreLabel, + bestScore, + isNewRecord: score > bestScore, + mode: 'game', + }, + }); + setShareText(data.text || `${gameName}: ${score} ${scoreLabel ?? 'Punkte'}\n${t('gameOver.share_challenge')}`); + } catch { + setShareText(`${gameName}: ${score} ${scoreLabel ?? 'Punkte'}\n${t('gameOver.share_challenge')}`); + } finally { + setShareTextLoading(false); + } + } + + async function submitCommunityPost() { + if (!shareText.trim()) return; + setSharing(true); + setPostError(false); + try { + const scoreLine = `${scoreLabel ?? 'Score'}: ${score}`; + await apiFetch('/api/community/post', { + method: 'POST', + body: { + category: 'game_share', + content: `${gameName}\n${scoreLine}\n${shareText.trim()}`, + }, + }); + setPosted(true); + setShareSectionOpen(false); + setTimeout(() => handleExit(), 1500); + } catch (err) { + console.error('[gameover/post] failed:', err); + setPostError(true); + } finally { + setSharing(false); + } + } + + const pillBg = colors.surfaceElevated; + const pillText = colors.text; + const pillMuted = colors.textMuted; return ( - - {/* Backdrop */} - - - {/* Card */} - - {/* Title row */} - - - {t('gameOver.title')} - - - {gameName} - - - - {/* Score row */} - + + + - {/* Score */} + {/* Grab-handle */} - - {fmt(score)} - - - {t('gameOver.score')} - - + /> - {/* Best */} - - - {fmt(Math.max(score, bestScore))} - - - {isNewBest ? t('gameOver.newBest') : t('gameOver.best')} - - - + {/* Lyra avatar + message */} + + + + Lyra + + + {msg.title} + + + {msg.body} + + - {/* Motivational text */} - - {t(motivationalKey)} - + {/* Score pills */} + + + + {displayScore} + + + {scoreLabel ?? t('gameOver.score')} + + + + + {displayBest} + + + {isNewBest ? t('gameOver.newBest') : t('gameOver.best')} + + + - {/* Buttons */} - - { - Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium).catch(() => {}); - onRetry(); - }} - style={({ pressed }) => ({ - flex: 1, - paddingVertical: 13, - paddingHorizontal: 16, - borderRadius: 12, - backgroundColor: '#007AFF', - alignItems: 'center', - opacity: pressed ? 0.75 : 1, - })} - > - - {t('gameOver.retry')} - - + {/* Star rating */} + + { if (!saved) setRating(v); }} + /> + {saved ? ( + + {t('gameOver.rating_saved')} + + ) : null} + - { - Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch(() => {}); - onExit(); - }} - style={({ pressed }) => ({ - flex: 1, - paddingVertical: 13, - paddingHorizontal: 16, - borderRadius: 12, - backgroundColor: colors.surfaceElevated, - alignItems: 'center', - opacity: pressed ? 0.75 : 1, - })} - > - - {t('gameOver.exit')} - - - - - + {/* Feedback textarea + save */} + {rating > 0 && !saved ? ( + + + + + {saving ? t('common.loading') : t('gameOver.save_rating')} + + + + ) : null} + + {/* Primary action row */} + + { + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium).catch(() => {}); + onRetry(); + }} + activeOpacity={0.85} + style={{ + flex: 1, + backgroundColor: '#f59e0b', + borderRadius: 14, + minHeight: 50, + paddingVertical: 14, + paddingHorizontal: 20, + alignItems: 'center', + justifyContent: 'center', + }} + > + + {t('gameOver.retry')} + + + + { + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch(() => {}); + handleExit(); + }} + activeOpacity={0.75} + style={{ + flex: 1, + backgroundColor: '#e5e7eb', + borderRadius: 14, + minHeight: 50, + paddingVertical: 14, + paddingHorizontal: 20, + alignItems: 'center', + justifyContent: 'center', + borderWidth: 1, + borderColor: 'rgba(0,0,0,0.08)', + }} + > + + {t('gameOver.exit')} + + + + + {/* Share section */} + {posted ? ( + + + + {t('gameOver.posted')} + + + ) : !shareSectionOpen ? ( + + + + + {t('gameOver.share_result')} + + + + ) : ( + + {shareTextLoading ? ( + + + + {t('gameOver.share_loading')} + + + ) : ( + + )} + + {postError ? ( + + {t('gameOver.post_error')} + + ) : null} + + + { setShareSectionOpen(false); setShareText(''); setPostError(false); }} + activeOpacity={0.7} + style={{ + flex: 1, + backgroundColor: '#e5e7eb', + borderRadius: 14, + minHeight: 50, + paddingVertical: 14, + paddingHorizontal: 20, + alignItems: 'center', + justifyContent: 'center', + borderWidth: 1, + borderColor: 'rgba(0,0,0,0.08)', + }} + > + + {t('common.cancel')} + + + + + {sharing ? ( + + ) : ( + + )} + + {t('gameOver.post_to_community')} + + + + + )} + + + + ); } diff --git a/apps/rebreak-native/components/urge/UrgeGames.tsx b/apps/rebreak-native/components/urge/UrgeGames.tsx index 23f3b22..f1bf0e3 100644 --- a/apps/rebreak-native/components/urge/UrgeGames.tsx +++ b/apps/rebreak-native/components/urge/UrgeGames.tsx @@ -295,16 +295,17 @@ export function SnakeGame({ return ( - {/* Header */} - - {lyraMessage} - - - - + {!gameOver && ( + <> + {/* Lyra hint */} + + {lyraMessage} + - {/* Digital score dashboard */} - + {/* Digital score dashboard */} + + + )} {/* Board */} @@ -354,6 +355,8 @@ export function SnakeGame({ score={score} bestScore={highScore} gameName="Snake" + scoreLabel="Äpfel" + goodScore={10} isNewBest={isNewBest} onRetry={resetSnake} onExit={() => onAbandon()} @@ -581,6 +584,8 @@ export function MemoryGame({ score={moveCount} bestScore={bestMoves} gameName="Memory" + scoreLabel="Züge" + goodScore={30} isNewBest={isNewBestMemory} onRetry={init} onExit={() => onAbandon()} @@ -1011,14 +1016,17 @@ export function TetrisGame({ return ( - {/* Header */} - - {lyraMessage} - - + {!gameOver && ( + <> + {/* Lyra hint */} + + {lyraMessage} + - {/* Digital score dashboard */} - + {/* Digital score dashboard */} + + + )} {/* Board */} @@ -1103,6 +1111,8 @@ export function TetrisGame({ score={score} bestScore={highScore} gameName="Tetris" + scoreLabel="Punkte" + goodScore={1000} isNewBest={isNewBestTetris} onRetry={resetTetris} onExit={() => onAbandon()} diff --git a/backend/server/api/mail/status.get.ts b/backend/server/api/mail/status.get.ts index 0a604d7..230e521 100644 --- a/backend/server/api/mail/status.get.ts +++ b/backend/server/api/mail/status.get.ts @@ -23,6 +23,13 @@ export default defineEventHandler(async (event) => { c.emailsScanned > 0 ? Math.round((c.emailsBlocked / c.emailsScanned) * 100) : 0, + // Connect-Error-Status für UI (Auth-Fehler etc.) — wird beim Re-Connect via + // upsertMailConnection auf null gesetzt, bei IDLE/Scan-Failure neu geschrieben + lastConnectError: (c as { lastConnectError?: string | null }).lastConnectError ?? null, + lastConnectErrorAt: + (c as { lastConnectErrorAt?: Date | null }).lastConnectErrorAt?.toISOString() ?? null, + lastIdleHeartbeatAt: + (c as { lastIdleHeartbeatAt?: Date | null }).lastIdleHeartbeatAt?.toISOString() ?? null, })); const blocked7d = await getMailBlockedStats(user.id); diff --git a/backend/server/db/mail.ts b/backend/server/db/mail.ts index 4db90ea..c9ae815 100644 --- a/backend/server/db/mail.ts +++ b/backend/server/db/mail.ts @@ -51,6 +51,10 @@ export async function upsertMailConnection(data: { rejectUnauthorized: data.rejectUnauthorized ?? true, useStarttls: data.useStarttls ?? false, isActive: true, + // Bei Re-Connect (z.B. neues App-Passwort): alte Error-Spuren clearen, + // damit UI sofort wieder "Live" zeigt — IDLE-daemon übernimmt. + lastConnectError: null, + lastConnectErrorAt: null, }, }); }