fix(mail): clear connect-error on re-connect + return error fields in status
- 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) <noreply@anthropic.com>
This commit is contained in:
parent
347ad1f6c5
commit
6afffdbb18
@ -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<T extends string> = { value: T; label: string };
|
||||
|
||||
type SectionRow = {
|
||||
icon: React.ComponentProps<typeof Ionicons>['name'];
|
||||
/** Ionicons-name ODER eigenes SVG-Component (für custom icons wie LanguageIcon) */
|
||||
icon: React.ComponentProps<typeof Ionicons>['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 ? (
|
||||
<IconComponent size={18} color={iconColor} />
|
||||
) : (
|
||||
<Ionicons
|
||||
name={row.icon}
|
||||
name={row.icon as React.ComponentProps<typeof Ionicons>['name']}
|
||||
size={18}
|
||||
color={row.destructive ? colors.error : colors.textMuted}
|
||||
color={iconColor}
|
||||
/>
|
||||
)}
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
|
||||
@ -1,165 +1,232 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { Animated, Pressable, Text, View } from 'react-native';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Animated,
|
||||
Modal,
|
||||
ScrollView,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { RiveAvatar } from '../RiveAvatar';
|
||||
import { StarRating } from './StarRating';
|
||||
import { useColors } from '../../lib/theme';
|
||||
import { apiFetch } from '../../lib/api';
|
||||
|
||||
export type GameOverScreenProps = {
|
||||
score: number;
|
||||
bestScore: number;
|
||||
gameName: string;
|
||||
scoreLabel?: string;
|
||||
goodScore?: number;
|
||||
onRetry: () => 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 (
|
||||
<Animated.View
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 24,
|
||||
opacity: fadeAnim,
|
||||
}}
|
||||
>
|
||||
{/* Backdrop */}
|
||||
<Pressable
|
||||
onPress={onExit}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
backgroundColor: 'rgba(0,0,0,0.55)',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Card */}
|
||||
<Modal visible transparent animationType="none" onRequestClose={handleExit}>
|
||||
<View style={{ flex: 1, justifyContent: 'flex-end' }}>
|
||||
<TouchableOpacity onPress={handleExit} activeOpacity={1} style={{ flex: 1 }} />
|
||||
<Animated.View
|
||||
style={{
|
||||
transform: [{ translateY: slideAnim }],
|
||||
width: '100%',
|
||||
maxWidth: 340,
|
||||
backgroundColor: colors.surface,
|
||||
borderRadius: 20,
|
||||
borderTopLeftRadius: 28,
|
||||
borderTopRightRadius: 28,
|
||||
paddingTop: 12,
|
||||
paddingHorizontal: 20,
|
||||
paddingTop: 24,
|
||||
paddingBottom: 20,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 8 },
|
||||
shadowOpacity: 0.18,
|
||||
shadowRadius: 20,
|
||||
elevation: 12,
|
||||
gap: 16,
|
||||
paddingBottom: insets.bottom + 24,
|
||||
}}
|
||||
>
|
||||
{/* Title row */}
|
||||
<View style={{ alignItems: 'center', gap: 4 }}>
|
||||
<Text
|
||||
{/* Grab-handle */}
|
||||
<View
|
||||
style={{
|
||||
fontFamily: 'Nunito_800ExtraBold',
|
||||
fontSize: 22,
|
||||
color: colors.text,
|
||||
textAlign: 'center',
|
||||
alignSelf: 'center',
|
||||
width: 36,
|
||||
height: 5,
|
||||
borderRadius: 3,
|
||||
backgroundColor: colors.textMuted,
|
||||
opacity: 0.3,
|
||||
marginBottom: 16,
|
||||
}}
|
||||
/>
|
||||
|
||||
<ScrollView
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={{ gap: 16, paddingBottom: 8 }}
|
||||
>
|
||||
{t('gameOver.title')}
|
||||
{/* Lyra avatar + message */}
|
||||
<View style={{ alignItems: 'center', gap: 8 }}>
|
||||
<RiveAvatar emotion={emotion} size="md" />
|
||||
<Text style={{ fontSize: 11, color: colors.textMuted, fontFamily: 'Nunito_600SemiBold' }}>
|
||||
Lyra
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
fontFamily: 'Nunito_400Regular',
|
||||
fontSize: 13,
|
||||
color: colors.textMuted,
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{gameName}
|
||||
<Text style={{ fontFamily: 'Nunito_800ExtraBold', fontSize: 18, color: colors.text, textAlign: 'center' }}>
|
||||
{msg.title}
|
||||
</Text>
|
||||
<Text style={{ fontFamily: 'Nunito_400Regular', fontSize: 13, color: colors.textMuted, textAlign: 'center', lineHeight: 18, paddingHorizontal: 4 }}>
|
||||
{msg.body}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Score row */}
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
{/* Score */}
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
backgroundColor: colors.surfaceElevated,
|
||||
borderRadius: 14,
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 8,
|
||||
alignItems: 'center',
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontFamily: 'Courier New' as any,
|
||||
fontSize: 22,
|
||||
color: '#00e680',
|
||||
letterSpacing: 2,
|
||||
fontVariant: ['tabular-nums'],
|
||||
}}
|
||||
>
|
||||
{fmt(score)}
|
||||
{/* Score pills */}
|
||||
<View style={{ flexDirection: 'row', justifyContent: 'center', gap: 10 }}>
|
||||
<View style={{ flex: 1, backgroundColor: pillBg, borderRadius: 14, paddingVertical: 12, paddingHorizontal: 8, alignItems: 'center', gap: 2 }}>
|
||||
<Text style={{ fontFamily: 'Nunito_800ExtraBold', fontSize: 20, color: pillText }}>
|
||||
{displayScore}
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 10,
|
||||
color: colors.textMuted,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 1,
|
||||
fontFamily: 'Nunito_600SemiBold',
|
||||
}}
|
||||
>
|
||||
{t('gameOver.score')}
|
||||
<Text style={{ fontSize: 10, color: pillMuted, textTransform: 'uppercase', letterSpacing: 1, fontFamily: 'Nunito_600SemiBold' }}>
|
||||
{scoreLabel ?? t('gameOver.score')}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Best */}
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
backgroundColor: isNewBest ? '#fef3c7' : colors.surfaceElevated,
|
||||
backgroundColor: isNewBest ? '#fef3c7' : pillBg,
|
||||
borderRadius: 14,
|
||||
borderWidth: isNewBest ? 1.5 : 0,
|
||||
borderColor: isNewBest ? '#f59e0b' : 'transparent',
|
||||
@ -169,88 +236,233 @@ export function GameOverScreen({
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontFamily: 'Courier New' as any,
|
||||
fontSize: 22,
|
||||
color: isNewBest ? '#d97706' : colors.textMuted,
|
||||
letterSpacing: 2,
|
||||
fontVariant: ['tabular-nums'],
|
||||
}}
|
||||
>
|
||||
{fmt(Math.max(score, bestScore))}
|
||||
<Text style={{ fontFamily: 'Nunito_800ExtraBold', fontSize: 20, color: isNewBest ? '#d97706' : pillMuted }}>
|
||||
{displayBest}
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 10,
|
||||
color: isNewBest ? '#d97706' : colors.textMuted,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 1,
|
||||
fontFamily: 'Nunito_600SemiBold',
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontSize: 10, color: isNewBest ? '#d97706' : pillMuted, textTransform: 'uppercase', letterSpacing: 1, fontFamily: 'Nunito_600SemiBold' }}>
|
||||
{isNewBest ? t('gameOver.newBest') : t('gameOver.best')}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Motivational text */}
|
||||
<Text
|
||||
{/* Star rating */}
|
||||
<View style={{ alignItems: 'center', gap: 6 }}>
|
||||
<StarRating
|
||||
value={rating}
|
||||
size="lg"
|
||||
interactive={!saved}
|
||||
filledColor="#f59e0b"
|
||||
onChange={(v) => { if (!saved) setRating(v); }}
|
||||
/>
|
||||
{saved ? (
|
||||
<Text style={{ fontSize: 11, color: colors.textMuted, fontFamily: 'Nunito_400Regular' }}>
|
||||
{t('gameOver.rating_saved')}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
{/* Feedback textarea + save */}
|
||||
{rating > 0 && !saved ? (
|
||||
<View style={{ gap: 8 }}>
|
||||
<TextInput
|
||||
value={feedback}
|
||||
onChangeText={setFeedback}
|
||||
placeholder={t('gameOver.feedback_placeholder')}
|
||||
placeholderTextColor={colors.textMuted}
|
||||
multiline
|
||||
numberOfLines={2}
|
||||
style={{
|
||||
backgroundColor: colors.surfaceElevated,
|
||||
borderRadius: 12,
|
||||
padding: 12,
|
||||
fontSize: 13,
|
||||
color: colors.textMuted,
|
||||
textAlign: 'center',
|
||||
lineHeight: 19,
|
||||
fontFamily: 'Nunito_400Regular',
|
||||
paddingHorizontal: 4,
|
||||
color: colors.text,
|
||||
minHeight: 56,
|
||||
textAlignVertical: 'top',
|
||||
}}
|
||||
/>
|
||||
<TouchableOpacity
|
||||
onPress={submitRating}
|
||||
disabled={saving}
|
||||
activeOpacity={0.7}
|
||||
style={{
|
||||
backgroundColor: '#f59e0b',
|
||||
borderRadius: 14,
|
||||
minHeight: 50,
|
||||
paddingVertical: 14,
|
||||
paddingHorizontal: 20,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
opacity: saving ? 0.65 : 1,
|
||||
}}
|
||||
>
|
||||
{t(motivationalKey)}
|
||||
<Text style={{ fontFamily: 'Nunito_700Bold', fontSize: 16, color: '#ffffff' }}>
|
||||
{saving ? t('common.loading') : t('gameOver.save_rating')}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{/* Buttons */}
|
||||
<View style={{ flexDirection: 'row', gap: 10 }}>
|
||||
<Pressable
|
||||
{/* Primary action row */}
|
||||
<View style={{ flexDirection: 'row', gap: 12 }}>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium).catch(() => {});
|
||||
onRetry();
|
||||
}}
|
||||
style={({ pressed }) => ({
|
||||
activeOpacity={0.85}
|
||||
style={{
|
||||
flex: 1,
|
||||
paddingVertical: 13,
|
||||
paddingHorizontal: 16,
|
||||
borderRadius: 12,
|
||||
backgroundColor: '#007AFF',
|
||||
backgroundColor: '#f59e0b',
|
||||
borderRadius: 14,
|
||||
minHeight: 50,
|
||||
paddingVertical: 14,
|
||||
paddingHorizontal: 20,
|
||||
alignItems: 'center',
|
||||
opacity: pressed ? 0.75 : 1,
|
||||
})}
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontFamily: 'Nunito_700Bold', fontSize: 15, color: '#ffffff' }}>
|
||||
<Text style={{ fontFamily: 'Nunito_700Bold', fontSize: 16, color: '#ffffff' }}>
|
||||
{t('gameOver.retry')}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</TouchableOpacity>
|
||||
|
||||
<Pressable
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch(() => {});
|
||||
onExit();
|
||||
handleExit();
|
||||
}}
|
||||
style={({ pressed }) => ({
|
||||
activeOpacity={0.75}
|
||||
style={{
|
||||
flex: 1,
|
||||
paddingVertical: 13,
|
||||
paddingHorizontal: 16,
|
||||
borderRadius: 12,
|
||||
backgroundColor: colors.surfaceElevated,
|
||||
backgroundColor: '#e5e7eb',
|
||||
borderRadius: 14,
|
||||
minHeight: 50,
|
||||
paddingVertical: 14,
|
||||
paddingHorizontal: 20,
|
||||
alignItems: 'center',
|
||||
opacity: pressed ? 0.75 : 1,
|
||||
})}
|
||||
justifyContent: 'center',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(0,0,0,0.08)',
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontFamily: 'Nunito_700Bold', fontSize: 15, color: colors.textMuted }}>
|
||||
<Text style={{ fontFamily: 'Nunito_700Bold', fontSize: 16, color: '#374151' }}>
|
||||
{t('gameOver.exit')}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Share section */}
|
||||
{posted ? (
|
||||
<View style={{ alignItems: 'center', paddingVertical: 4, flexDirection: 'row', justifyContent: 'center', gap: 6 }}>
|
||||
<Ionicons name="checkmark-circle" size={15} color={colors.success} />
|
||||
<Text style={{ fontSize: 13, color: colors.success, fontFamily: 'Nunito_600SemiBold' }}>
|
||||
{t('gameOver.posted')}
|
||||
</Text>
|
||||
</View>
|
||||
) : !shareSectionOpen ? (
|
||||
<TouchableOpacity
|
||||
onPress={openShareSection}
|
||||
activeOpacity={0.6}
|
||||
style={{ alignItems: 'center', paddingVertical: 4 }}
|
||||
>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
|
||||
<Ionicons name="people-outline" size={15} color={colors.textMuted} />
|
||||
<Text style={{ fontSize: 13, color: colors.textMuted, fontFamily: 'Nunito_600SemiBold' }}>
|
||||
{t('gameOver.share_result')}
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<View style={{ gap: 10 }}>
|
||||
{shareTextLoading ? (
|
||||
<View style={{ alignItems: 'center', paddingVertical: 12 }}>
|
||||
<ActivityIndicator size="small" color={colors.textMuted} />
|
||||
<Text style={{ fontSize: 12, color: colors.textMuted, fontFamily: 'Nunito_400Regular', marginTop: 6 }}>
|
||||
{t('gameOver.share_loading')}
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<TextInput
|
||||
value={shareText}
|
||||
onChangeText={setShareText}
|
||||
multiline
|
||||
numberOfLines={4}
|
||||
style={{
|
||||
backgroundColor: colors.surfaceElevated,
|
||||
borderRadius: 14,
|
||||
padding: 14,
|
||||
fontSize: 14,
|
||||
fontFamily: 'Nunito_400Regular',
|
||||
color: colors.text,
|
||||
minHeight: 100,
|
||||
textAlignVertical: 'top',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{postError ? (
|
||||
<Text style={{ fontSize: 12, color: colors.error, fontFamily: 'Nunito_600SemiBold', textAlign: 'center' }}>
|
||||
{t('gameOver.post_error')}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
<View style={{ flexDirection: 'row', gap: 12 }}>
|
||||
<TouchableOpacity
|
||||
onPress={() => { 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)',
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontFamily: 'Nunito_700Bold', fontSize: 16, color: '#374151' }}>
|
||||
{t('common.cancel')}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={submitCommunityPost}
|
||||
disabled={!shareText.trim() || sharing || shareTextLoading}
|
||||
activeOpacity={0.85}
|
||||
style={{
|
||||
flex: 1,
|
||||
backgroundColor: '#f59e0b',
|
||||
borderRadius: 14,
|
||||
minHeight: 50,
|
||||
paddingVertical: 14,
|
||||
paddingHorizontal: 20,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexDirection: 'row',
|
||||
gap: 6,
|
||||
opacity: sharing || !shareText.trim() || shareTextLoading ? 0.55 : 1,
|
||||
}}
|
||||
>
|
||||
{sharing ? (
|
||||
<ActivityIndicator size="small" color="#ffffff" />
|
||||
) : (
|
||||
<Ionicons name="paper-plane-outline" size={16} color="#ffffff" />
|
||||
)}
|
||||
<Text style={{ fontFamily: 'Nunito_700Bold', fontSize: 16, color: '#ffffff' }}>
|
||||
{t('gameOver.post_to_community')}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@ -295,16 +295,17 @@ export function SnakeGame({
|
||||
|
||||
return (
|
||||
<View style={{ paddingHorizontal: 16, paddingTop: 4, paddingBottom: Math.max(insets.bottom, 16), position: 'relative' }}>
|
||||
{/* Header */}
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginBottom: 8 }}>
|
||||
<Text style={{ fontSize: 11, color: '#6b7280', flex: 1, marginRight: 8 }} numberOfLines={2}>{lyraMessage}</Text>
|
||||
<Pressable onPress={onAbandon} hitSlop={10} style={{ width: 28, height: 28, alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Text style={{ fontSize: 18, color: '#6b7280' }}>✕</Text>
|
||||
</Pressable>
|
||||
{!gameOver && (
|
||||
<>
|
||||
{/* Lyra hint */}
|
||||
<View style={{ marginBottom: 8 }}>
|
||||
<Text style={{ fontSize: 11, color: '#6b7280' }} numberOfLines={2}>{lyraMessage}</Text>
|
||||
</View>
|
||||
|
||||
{/* Digital score dashboard */}
|
||||
<DigitalScore score={score} best={highScore} boardWidth={boardW} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Board */}
|
||||
<View style={{ alignItems: 'center' }} {...panResponder.panHandlers}>
|
||||
@ -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 (
|
||||
<View style={{ paddingHorizontal: 16, paddingTop: 4, paddingBottom: Math.max(insets.bottom, 16), position: 'relative' }}>
|
||||
{/* Header */}
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginBottom: 8 }}>
|
||||
<Text style={{ fontSize: 11, color: '#6b7280', flex: 1, marginRight: 8 }} numberOfLines={2}>{lyraMessage}</Text>
|
||||
<Pressable onPress={onAbandon} hitSlop={10}><Text style={{ fontSize: 18, color: '#6b7280' }}>✕</Text></Pressable>
|
||||
{!gameOver && (
|
||||
<>
|
||||
{/* Lyra hint */}
|
||||
<View style={{ marginBottom: 8 }}>
|
||||
<Text style={{ fontSize: 11, color: '#6b7280' }} numberOfLines={2}>{lyraMessage}</Text>
|
||||
</View>
|
||||
|
||||
{/* Digital score dashboard */}
|
||||
<DigitalScore score={score} best={highScore} extra={level} extraLabel="LVL" boardWidth={boardWidth} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Board */}
|
||||
<View style={{ alignItems: 'center', marginVertical: 4 }}>
|
||||
@ -1103,6 +1111,8 @@ export function TetrisGame({
|
||||
score={score}
|
||||
bestScore={highScore}
|
||||
gameName="Tetris"
|
||||
scoreLabel="Punkte"
|
||||
goodScore={1000}
|
||||
isNewBest={isNewBestTetris}
|
||||
onRetry={resetTetris}
|
||||
onExit={() => onAbandon()}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user