2026-05-11 16:44:26 +02:00

715 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useRef, useState } from 'react';
import {
Modal,
View,
Text,
TouchableOpacity,
ScrollView,
Dimensions,
Animated,
PanResponder,
ActivityIndicator,
Easing,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons';
import { useTranslation } from 'react-i18next';
import Svg, { Path, Circle } from 'react-native-svg';
import type { ProtectionState } from '../../lib/protection';
import { apiFetch } from '../../lib/api';
import { useColors } from '../../lib/theme';
type Props = {
visible: boolean;
state: ProtectionState;
onClose: () => void;
onRequestDeactivation: () => void;
onTalkToLyra: () => void;
};
type StatsResponse = {
current: number;
weeklyAdded: number;
monthlyAdded: number;
history: { label: string; count: number }[];
submissions: { inVote: number; inReview: number };
mySubmissions: { active: number; inVote: number; inReview: number };
avgPerUser: number;
avgApprovalWaitDays: number;
};
const SCREEN_HEIGHT = Dimensions.get('window').height;
const DEFAULT_HEIGHT = SCREEN_HEIGHT * 0.9;
const EXPANDED_HEIGHT = SCREEN_HEIGHT * 0.95;
const MIN_HEIGHT = SCREEN_HEIGHT * 0.4;
const DISMISS_HEIGHT = SCREEN_HEIGHT * 0.3;
// Brand colors
const HERO_COLOR = '#f97316'; // orange-500 (counter accent)
const SEG_ACTIVE = '#16a34a';
const SEG_VOTE = '#3b82f6';
const SEG_REVIEW = '#f59e0b';
export function ProtectionDetailsSheet({
visible,
state,
onClose,
onRequestDeactivation,
}: Props) {
const { t, i18n } = useTranslation();
const colors = useColors();
const insets = useSafeAreaInsets();
const localeTag = i18n.language === 'de' ? 'de-DE' : 'en-US';
const sheetHeight = useRef(new Animated.Value(DEFAULT_HEIGHT)).current;
const dismissY = useRef(new Animated.Value(0)).current;
const currentHeight = useRef(DEFAULT_HEIGHT);
useEffect(() => {
if (visible) {
sheetHeight.setValue(DEFAULT_HEIGHT);
dismissY.setValue(0);
currentHeight.current = DEFAULT_HEIGHT;
}
}, [visible, sheetHeight, dismissY]);
const handleClose = () => {
sheetHeight.setValue(DEFAULT_HEIGHT);
dismissY.setValue(0);
currentHeight.current = DEFAULT_HEIGHT;
onClose();
};
const panResponder = useRef(
PanResponder.create({
onStartShouldSetPanResponder: () => true,
onMoveShouldSetPanResponder: () => true,
onPanResponderTerminationRequest: () => false,
onPanResponderMove: (_, g) => {
const next = currentHeight.current - g.dy;
const clamped = Math.max(DISMISS_HEIGHT - 60, Math.min(EXPANDED_HEIGHT + 20, next));
sheetHeight.setValue(clamped);
},
onPanResponderRelease: (_, g) => {
const finalH = currentHeight.current - g.dy;
const velocity = g.vy;
if (finalH < DISMISS_HEIGHT || velocity > 1.5) {
Animated.timing(dismissY, {
toValue: SCREEN_HEIGHT,
duration: 200,
useNativeDriver: true,
}).start(() => handleClose());
return;
}
let target = finalH;
if (velocity < -1.5) target = EXPANDED_HEIGHT;
const clamped = Math.max(MIN_HEIGHT, Math.min(EXPANDED_HEIGHT, target));
Animated.spring(sheetHeight, {
toValue: clamped,
useNativeDriver: false,
friction: 9,
tension: 70,
}).start();
currentHeight.current = clamped;
},
}),
).current;
const [stats, setStats] = useState<StatsResponse | null>(null);
const [loadingStats, setLoadingStats] = useState(false);
useEffect(() => {
if (!visible) return;
let alive = true;
setLoadingStats(true);
apiFetch<StatsResponse>('/api/blocklist/stats')
.then((res) => { if (alive) setStats(res); })
.catch(() => { /* silent */ })
.finally(() => { if (alive) setLoadingStats(false); });
return () => { alive = false; };
}, [visible]);
const globalCount = stats?.current ?? state.blocklistCount;
const weeklyAdded = stats?.weeklyAdded ?? 0;
const monthlyAdded = stats?.monthlyAdded ?? 0;
const myActive = stats?.mySubmissions?.active ?? 0;
const myInVote = stats?.mySubmissions?.inVote ?? 0;
const myInReview = stats?.mySubmissions?.inReview ?? 0;
const avgPerUser = stats?.avgPerUser ?? 0;
const avgWait = stats?.avgApprovalWaitDays ?? 0;
return (
<Modal
visible={visible}
transparent
animationType="slide"
onRequestClose={handleClose}
statusBarTranslucent
>
<TouchableOpacity
onPress={handleClose}
activeOpacity={1}
style={{ flex: 1, backgroundColor: 'rgba(0,0,0,0.45)' }}
/>
<Animated.View
style={{
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
height: sheetHeight,
}}
>
<Animated.View
style={{
flex: 1,
backgroundColor: colors.bg,
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
overflow: 'hidden',
transform: [{ translateY: dismissY }],
shadowColor: '#000',
shadowOffset: { width: 0, height: -2 },
shadowOpacity: 0.08,
shadowRadius: 8,
}}
>
{/* Drag-Bar */}
<View
{...panResponder.panHandlers}
style={{ alignItems: 'center', paddingTop: 8, paddingBottom: 6 }}
>
<View style={{ width: 36, height: 5, borderRadius: 3, backgroundColor: colors.border }} />
</View>
{/* Header */}
<View
{...panResponder.panHandlers}
style={{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 20,
paddingTop: 4,
paddingBottom: 12,
borderBottomWidth: 1,
borderBottomColor: colors.border,
}}
>
<View style={{ width: 50 }} />
<Text style={{ fontSize: 16, fontFamily: 'Nunito_700Bold', color: colors.text }}>
{t('blocker.details_title')}
</Text>
<TouchableOpacity onPress={handleClose} hitSlop={10} activeOpacity={0.6} style={{ width: 50, alignItems: 'flex-end' }}>
<Text style={{ fontSize: 16, fontFamily: 'Nunito_400Regular', color: colors.textMuted }}>
{t('blocker.details_done')}
</Text>
</TouchableOpacity>
</View>
<ScrollView
contentContainerStyle={{ padding: 20, paddingBottom: Math.max(insets.bottom, 32) + 56, gap: 18 }}
showsVerticalScrollIndicator
>
{loadingStats && !stats ? (
<View style={{ padding: 40, alignItems: 'center' }}>
<ActivityIndicator color="#737373" />
</View>
) : null}
{/* HERO Globale geblockte Domains: Counter (slow, color) + 2 Delta-Badges */}
<View
style={{
padding: 20,
borderRadius: 16,
backgroundColor: '#0a0a0a',
gap: 14,
}}
>
<View>
<Text style={{ fontSize: 11, color: '#a3a3a3', fontFamily: 'Nunito_700Bold', letterSpacing: 0.5, textTransform: 'uppercase' }}>
{t('blocker.kpi_global_label')}
</Text>
<AnimatedCounter
value={globalCount}
locale={localeTag}
durationMs={2400}
style={{ fontSize: 38, color: HERO_COLOR, fontFamily: 'Nunito_900Black', letterSpacing: -1, marginTop: 2 }}
/>
</View>
<View style={{ flexDirection: 'row', gap: 10 }}>
<DeltaBadge value={weeklyAdded} label={t('blocker.delta_week')} locale={localeTag} />
<DeltaBadge value={monthlyAdded} label={t('blocker.delta_month')} locale={localeTag} />
</View>
</View>
{/* SUBMISSIONS Half Donut mit center-number + center-legend */}
<View
style={{
padding: 18,
borderRadius: 16,
borderWidth: 1,
borderColor: colors.border,
backgroundColor: colors.bg,
gap: 8,
}}
>
<View>
<Text style={{ fontSize: 13, color: colors.text, fontFamily: 'Nunito_700Bold' }}>
{t('blocker.kpi_submissions_title')}
</Text>
<Text style={{ fontSize: 11, color: colors.textMuted, fontFamily: 'Nunito_400Regular', marginTop: 2 }}>
{t('blocker.kpi_submissions_subtitle')}
</Text>
</View>
<HalfDonut
segments={[
{ value: myActive, color: SEG_ACTIVE },
{ value: myInVote, color: SEG_VOTE },
{ value: myInReview, color: SEG_REVIEW },
]}
centerValue={myActive + myInVote + myInReview}
centerLabel={t('blocker.kpi_my_submissions')}
/>
{/* Centered Legend */}
<View
style={{
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'flex-start',
gap: 14,
marginTop: 4,
}}
>
<LegendItem color={SEG_ACTIVE} label={t('blocker.kpi_status_active')} value={myActive} />
<LegendItem color={SEG_VOTE} label={t('blocker.kpi_status_vote')} value={myInVote} />
<LegendItem color={SEG_REVIEW} label={t('blocker.kpi_status_review')} value={myInReview} />
</View>
</View>
{/* AVG KPIs kleiner */}
<View style={{ flexDirection: 'row', gap: 10 }}>
<KpiCard
icon="person-circle-outline"
label={t('blocker.kpi_avg_per_user')}
value={avgPerUser}
locale={localeTag}
decimals={1}
/>
<KpiCard
icon="time-outline"
label={t('blocker.kpi_avg_wait')}
value={avgWait}
locale={localeTag}
decimals={1}
suffix={t('blocker.kpi_days_suffix')}
/>
</View>
{/* FAQ-Banner: Heading-Row mit Help-Icon rechts (kein gestapeltes Layout) */}
<View style={{ alignSelf: 'stretch', gap: 8, marginTop: 4 }}>
<View
style={{
alignSelf: 'stretch',
flexDirection: 'row',
alignItems: 'center',
marginBottom: 4,
}}
>
<Text
style={{
flex: 1,
fontSize: 11,
fontFamily: 'Nunito_700Bold',
color: colors.textMuted,
letterSpacing: 0.5,
textTransform: 'uppercase',
}}
>
{t('blocker.faq_heading')}
</Text>
<Ionicons name="help-circle-outline" size={18} color={colors.textMuted} />
</View>
{[1, 2, 3, 4].map((n) => (
<FaqItem
key={n}
question={t(`blocker.faq${n}_q`)}
answer={t(`blocker.faq${n}_a`)}
/>
))}
</View>
{/* MEHR INFO outline button: TouchableOpacity=card, inner View=flex-row */}
<TouchableOpacity
onPress={onRequestDeactivation}
activeOpacity={0.75}
style={{ marginTop: 4 }}
>
<View style={{
paddingVertical: 14,
paddingHorizontal: 16,
borderRadius: 12,
borderWidth: 1.5,
borderColor: HERO_COLOR,
backgroundColor: colors.surface,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
}}>
<Ionicons name="information-circle-outline" size={18} color={HERO_COLOR} />
<Text style={{ fontSize: 14, fontFamily: 'Nunito_700Bold', color: HERO_COLOR }}>
{t('blocker.more_info_title')}
</Text>
</View>
</TouchableOpacity>
</ScrollView>
</Animated.View>
</Animated.View>
</Modal>
);
}
// ─── Animated Counter ──────────────────────────────────────────────────────
function AnimatedCounter({
value,
locale,
decimals = 0,
durationMs = 1200,
style,
}: {
value: number;
locale: string;
decimals?: number;
durationMs?: number;
style?: any;
}) {
const anim = useRef(new Animated.Value(0)).current;
const [display, setDisplay] = useState(0);
useEffect(() => {
anim.setValue(0);
const listener = anim.addListener(({ value: v }) => {
setDisplay(v * value);
});
Animated.timing(anim, {
toValue: 1,
duration: durationMs,
easing: Easing.out(Easing.cubic),
useNativeDriver: false,
}).start();
return () => anim.removeListener(listener);
}, [value, anim, durationMs]);
const formatted = display.toLocaleString(locale, {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
});
return <Text style={style}>{formatted}</Text>;
}
// ─── Delta Badge (e.g. "+25 diese Woche ↗") ────────────────────────────────
function DeltaBadge({
value,
label,
locale,
}: {
value: number;
label: string;
locale: string;
}) {
const formatted = `+${value.toLocaleString(locale)}`;
return (
<View
style={{
flex: 1,
flexDirection: 'row',
alignItems: 'center',
gap: 8,
paddingVertical: 10,
paddingHorizontal: 12,
borderRadius: 10,
backgroundColor: '#1f1f1f',
borderWidth: 1,
borderColor: '#262626',
}}
>
<View
style={{
width: 24,
height: 24,
borderRadius: 12,
backgroundColor: 'rgba(22,163,74,0.15)',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Ionicons name="arrow-up" size={14} color="#22c55e" />
</View>
<View style={{ flex: 1 }}>
<Text style={{ fontSize: 13, color: '#fff', fontFamily: 'Nunito_900Black', letterSpacing: -0.3 }}>
{formatted}
</Text>
<Text style={{ fontSize: 10, color: '#a3a3a3', fontFamily: 'Nunito_400Regular' }}>
{label}
</Text>
</View>
</View>
);
}
// ─── KPI Card (small) ──────────────────────────────────────────────────────
function KpiCard({
icon,
label,
value,
locale,
decimals = 0,
suffix,
}: {
icon: any;
label: string;
value: number;
locale: string;
decimals?: number;
suffix?: string;
}) {
const colors = useColors();
return (
<View
style={{
flex: 1,
padding: 12,
borderRadius: 12,
borderWidth: 1,
borderColor: colors.border,
backgroundColor: colors.surface,
gap: 6,
}}
>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
<Ionicons name={icon} size={14} color={colors.textMuted} />
<Text style={{ flex: 1, fontSize: 10, color: colors.textMuted, fontFamily: 'Nunito_400Regular', lineHeight: 13 }}>
{label}
</Text>
</View>
<View style={{ flexDirection: 'row', alignItems: 'baseline', gap: 4 }}>
<AnimatedCounter
value={value}
locale={locale}
decimals={decimals}
style={{ fontSize: 18, fontFamily: 'Nunito_900Black', color: colors.text, letterSpacing: -0.3 }}
/>
{suffix ? (
<Text style={{ fontSize: 10, color: colors.textMuted, fontFamily: 'Nunito_700Bold' }}>{suffix}</Text>
) : null}
</View>
</View>
);
}
// ─── Legend Item (compact, centered row) ───────────────────────────────────
function LegendItem({
color,
label,
value,
}: {
color: string;
label: string;
value: number;
}) {
const colors = useColors();
return (
<View style={{ alignItems: 'center', gap: 4 }}>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 5 }}>
<View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: color }} />
<Text style={{ fontSize: 11, color: colors.textMuted, fontFamily: 'Nunito_700Bold' }}>{value}</Text>
</View>
<Text style={{ fontSize: 10, color: colors.textMuted, fontFamily: 'Nunito_400Regular' }}>{label}</Text>
</View>
);
}
// ─── Half Donut (multi-segment) ────────────────────────────────────────────
function HalfDonut({
segments,
centerValue,
centerLabel,
}: {
segments: { value: number; color: string }[];
centerValue: number;
centerLabel: string;
}) {
const colors = useColors();
const total = Math.max(1, segments.reduce((s, x) => s + x.value, 0));
const W = 220;
const H = 130;
const cx = W / 2;
const cy = H - 8;
const r = 90;
const stroke = 18;
// Compute cumulative angles in [180, 360]
let cumAngle = 180;
const arcs = segments.map((seg) => {
const startAngle = cumAngle;
const endAngle = cumAngle + 180 * (seg.value / total);
cumAngle = endAngle;
return { ...seg, startAngle, endAngle };
});
const animProgress = useRef(new Animated.Value(0)).current;
const [progress, setProgress] = useState(0);
useEffect(() => {
animProgress.setValue(0);
const l = animProgress.addListener(({ value }) => setProgress(value));
Animated.timing(animProgress, {
toValue: 1,
duration: 1100,
easing: Easing.out(Easing.cubic),
useNativeDriver: false,
}).start();
return () => animProgress.removeListener(l);
}, [centerValue, animProgress]);
return (
<View style={{ alignItems: 'center', justifyContent: 'center' }}>
<Svg width={W} height={H}>
{/* Background track */}
<Path
d={arcPath(cx, cy, r, 180, 360)}
stroke={colors.surfaceElevated}
strokeWidth={stroke}
fill="none"
strokeLinecap="round"
/>
{arcs.map((a, i) => {
const animatedEnd =
a.startAngle + (a.endAngle - a.startAngle) * progress;
if (animatedEnd <= a.startAngle + 0.5) return null;
return (
<Path
key={i}
d={arcPath(cx, cy, r, a.startAngle, animatedEnd)}
stroke={a.color}
strokeWidth={stroke}
fill="none"
strokeLinecap="round"
/>
);
})}
{centerValue === 0 && (
<Circle cx={cx} cy={cy - r + stroke / 2} r={3} fill="#d4d4d8" />
)}
</Svg>
{/* Center number — exactly centered horizontally + vertically inside semicircle */}
<View
pointerEvents="none"
style={{
position: 'absolute',
left: 0,
right: 0,
top: H / 2 + 4,
alignItems: 'center',
}}
>
<Text style={{ fontSize: 30, fontFamily: 'Nunito_900Black', color: colors.text, letterSpacing: -0.5 }}>
{centerValue}
</Text>
<Text style={{ fontSize: 10, color: colors.textMuted, fontFamily: 'Nunito_400Regular', marginTop: -2 }}>
{centerLabel}
</Text>
</View>
</View>
);
}
function arcPath(cx: number, cy: number, r: number, startDeg: number, endDeg: number) {
const start = polar(cx, cy, r, startDeg);
const end = polar(cx, cy, r, endDeg);
const largeArc = endDeg - startDeg > 180 ? 1 : 0;
return `M ${start.x} ${start.y} A ${r} ${r} 0 ${largeArc} 1 ${end.x} ${end.y}`;
}
function polar(cx: number, cy: number, r: number, angleDeg: number) {
const rad = (angleDeg * Math.PI) / 180;
return { x: cx + r * Math.cos(rad), y: cy + r * Math.sin(rad) };
}
// ─── FAQ Item (chevron AT END of header row, on right) ─────────────────────
function FaqItem({ question, answer }: { question: string; answer: string }) {
const colors = useColors();
const [open, setOpen] = useState(false);
const rotateAnim = useRef(new Animated.Value(0)).current;
useEffect(() => {
Animated.timing(rotateAnim, {
toValue: open ? 1 : 0,
duration: 200,
useNativeDriver: true,
}).start();
}, [open, rotateAnim]);
const rotate = rotateAnim.interpolate({
inputRange: [0, 1],
outputRange: ['0deg', '180deg'],
});
return (
<View
style={{
alignSelf: 'stretch',
borderWidth: 1,
borderColor: colors.border,
borderRadius: 12,
overflow: 'hidden',
backgroundColor: colors.bg,
}}
>
<TouchableOpacity
onPress={() => setOpen((v) => !v)}
activeOpacity={0.75}
>
<View style={{ flexDirection: 'row', alignItems: 'center', paddingHorizontal: 14, paddingVertical: 14 }}>
<View style={{ flex: 1, paddingRight: 12 }}>
<Text style={{ fontSize: 13, fontFamily: 'Nunito_700Bold', color: colors.text, lineHeight: 18 }}>
{question}
</Text>
</View>
<Animated.View
style={{
width: 28,
height: 28,
borderRadius: 14,
backgroundColor: colors.surfaceElevated,
alignItems: 'center',
justifyContent: 'center',
transform: [{ rotate }],
}}
>
<Ionicons name="chevron-down" size={16} color={colors.textMuted} />
</Animated.View>
</View>
</TouchableOpacity>
{open && (
<View style={{ paddingHorizontal: 14, paddingBottom: 14, paddingTop: 0 }}>
<Text style={{ fontSize: 13, fontFamily: 'Nunito_400Regular', color: colors.textMuted, lineHeight: 19 }}>
{answer}
</Text>
</View>
)}
</View>
);
}