- components/AppAlert.tsx — one parametrized alert composable (error / success / confirm), replacing scattered Alert.alert(). error mode sanitizes raw response bodies (strips HTML, detects 413/5xx/nginx → friendly generic text, raw text only in a collapsible "Details" section). Light backdrop, TouchableOpacity. - profile/AvatarCropSheet — compress the cropped avatar via expo-image-manipulator (max 512×512, JPEG q0.7 → ~50–150 KB) before upload, so the nginx 1 MB cap on staging.rebreak.org/api/ no longer 413s; compress errors shown via AppAlert. (adds expo-image-manipulator ~14.0.7 — needs a fresh dev build) - lib/protection.ts — FAMILY_CONTROLS_AVAILABLE = expoConfig.extra.familyControlsEnabled - app/(app)/blocker.tsx — App-Lock toggle only rendered when FAMILY_CONTROLS_AVAILABLE; otherwise a quiet "App-Lock — coming soon" row + "bald" badge. The URL-filter banner / ProtectionLockedCard stay positive (the filter carries the protection). - de/en strings for alert.* and blocker.app_lock_coming_soon_* Follow-ups: nginx client_max_body_size → ≥5 MB on staging (backyard, separate); ConfirmAlert/SuccessAlert call-site sweep onto AppAlert (separate). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
286 lines
7.7 KiB
TypeScript
286 lines
7.7 KiB
TypeScript
import { useRef, useState } from 'react';
|
|
import {
|
|
View,
|
|
Text,
|
|
Image,
|
|
Modal,
|
|
TouchableOpacity,
|
|
ActivityIndicator,
|
|
StyleSheet,
|
|
Dimensions,
|
|
} from 'react-native';
|
|
import {
|
|
GestureDetector,
|
|
Gesture,
|
|
GestureHandlerRootView,
|
|
} from 'react-native-gesture-handler';
|
|
import Animated, { useSharedValue, useAnimatedStyle, withSpring } from 'react-native-reanimated';
|
|
import { manipulateAsync, SaveFormat } from 'expo-image-manipulator';
|
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { useColors } from '../../lib/theme';
|
|
import { AppAlert } from '../AppAlert';
|
|
|
|
const CROP_SIZE = Math.min(Dimensions.get('window').width - 48, 320);
|
|
const SPRING = { damping: 18, stiffness: 200 };
|
|
|
|
type Props = {
|
|
imageUri: string | null;
|
|
onConfirm: (croppedUri: string) => void;
|
|
onCancel: () => void;
|
|
};
|
|
|
|
export function AvatarCropSheet({ imageUri, onConfirm, onCancel }: Props) {
|
|
const { t } = useTranslation();
|
|
const colors = useColors();
|
|
const insets = useSafeAreaInsets();
|
|
const [processing, setProcessing] = useState(false);
|
|
const [uploadError, setUploadError] = useState<string | null>(null);
|
|
|
|
const scale = useSharedValue(1);
|
|
const savedScale = useSharedValue(1);
|
|
const translateX = useSharedValue(0);
|
|
const translateY = useSharedValue(0);
|
|
const savedTranslateX = useSharedValue(0);
|
|
const savedTranslateY = useSharedValue(0);
|
|
|
|
const pinch = Gesture.Pinch()
|
|
.onUpdate((e) => {
|
|
scale.value = Math.min(Math.max(savedScale.value * e.scale, 1), 5);
|
|
})
|
|
.onEnd(() => {
|
|
savedScale.value = scale.value;
|
|
});
|
|
|
|
const pan = Gesture.Pan()
|
|
.onUpdate((e) => {
|
|
translateX.value = savedTranslateX.value + e.translationX;
|
|
translateY.value = savedTranslateY.value + e.translationY;
|
|
})
|
|
.onEnd(() => {
|
|
savedTranslateX.value = translateX.value;
|
|
savedTranslateY.value = translateY.value;
|
|
});
|
|
|
|
const composed = Gesture.Simultaneous(pinch, pan);
|
|
|
|
const imageStyle = useAnimatedStyle(() => ({
|
|
transform: [
|
|
{ translateX: translateX.value },
|
|
{ translateY: translateY.value },
|
|
{ scale: scale.value },
|
|
],
|
|
}));
|
|
|
|
function reset() {
|
|
scale.value = withSpring(1, SPRING);
|
|
savedScale.value = 1;
|
|
translateX.value = withSpring(0, SPRING);
|
|
translateY.value = withSpring(0, SPRING);
|
|
savedTranslateX.value = 0;
|
|
savedTranslateY.value = 0;
|
|
}
|
|
|
|
async function handleConfirm() {
|
|
if (!imageUri || processing) return;
|
|
setProcessing(true);
|
|
try {
|
|
const result = await manipulateAsync(
|
|
imageUri,
|
|
[{ resize: { width: 512, height: 512 } }],
|
|
{ format: SaveFormat.JPEG, compress: 0.7 },
|
|
);
|
|
onConfirm(result.uri);
|
|
} catch (e: any) {
|
|
setUploadError(e?.message ?? t('alert.error_generic'));
|
|
} finally {
|
|
setProcessing(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Modal
|
|
visible={!!imageUri}
|
|
animationType="slide"
|
|
presentationStyle="pageSheet"
|
|
onRequestClose={onCancel}
|
|
>
|
|
<GestureHandlerRootView style={{ flex: 1 }}>
|
|
<View style={[styles.container, { backgroundColor: colors.bg, paddingBottom: insets.bottom + 16 }]}>
|
|
<View style={[styles.header, { borderBottomColor: colors.border }]}>
|
|
<TouchableOpacity onPress={onCancel} hitSlop={10} activeOpacity={0.6} style={styles.headerBtn}>
|
|
<Text style={[styles.headerBtnText, { color: colors.textMuted }]}>
|
|
{t('common.cancel')}
|
|
</Text>
|
|
</TouchableOpacity>
|
|
<Text style={[styles.headerTitle, { color: colors.text }]}>
|
|
{t('profile.crop_title')}
|
|
</Text>
|
|
<TouchableOpacity
|
|
onPress={handleConfirm}
|
|
hitSlop={10}
|
|
activeOpacity={0.6}
|
|
style={styles.headerBtn}
|
|
disabled={processing}
|
|
>
|
|
{processing ? (
|
|
<ActivityIndicator size="small" color={colors.brandOrange} />
|
|
) : (
|
|
<Text style={[styles.headerBtnText, { color: colors.brandOrange, fontFamily: 'Nunito_700Bold' }]}>
|
|
{t('profile.crop_confirm')}
|
|
</Text>
|
|
)}
|
|
</TouchableOpacity>
|
|
</View>
|
|
|
|
<View style={styles.body}>
|
|
<View style={[styles.cropFrame, { width: CROP_SIZE, height: CROP_SIZE }]}>
|
|
<View style={styles.cropOverflow}>
|
|
<GestureDetector gesture={composed}>
|
|
<Animated.View style={imageStyle}>
|
|
{imageUri ? (
|
|
<Image
|
|
source={{ uri: imageUri }}
|
|
style={{ width: CROP_SIZE, height: CROP_SIZE }}
|
|
resizeMode="cover"
|
|
/>
|
|
) : null}
|
|
</Animated.View>
|
|
</GestureDetector>
|
|
</View>
|
|
|
|
<View style={styles.cornerTL} />
|
|
<View style={styles.cornerTR} />
|
|
<View style={styles.cornerBL} />
|
|
<View style={styles.cornerBR} />
|
|
</View>
|
|
|
|
<Text style={[styles.hint, { color: colors.textMuted }]}>
|
|
{t('profile.crop_hint')}
|
|
</Text>
|
|
|
|
<TouchableOpacity onPress={reset} activeOpacity={0.7} style={styles.resetBtn}>
|
|
<Text style={[styles.resetText, { color: colors.textMuted }]}>
|
|
{t('profile.crop_reset')}
|
|
</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
</View>
|
|
</GestureHandlerRootView>
|
|
|
|
<AppAlert
|
|
mode="error"
|
|
visible={uploadError !== null}
|
|
title={t('alert.compress_error_title')}
|
|
message={uploadError ?? ''}
|
|
onClose={() => setUploadError(null)}
|
|
/>
|
|
</Modal>
|
|
);
|
|
}
|
|
|
|
const CORNER = 18;
|
|
const BORDER = 2.5;
|
|
|
|
const styles = StyleSheet.create({
|
|
container: {
|
|
flex: 1,
|
|
},
|
|
header: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
paddingHorizontal: 16,
|
|
paddingVertical: 14,
|
|
borderBottomWidth: StyleSheet.hairlineWidth,
|
|
},
|
|
headerTitle: {
|
|
fontSize: 16,
|
|
fontFamily: 'Nunito_700Bold',
|
|
},
|
|
headerBtn: {
|
|
minWidth: 64,
|
|
alignItems: 'center',
|
|
},
|
|
headerBtnText: {
|
|
fontSize: 15,
|
|
fontFamily: 'Nunito_600SemiBold',
|
|
},
|
|
body: {
|
|
flex: 1,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
paddingHorizontal: 24,
|
|
gap: 20,
|
|
},
|
|
cropFrame: {
|
|
borderRadius: 12,
|
|
overflow: 'hidden',
|
|
position: 'relative',
|
|
backgroundColor: '#111',
|
|
},
|
|
cropOverflow: {
|
|
width: '100%',
|
|
height: '100%',
|
|
overflow: 'hidden',
|
|
},
|
|
cornerTL: {
|
|
position: 'absolute',
|
|
top: 0,
|
|
left: 0,
|
|
width: CORNER,
|
|
height: CORNER,
|
|
borderTopWidth: BORDER,
|
|
borderLeftWidth: BORDER,
|
|
borderColor: '#fff',
|
|
borderTopLeftRadius: 12,
|
|
},
|
|
cornerTR: {
|
|
position: 'absolute',
|
|
top: 0,
|
|
right: 0,
|
|
width: CORNER,
|
|
height: CORNER,
|
|
borderTopWidth: BORDER,
|
|
borderRightWidth: BORDER,
|
|
borderColor: '#fff',
|
|
borderTopRightRadius: 12,
|
|
},
|
|
cornerBL: {
|
|
position: 'absolute',
|
|
bottom: 0,
|
|
left: 0,
|
|
width: CORNER,
|
|
height: CORNER,
|
|
borderBottomWidth: BORDER,
|
|
borderLeftWidth: BORDER,
|
|
borderColor: '#fff',
|
|
borderBottomLeftRadius: 12,
|
|
},
|
|
cornerBR: {
|
|
position: 'absolute',
|
|
bottom: 0,
|
|
right: 0,
|
|
width: CORNER,
|
|
height: CORNER,
|
|
borderBottomWidth: BORDER,
|
|
borderRightWidth: BORDER,
|
|
borderColor: '#fff',
|
|
borderBottomRightRadius: 12,
|
|
},
|
|
hint: {
|
|
fontSize: 12,
|
|
fontFamily: 'Nunito_400Regular',
|
|
textAlign: 'center',
|
|
lineHeight: 17,
|
|
},
|
|
resetBtn: {
|
|
paddingVertical: 8,
|
|
paddingHorizontal: 16,
|
|
},
|
|
resetText: {
|
|
fontSize: 13,
|
|
fontFamily: 'Nunito_600SemiBold',
|
|
},
|
|
});
|