From dfcee68dc87cfa356d3e3fdf37e89a5309e68300 Mon Sep 17 00:00:00 2001 From: chahinebrini Date: Thu, 21 May 2026 23:15:23 +0200 Subject: [PATCH] =?UTF-8?q?feat(blocker):=20Blocker-Page-Redesign=20?= =?UTF-8?q?=E2=80=94=202=20Sektionen=20statt=204=20Bloecke?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Meine Filter (unified web + mail_domain, Typ-Badge pro Kachel, Slot-Pool- Zaehler) + collapsible VIP-Liste (Zweitschutz-Beschreibung, read-only). Die 3 redundanten Web-Bloecke (CustomFilterOverview + DomainSection Eigene Domains) entfernt. Slot-Pool rein frontend (limits.web+limits.mail), kein Backend noetig. Co-Authored-By: Claude Opus 4.7 --- apps/rebreak-native/app/(app)/blocker.tsx | 307 +----------------- .../components/blocker/VipDomainList.tsx | 238 +++++++++++--- apps/rebreak-native/locales/de.json | 5 + apps/rebreak-native/locales/en.json | 5 + 4 files changed, 212 insertions(+), 343 deletions(-) diff --git a/apps/rebreak-native/app/(app)/blocker.tsx b/apps/rebreak-native/app/(app)/blocker.tsx index 63a6260..b69aee8 100644 --- a/apps/rebreak-native/app/(app)/blocker.tsx +++ b/apps/rebreak-native/app/(app)/blocker.tsx @@ -1,16 +1,14 @@ import { useCallback, useEffect, useRef, useState } from 'react'; -import { Animated, AppState, Platform, ScrollView, Text, View, Alert, ActivityIndicator, TouchableOpacity } from 'react-native'; +import { AppState, Platform, ScrollView, View, Alert, ActivityIndicator } from 'react-native'; import { useRouter } from 'expo-router'; import { useBottomTabBarHeight } from 'react-native-bottom-tabs'; import { useTranslation } from 'react-i18next'; -import { Ionicons } from '@expo/vector-icons'; import { AppHeader } from '../../components/AppHeader'; import { LayerSwitchCard } from '../../components/blocker/LayerSwitchCard'; import { ProtectionLockedCard } from '../../components/blocker/ProtectionLockedCard'; import { CooldownBanner } from '../../components/blocker/CooldownBanner'; -import { DomainGrid } from '../../components/blocker/DomainGrid'; import { AddDomainSheet } from '../../components/blocker/AddDomainSheet'; -import { VipDomainList } from '../../components/blocker/VipDomainList'; +import { MyFiltersList, VipDomainList } from '../../components/blocker/VipDomainList'; import { ProtectionDetailsSheet } from '../../components/blocker/ProtectionDetailsSheet'; import { DeactivationExplainerSheet } from '../../components/blocker/DeactivationExplainerSheet'; import { PermissionDeniedSheet } from '../../components/PermissionDeniedSheet'; @@ -20,7 +18,7 @@ import { useCustomDomains } from '../../hooks/useCustomDomains'; import { useBlocklistSync } from '../../hooks/useBlocklistSync'; import { useDomainSubmissionRealtime } from '../../hooks/useDomainSubmissionRealtime'; import { protection, FAMILY_CONTROLS_AVAILABLE } from '../../lib/protection'; -import { useColors, type ColorScheme } from '../../lib/theme'; +import { useColors } from '../../lib/theme'; export default function BlockerScreen() { const router = useRouter(); @@ -48,7 +46,6 @@ export default function BlockerScreen() { countsByType, limits, addDomain, - submitDomain, removeDomain, refresh: refreshDomains, } = useCustomDomains(plan); @@ -65,7 +62,7 @@ export default function BlockerScreen() { }, [refreshDomains, syncBlocklist, refresh]); useDomainSubmissionRealtime(onDomainChange, true); - const [mailOpen, setMailOpen] = useState(false); + const [vipOpen, setVipOpen] = useState(false); const [addSheetOpen, setAddSheetOpen] = useState(false); const [detailsOpen, setDetailsOpen] = useState(false); @@ -332,62 +329,25 @@ export default function BlockerScreen() { /> )} - {/* VIP-Liste: Meine geblockten Seiten */} - setAddSheetOpen(true)} onRemoveDomain={handleRemoveWebDomain} colors={colors} /> - {/* Custom-Filter-Slot-Übersicht */} - setAddSheetOpen(true)} + {/* Sektion 2: VIP-Liste (Zweitschutz, collapsible) */} + setVipOpen((v) => !v)} colors={colors} - t={t} /> - - {/* Section 1: Eigene Domains */} - = limits.web} - > - Alert.alert(t('blocker.upgrade_alert_title'), t('blocker.upgrade_alert_desc'))} - /> - - - {/* Section 2: Eigene Mails */} - setMailOpen((v) => !v)} - atLimit={countsByType.mail >= limits.mail} - > - Alert.alert(t('blocker.upgrade_alert_title'), t('blocker.upgrade_alert_desc'))} - /> - {/* Sheets */} @@ -460,242 +420,3 @@ export default function BlockerScreen() { ); } -// ─── CustomFilterOverview ───────────────────────────────────────────────────── - -function CustomFilterOverview({ - webCount, - mailCount, - webLimit, - mailLimit, - onAddPress, - colors, - t, -}: { - webCount: number; - mailCount: number; - webLimit: number; - mailLimit: number; - onAddPress: () => void; - colors: ColorScheme; - t: (key: string, opts?: Record) => string; -}) { - const total = webCount + mailCount; - const max = webLimit + mailLimit; - const webFillAnim = useRef(new Animated.Value(0)).current; - const mailFillAnim = useRef(new Animated.Value(0)).current; - - const webRatio = max > 0 ? Math.min(webCount / max, 1) : 0; - const mailRatio = max > 0 ? Math.min(mailCount / max, 1) : 0; - - useEffect(() => { - Animated.parallel([ - Animated.timing(webFillAnim, { toValue: webRatio, duration: 380, useNativeDriver: false }), - Animated.timing(mailFillAnim, { toValue: mailRatio, duration: 380, useNativeDriver: false }), - ]).start(); - }, [webRatio, mailRatio]); - - return ( - - {/* Top row: title + legend on left, count badge + add button on right */} - - - {t('blocker.custom_filter_overview_title')} - - - - - {webCount} Web - - - - - - {mailCount} Mail - - - - - - {t('blocker.custom_filter_overview_count', { count: total, max })} - - - - - - - - {/* Split progress bar — same height/pattern as DomainSection */} - - - - - - ); -} - -// ─── DomainSection ──────────────────────────────────────────────────────────── - -function DomainSection({ - title, - count, - max, - collapsible = false, - open = true, - onToggle, - atLimit, - children, -}: { - title: string; - count: number; - max: number; - collapsible?: boolean; - open?: boolean; - onToggle?: () => void; - atLimit: boolean; - children: React.ReactNode; -}) { - const { t } = useTranslation(); - const colors = useColors(); - - const fillAnim = useRef(new Animated.Value(0)).current; - const ratio = max > 0 ? Math.min(count / max, 1) : 0; - - useEffect(() => { - Animated.timing(fillAnim, { - toValue: ratio, - duration: 380, - useNativeDriver: false, - }).start(); - }, [ratio]); - - const pct = ratio * 100; - const barColor = pct >= 90 ? '#dc2626' : pct >= 60 ? '#f59e0b' : '#16a34a'; - - const badgeBg = atLimit ? '#fee2e2' : colors.surfaceElevated; - const badgeFg = atLimit ? '#dc2626' : colors.textMuted; - - return ( - - {/* Section Header */} - - - {title} - - - - {t('blocker.count_label', { count, max })} - - - {collapsible && ( - - )} - - - {(!collapsible || open) && ( - - {/* Progressbar */} - - - - - {/* Grid */} - {children} - - )} - - ); -} diff --git a/apps/rebreak-native/components/blocker/VipDomainList.tsx b/apps/rebreak-native/components/blocker/VipDomainList.tsx index 49fef73..4fc7664 100644 --- a/apps/rebreak-native/components/blocker/VipDomainList.tsx +++ b/apps/rebreak-native/components/blocker/VipDomainList.tsx @@ -7,10 +7,12 @@ import { useColors, type ColorScheme } from '../../lib/theme'; import { RemoveDomainSheet } from './RemoveDomainSheet'; import type { CustomDomain } from '../../hooks/useCustomDomains'; -type Props = { +// ─── Meine Filter (unified web + mail_domain) ───────────────────────────────── + +type MyFiltersProps = { domains: CustomDomain[]; - webCount: number; - webLimit: number; + totalCount: number; + totalLimit: number; globalBlocklistCount: number; onAddPress: () => void; onRemoveDomain: (id: string) => Promise; @@ -18,32 +20,30 @@ type Props = { }; /** - * VIP-Sektion: "Meine geblockten Seiten". + * "Meine Filter" — unified Sektion für web + mail_domain Einträge. * - * Zeigt eigene Web-Custom-Domains des Users (kind='web') + Zähler (X / Limit). - * Domain entfernen → 3-Click Friction via RemoveDomainSheet (selbes Pattern wie - * Schutz-Deaktivieren). Domain hinzufügen → frei via onAddPress. - * Darunter: ruhiger Hinweis auf die automatische globale Blocklist. + * Ein Slot-Pool: totalLimit = Legend 20 / Pro 10 (web+mail zusammen). + * Kacheln zeigen Typ-Badge (Web / Mail). Entfernen via RemoveDomainSheet. */ -export function VipDomainList({ +export function MyFiltersList({ domains, - webCount, - webLimit, + totalCount, + totalLimit, globalBlocklistCount, onAddPress, onRemoveDomain, colors, -}: Props) { +}: MyFiltersProps) { const { t } = useTranslation(); - const webDomains = useMemo( - () => domains.filter((d) => (d.type === 'web' || !d.type) && d.status !== 'approved'), + const visibleDomains = useMemo( + () => domains.filter((d) => d.status !== 'approved'), [domains], ); - const atLimit = webCount >= webLimit; + const atLimit = totalCount >= totalLimit; const fillAnim = useRef(new Animated.Value(0)).current; - const ratio = webLimit > 0 ? Math.min(webCount / webLimit, 1) : 0; + const ratio = totalLimit > 0 ? Math.min(totalCount / totalLimit, 1) : 0; useEffect(() => { Animated.timing(fillAnim, { toValue: ratio, duration: 380, useNativeDriver: false }).start(); @@ -51,7 +51,6 @@ export function VipDomainList({ const pct = ratio * 100; const barColor = pct >= 90 ? colors.error : pct >= 60 ? colors.warning : colors.brandOrange; - const badgeBg = atLimit ? '#fee2e2' : colors.surfaceElevated; const badgeFg = atLimit ? colors.error : colors.textMuted; @@ -65,7 +64,6 @@ export function VipDomainList({ overflow: 'hidden', }} > - {/* Header */} - {t('blocker.vip_section_title')} + {t('blocker.my_filters_title')} - {t('blocker.count_label', { count: webCount, max: webLimit })} + {t('blocker.count_label', { count: totalCount, max: totalLimit })} - {/* Progress bar */} - {/* Domain list or empty state */} - {webDomains.length === 0 ? ( - + {visibleDomains.length === 0 ? ( + ) : ( - + )} - {/* Global list hint — ruhig, nicht als Casino-Trigger */} - + void; colors: ColorScheme }) { +function MyFiltersEmptyState({ onAddPress, colors }: { onAddPress: () => void; colors: ColorScheme }) { const { t } = useTranslation(); return ( @@ -189,16 +175,14 @@ function VipEmptyState({ onAddPress, colors }: { onAddPress: () => void; colors: lineHeight: 18, }} > - {t('blocker.vip_empty')} + {t('blocker.my_filters_empty')} ); } -// ─── Domain Tiles ───────────────────────────────────────────────────────────── - -function VipDomainTiles({ +function FilterTilesGrid({ domains, onRemoveDomain, }: { @@ -208,13 +192,13 @@ function VipDomainTiles({ return ( {domains.map((d) => ( - + ))} ); } -function VipDomainTile({ +function FilterTile({ domain, onRemove, }: { @@ -227,6 +211,7 @@ function VipDomainTile({ const [removeSheetOpen, setRemoveSheetOpen] = useState(false); const [removing, setRemoving] = useState(false); + const isMail = domain.type === 'mail_domain'; const stripped = domain.domain.replace(/^www\./, ''); const statusColor: string = (() => { @@ -264,30 +249,55 @@ function VipDomainTile({ borderRadius: 14, padding: 8, width: '31%', - minHeight: 110, + minHeight: 118, gap: 4, opacity: removing ? 0.4 : 1, }} > - {/* Status badge row */} - + {/* Type + Status badge row */} + + + {isMail ? t('blocker.type_mail') : t('blocker.type_web')} + + + - + {badgeLabel} - {/* Favicon + domain name */} + {/* Icon + label */} - {!imgError ? ( + {isMail ? ( + + + + ) : !imgError ? ( ); } + +// ─── VIP-Liste (collapsible, Zweitschutz) ──────────────────────────────────── + +type VipListProps = { + domains: CustomDomain[]; + globalBlocklistCount: number; + open: boolean; + onToggle: () => void; + colors: ColorScheme; +}; + +/** + * "VIP-Liste" — Zweitschutz-Sektion. Collapsible. + * + * Zeigt die zusammengesetzte VIP-Layer-2-Liste: + * - Eigene Web-Domains des Users (für Family-Controls / webContent-Sync) + * - Hinweis auf den globalen kuratierten Teil (nicht editierbar) + * + * Diese Liste greift als Zweitschutz, falls Layer 1 (VPN/URL-Filter) + * ein technisches Problem hat. + */ +export function VipDomainList({ domains, globalBlocklistCount, open, onToggle, colors }: VipListProps) { + const { t } = useTranslation(); + + const webDomains = useMemo( + () => domains.filter((d) => (d.type === 'web' || !d.type) && d.status !== 'approved'), + [domains], + ); + + return ( + + + + + {t('blocker.vip_layer2_title')} + + + + + {open && ( + + + {t('blocker.vip_layer2_desc')} + + + {webDomains.length > 0 && ( + + {webDomains.map((d) => ( + + ))} + + )} + + + + + {t('blocker.vip_layer2_global_hint', { count: globalBlocklistCount })} + + + + )} + + ); +} + +function VipReadonlyChip({ domain, colors }: { domain: CustomDomain; colors: ColorScheme }) { + const stripped = domain.domain.replace(/^www\./, ''); + return ( + + + + {stripped} + + + ); +} diff --git a/apps/rebreak-native/locales/de.json b/apps/rebreak-native/locales/de.json index ae77b4a..303d91c 100644 --- a/apps/rebreak-native/locales/de.json +++ b/apps/rebreak-native/locales/de.json @@ -382,6 +382,11 @@ "vip_section_title": "Meine geblockten Seiten", "vip_empty": "Noch keine eigenen Seiten.\nTippe + um eine Website zu sperren.", "vip_global_hint": "+ %{count} bekannte Glücksspielseiten automatisch geschützt", + "my_filters_title": "Meine Filter", + "my_filters_empty": "Noch keine Filter. Tippe + um eine Website oder E-Mail zu blockieren.", + "vip_layer2_title": "VIP-Liste", + "vip_layer2_desc": "Zweitschutz: Diese Liste greift, falls der URL-Filter (Layer 1) ein technisches Problem hat. Sie enthält deine eigenen Domains plus einen kuratierten globalen Anteil.", + "vip_layer2_global_hint": "+ %{count} bekannte Glücksspielseiten automatisch geschützt", "remove_domain_sheet_heading": "Domain entfernen", "remove_domain_title": "Kurz nachdenken.", "remove_domain_intro": "Du bist dabei, diese Seite aus deiner persönlichen Sperrliste zu entfernen. Das passiert sofort — sie wäre dann wieder erreichbar.", diff --git a/apps/rebreak-native/locales/en.json b/apps/rebreak-native/locales/en.json index 4889330..7e49b67 100644 --- a/apps/rebreak-native/locales/en.json +++ b/apps/rebreak-native/locales/en.json @@ -382,6 +382,11 @@ "vip_section_title": "My blocked sites", "vip_empty": "No custom sites yet.\nTap + to block a website.", "vip_global_hint": "+ %{count} known gambling sites automatically protected", + "my_filters_title": "My Filters", + "my_filters_empty": "No filters yet. Tap + to block a website or email.", + "vip_layer2_title": "VIP List", + "vip_layer2_desc": "Second-layer protection: this list activates if the URL filter (Layer 1) has a technical issue. It includes your custom domains plus a curated global portion.", + "vip_layer2_global_hint": "+ %{count} known gambling sites automatically protected", "remove_domain_sheet_heading": "Remove domain", "remove_domain_title": "Take a moment.", "remove_domain_intro": "You're about to remove this site from your personal blocklist. This takes effect immediately — the site would be reachable again.",