Initial Upload
Some checks failed
CI / Lint & Typecheck (push) Has been cancelled
CI / Test (routes) (push) Has been cancelled
CI / Test (security) (push) Has been cancelled
CI / Test (services) (push) Has been cancelled
CI / Test (unit) (push) Has been cancelled
CI / Test (integration) (push) Has been cancelled
CI / Test Coverage (push) Has been cancelled
CI / Build (push) Has been cancelled

This commit is contained in:
2025-12-17 12:32:50 +13:00
commit 3015f48118
471 changed files with 141143 additions and 0 deletions

View File

@@ -0,0 +1,18 @@
/**
* Auth layout - for login/pairing screens
*/
import { Stack } from 'expo-router';
import { colors } from '@/lib/theme';
export default function AuthLayout() {
return (
<Stack
screenOptions={{
headerShown: false,
contentStyle: { backgroundColor: colors.background.dark },
}}
>
<Stack.Screen name="pair" options={{ title: 'Connect to Server' }} />
</Stack>
);
}

View File

@@ -0,0 +1,408 @@
/**
* Pairing screen - QR code scanner or manual entry
* Supports both initial pairing and adding additional servers
*/
import { useState, useRef } from 'react';
import {
View,
Text,
TextInput,
Pressable,
StyleSheet,
Alert,
KeyboardAvoidingView,
Platform,
ScrollView,
} from 'react-native';
import { CameraView, useCameraPermissions } from 'expo-camera';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { ChevronLeft } from 'lucide-react-native';
import { useAuthStore } from '@/lib/authStore';
import { colors, spacing, borderRadius, typography } from '@/lib/theme';
interface QRPairingPayload {
url: string;
token: string;
}
export default function PairScreen() {
const router = useRouter();
const [permission, requestPermission] = useCameraPermissions();
const [manualMode, setManualMode] = useState(false);
const [serverUrl, setServerUrl] = useState('');
const [token, setToken] = useState('');
const [scanned, setScanned] = useState(false);
const scanLockRef = useRef(false); // Synchronous lock to prevent race conditions
const { addServer, isAuthenticated, servers, isLoading, error, clearError } = useAuthStore();
// Check if this is adding an additional server vs first-time pairing
const isAddingServer = isAuthenticated && servers.length > 0;
const handleBarCodeScanned = async ({ data }: { data: string }) => {
// Use ref for synchronous check - state updates are async and cause race conditions
if (scanLockRef.current || isLoading) return;
scanLockRef.current = true; // Immediate synchronous lock
setScanned(true);
try {
// Parse tracearr://pair?data=<base64>
// First check if it even looks like our URL format
if (!data.startsWith('tracearr://pair')) {
// Silently ignore non-Tracearr QR codes (don't spam alerts)
setTimeout(() => {
scanLockRef.current = false;
setScanned(false);
}, 2000);
return;
}
const url = new URL(data);
const base64Data = url.searchParams.get('data');
if (!base64Data) {
throw new Error('Invalid QR code: missing pairing data');
}
// Decode and parse payload with proper error handling
let payload: QRPairingPayload;
try {
const decoded = atob(base64Data);
payload = JSON.parse(decoded) as QRPairingPayload;
} catch {
throw new Error('Invalid QR code format. Please generate a new code.');
}
// Validate payload has required fields
if (!payload.url || typeof payload.url !== 'string') {
throw new Error('Invalid QR code: missing server URL');
}
if (!payload.token || typeof payload.token !== 'string') {
throw new Error('Invalid QR code: missing pairing token');
}
// Validate URL format
if (!payload.url.startsWith('http://') && !payload.url.startsWith('https://')) {
throw new Error('Invalid server URL in QR code');
}
await addServer(payload.url, payload.token);
// Navigate to tabs after successful pairing
router.replace('/(tabs)');
} catch (err) {
Alert.alert('Pairing Failed', err instanceof Error ? err.message : 'Invalid QR code');
// Add cooldown before allowing another scan
setTimeout(() => {
scanLockRef.current = false;
setScanned(false);
}, 3000);
}
};
const handleManualPair = async () => {
if (!serverUrl.trim() || !token.trim()) {
Alert.alert('Missing Fields', 'Please enter both server URL and token');
return;
}
const trimmedUrl = serverUrl.trim();
// Validate URL format
if (!trimmedUrl.startsWith('http://') && !trimmedUrl.startsWith('https://')) {
Alert.alert('Invalid URL', 'Server URL must start with http:// or https://');
return;
}
// Validate URL is well-formed
try {
new URL(trimmedUrl);
} catch {
Alert.alert('Invalid URL', 'Please enter a valid server URL');
return;
}
clearError();
try {
await addServer(trimmedUrl, token.trim());
// Navigate to tabs after successful pairing
router.replace('/(tabs)');
} catch {
// Error is handled by the store
}
};
const handleBack = () => {
router.back();
};
if (manualMode) {
return (
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={styles.keyboardView}
>
<ScrollView contentContainerStyle={styles.scrollContent}>
{/* Back button for adding servers */}
{isAddingServer && (
<Pressable style={styles.backButton} onPress={handleBack}>
<ChevronLeft size={24} color={colors.text.primary.dark} />
<Text style={styles.backText}>Back</Text>
</Pressable>
)}
<View style={styles.header}>
<Text style={styles.title}>
{isAddingServer ? 'Add Server' : 'Connect to Server'}
</Text>
<Text style={styles.subtitle}>
Enter your Tracearr server URL and mobile access token
</Text>
</View>
<View style={styles.form}>
<View style={styles.inputGroup}>
<Text style={styles.label}>Server URL</Text>
<TextInput
style={styles.input}
value={serverUrl}
onChangeText={setServerUrl}
placeholder="https://tracearr.example.com"
placeholderTextColor={colors.text.muted.dark}
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
editable={!isLoading}
/>
</View>
<View style={styles.inputGroup}>
<Text style={styles.label}>Access Token</Text>
<TextInput
style={styles.input}
value={token}
onChangeText={setToken}
placeholder="trr_mob_..."
placeholderTextColor={colors.text.muted.dark}
autoCapitalize="none"
autoCorrect={false}
secureTextEntry
editable={!isLoading}
/>
</View>
{error && <Text style={styles.errorText}>{error}</Text>}
<Pressable
style={[styles.button, isLoading && styles.buttonDisabled]}
onPress={handleManualPair}
disabled={isLoading}
>
<Text style={styles.buttonText}>
{isLoading ? 'Connecting...' : 'Connect'}
</Text>
</Pressable>
<Pressable
style={styles.linkButton}
onPress={() => setManualMode(false)}
disabled={isLoading}
>
<Text style={styles.linkText}>Scan QR Code Instead</Text>
</Pressable>
</View>
</ScrollView>
</KeyboardAvoidingView>
</SafeAreaView>
);
}
return (
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
{/* Back button for adding servers */}
{isAddingServer && (
<Pressable style={styles.backButton} onPress={handleBack}>
<ChevronLeft size={24} color={colors.text.primary.dark} />
<Text style={styles.backText}>Back</Text>
</Pressable>
)}
<View style={styles.header}>
<Text style={styles.title}>
{isAddingServer ? 'Add Server' : 'Welcome to Tracearr'}
</Text>
<Text style={styles.subtitle}>
Open Settings Mobile App in your Tracearr dashboard and scan the QR code
</Text>
</View>
<View style={styles.cameraContainer}>
{permission?.granted ? (
<CameraView
style={styles.camera}
barcodeScannerSettings={{ barcodeTypes: ['qr'] }}
onBarcodeScanned={scanned ? undefined : handleBarCodeScanned}
>
<View style={styles.overlay}>
<View style={styles.scanFrame} />
</View>
</CameraView>
) : (
<View style={styles.permissionContainer}>
<Text style={styles.permissionText}>
Camera permission is required to scan QR codes
</Text>
<Pressable style={styles.button} onPress={requestPermission}>
<Text style={styles.buttonText}>Grant Permission</Text>
</Pressable>
</View>
)}
</View>
<View style={styles.footer}>
<Pressable style={styles.linkButton} onPress={() => setManualMode(true)}>
<Text style={styles.linkText}>Enter URL and Token Manually</Text>
</Pressable>
</View>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background.dark,
},
keyboardView: {
flex: 1,
},
scrollContent: {
flexGrow: 1,
padding: spacing.lg,
},
backButton: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
marginTop: spacing.sm,
},
backText: {
fontSize: typography.fontSize.base,
color: colors.text.primary.dark,
marginLeft: spacing.xs,
},
header: {
paddingHorizontal: spacing.lg,
paddingTop: spacing.xl,
paddingBottom: spacing.lg,
alignItems: 'center',
},
title: {
fontSize: typography.fontSize['2xl'],
fontWeight: 'bold',
color: colors.text.primary.dark,
marginBottom: spacing.sm,
textAlign: 'center',
},
subtitle: {
fontSize: typography.fontSize.base,
color: colors.text.secondary.dark,
textAlign: 'center',
lineHeight: 22,
},
cameraContainer: {
flex: 1,
marginHorizontal: spacing.lg,
marginBottom: spacing.lg,
borderRadius: borderRadius.xl,
overflow: 'hidden',
backgroundColor: colors.card.dark,
},
camera: {
flex: 1,
},
overlay: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: 'rgba(0,0,0,0.3)',
},
scanFrame: {
width: 250,
height: 250,
borderWidth: 2,
borderColor: colors.cyan.core,
borderRadius: borderRadius.lg,
backgroundColor: 'transparent',
},
permissionContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: spacing.lg,
},
permissionText: {
fontSize: typography.fontSize.base,
color: colors.text.secondary.dark,
textAlign: 'center',
marginBottom: spacing.lg,
},
footer: {
paddingHorizontal: spacing.lg,
paddingBottom: spacing.lg,
alignItems: 'center',
},
form: {
flex: 1,
gap: spacing.md,
},
inputGroup: {
gap: spacing.xs,
},
label: {
fontSize: typography.fontSize.sm,
fontWeight: '500',
color: colors.text.secondary.dark,
},
input: {
backgroundColor: colors.card.dark,
borderWidth: 1,
borderColor: colors.border.dark,
borderRadius: borderRadius.md,
padding: spacing.md,
fontSize: typography.fontSize.base,
color: colors.text.primary.dark,
},
button: {
backgroundColor: colors.cyan.core,
paddingVertical: spacing.md,
paddingHorizontal: spacing.lg,
borderRadius: borderRadius.md,
alignItems: 'center',
marginTop: spacing.sm,
},
buttonDisabled: {
opacity: 0.6,
},
buttonText: {
fontSize: typography.fontSize.base,
fontWeight: '600',
color: colors.blue.core,
},
linkButton: {
paddingVertical: spacing.md,
alignItems: 'center',
},
linkText: {
fontSize: typography.fontSize.base,
color: colors.cyan.core,
},
errorText: {
fontSize: typography.fontSize.sm,
color: colors.error,
textAlign: 'center',
},
});

View File

@@ -0,0 +1,116 @@
/**
* Main tab navigation layout
*/
import { Tabs } from 'expo-router';
import {
LayoutDashboard,
Activity,
Users,
Bell,
Settings,
type LucideIcon,
} from 'lucide-react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { colors } from '@/lib/theme';
import { ServerSelector } from '@/components/ServerSelector';
interface TabIconProps {
icon: LucideIcon;
focused: boolean;
}
function TabIcon({ icon: Icon, focused }: TabIconProps) {
return (
<Icon
size={24}
color={focused ? colors.cyan.core : colors.text.muted.dark}
strokeWidth={focused ? 2.5 : 2}
/>
);
}
export default function TabLayout() {
const insets = useSafeAreaInsets();
// Dynamic tab bar height: base height + safe area bottom inset
const tabBarHeight = 60 + insets.bottom;
return (
<Tabs
screenOptions={{
headerShown: true,
headerStyle: {
backgroundColor: colors.background.dark,
},
headerTintColor: colors.text.primary.dark,
headerTitleStyle: {
fontWeight: '600',
},
tabBarStyle: {
backgroundColor: colors.card.dark,
borderTopColor: colors.border.dark,
borderTopWidth: 1,
height: tabBarHeight,
paddingBottom: insets.bottom,
paddingTop: 8,
},
tabBarActiveTintColor: colors.cyan.core,
tabBarInactiveTintColor: colors.text.muted.dark,
tabBarLabelStyle: {
fontSize: 11,
fontWeight: '500',
},
}}
>
<Tabs.Screen
name="index"
options={{
title: 'Dashboard',
headerTitle: () => <ServerSelector />,
tabBarLabel: 'Dashboard',
tabBarIcon: ({ focused }) => (
<TabIcon icon={LayoutDashboard} focused={focused} />
),
}}
/>
<Tabs.Screen
name="activity"
options={{
title: 'Activity',
headerTitle: () => <ServerSelector />,
tabBarLabel: 'Activity',
tabBarIcon: ({ focused }) => (
<TabIcon icon={Activity} focused={focused} />
),
}}
/>
<Tabs.Screen
name="users"
options={{
title: 'Users',
headerTitle: () => <ServerSelector />,
tabBarLabel: 'Users',
tabBarIcon: ({ focused }) => <TabIcon icon={Users} focused={focused} />,
}}
/>
<Tabs.Screen
name="alerts"
options={{
title: 'Alerts',
headerTitle: () => <ServerSelector />,
tabBarLabel: 'Alerts',
tabBarIcon: ({ focused }) => <TabIcon icon={Bell} focused={focused} />,
}}
/>
<Tabs.Screen
name="settings"
options={{
title: 'Settings',
tabBarLabel: 'Settings',
tabBarIcon: ({ focused }) => (
<TabIcon icon={Settings} focused={focused} />
),
}}
/>
</Tabs>
);
}

View File

@@ -0,0 +1,157 @@
/**
* Activity tab - streaming statistics and charts
* Query keys include selectedServerId for proper cache isolation per media server
*/
import { useState } from 'react';
import { View, ScrollView, RefreshControl } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useQuery } from '@tanstack/react-query';
import { api } from '@/lib/api';
import { useMediaServer } from '@/providers/MediaServerProvider';
import { colors } from '@/lib/theme';
import { Text } from '@/components/ui/text';
import { Card } from '@/components/ui/card';
import { PeriodSelector, type StatsPeriod } from '@/components/ui/period-selector';
import {
PlaysChart,
PlatformChart,
DayOfWeekChart,
HourOfDayChart,
QualityChart,
} from '@/components/charts';
function ChartSection({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
return (
<View className="mb-4">
<Text className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-2">
{title}
</Text>
{children}
</View>
);
}
export default function ActivityScreen() {
const [period, setPeriod] = useState<StatsPeriod>('month');
const { selectedServerId } = useMediaServer();
// Fetch all stats data with selected period - query keys include selectedServerId for cache isolation
const {
data: playsData,
refetch: refetchPlays,
isRefetching: isRefetchingPlays,
} = useQuery({
queryKey: ['stats', 'plays', period, selectedServerId],
queryFn: () => api.stats.plays({ period, serverId: selectedServerId ?? undefined }),
});
const { data: dayOfWeekData, refetch: refetchDayOfWeek } = useQuery({
queryKey: ['stats', 'dayOfWeek', period, selectedServerId],
queryFn: () => api.stats.playsByDayOfWeek({ period, serverId: selectedServerId ?? undefined }),
});
const { data: hourOfDayData, refetch: refetchHourOfDay } = useQuery({
queryKey: ['stats', 'hourOfDay', period, selectedServerId],
queryFn: () => api.stats.playsByHourOfDay({ period, serverId: selectedServerId ?? undefined }),
});
const { data: platformsData, refetch: refetchPlatforms } = useQuery({
queryKey: ['stats', 'platforms', period, selectedServerId],
queryFn: () => api.stats.platforms({ period, serverId: selectedServerId ?? undefined }),
});
const { data: qualityData, refetch: refetchQuality } = useQuery({
queryKey: ['stats', 'quality', period, selectedServerId],
queryFn: () => api.stats.quality({ period, serverId: selectedServerId ?? undefined }),
});
const handleRefresh = () => {
void refetchPlays();
void refetchDayOfWeek();
void refetchHourOfDay();
void refetchPlatforms();
void refetchQuality();
};
// Period labels for display
const periodLabels: Record<StatsPeriod, string> = {
week: 'Last 7 Days',
month: 'Last 30 Days',
year: 'Last Year',
};
return (
<SafeAreaView style={{ flex: 1, backgroundColor: colors.background.dark }} edges={['left', 'right', 'bottom']}>
<ScrollView
className="flex-1"
contentContainerClassName="p-4 pt-3"
refreshControl={
<RefreshControl
refreshing={isRefetchingPlays}
onRefresh={handleRefresh}
tintColor={colors.cyan.core}
/>
}
>
{/* Header with Period Selector */}
<View className="flex-row items-center justify-between mb-4">
<View>
<Text className="text-lg font-semibold">Activity</Text>
<Text className="text-sm text-muted-foreground">{periodLabels[period]}</Text>
</View>
<PeriodSelector value={period} onChange={setPeriod} />
</View>
{/* Plays Over Time */}
<ChartSection title="Plays Over Time">
<PlaysChart data={playsData?.data || []} height={180} />
</ChartSection>
{/* Day of Week & Hour of Day in a row on larger screens */}
<View className="flex-row gap-3 mb-4">
<View className="flex-1">
<Text className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-2">
By Day
</Text>
<DayOfWeekChart data={dayOfWeekData?.data || []} height={160} />
</View>
</View>
<View className="mb-4">
<Text className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-2">
By Hour
</Text>
<HourOfDayChart data={hourOfDayData?.data || []} height={160} />
</View>
{/* Platform Breakdown */}
<ChartSection title="Platforms">
<PlatformChart data={platformsData?.data || []} />
</ChartSection>
{/* Quality Breakdown */}
<ChartSection title="Playback Quality">
{qualityData ? (
<QualityChart
directPlay={qualityData.directPlay}
transcode={qualityData.transcode}
directPlayPercent={qualityData.directPlayPercent}
transcodePercent={qualityData.transcodePercent}
height={120}
/>
) : (
<Card className="h-[120px] items-center justify-center">
<Text className="text-muted-foreground">Loading...</Text>
</Card>
)}
</ChartSection>
</ScrollView>
</SafeAreaView>
);
}

View File

@@ -0,0 +1,326 @@
/**
* Alerts tab - violations with infinite scroll
* Query keys include selectedServerId for proper cache isolation per media server
*/
import { View, FlatList, RefreshControl, Pressable, ActivityIndicator } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useInfiniteQuery, useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
import { useRouter } from 'expo-router';
import { formatDistanceToNow } from 'date-fns';
import {
MapPin,
Users,
Zap,
Monitor,
Globe,
AlertTriangle,
Check,
type LucideIcon,
} from 'lucide-react-native';
import { api } from '@/lib/api';
import { useMediaServer } from '@/providers/MediaServerProvider';
import { Text } from '@/components/ui/text';
import { Card } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { UserAvatar } from '@/components/ui/user-avatar';
import { colors } from '@/lib/theme';
import type { ViolationWithDetails, RuleType, UnitSystem } from '@tracearr/shared';
import { formatSpeed } from '@tracearr/shared';
const PAGE_SIZE = 50;
// Rule type icons mapping
const ruleIcons: Record<RuleType, LucideIcon> = {
impossible_travel: MapPin,
simultaneous_locations: Users,
device_velocity: Zap,
concurrent_streams: Monitor,
geo_restriction: Globe,
};
// Rule type display names
const ruleLabels: Record<RuleType, string> = {
impossible_travel: 'Impossible Travel',
simultaneous_locations: 'Simultaneous Locations',
device_velocity: 'Device Velocity',
concurrent_streams: 'Concurrent Streams',
geo_restriction: 'Geo Restriction',
};
// Format violation data into readable description based on rule type
function getViolationDescription(violation: ViolationWithDetails, unitSystem: UnitSystem = 'metric'): string {
const data = violation.data;
const ruleType = violation.rule?.type;
if (!data || !ruleType) {
return 'Rule violation detected';
}
switch (ruleType) {
case 'impossible_travel': {
const from = data.fromCity || data.fromLocation || 'unknown location';
const to = data.toCity || data.toLocation || 'unknown location';
const speed = typeof data.calculatedSpeedKmh === 'number'
? formatSpeed(data.calculatedSpeedKmh, unitSystem)
: 'impossible speed';
return `Traveled from ${from} to ${to} at ${speed}`;
}
case 'simultaneous_locations': {
const locations = data.locations as string[] | undefined;
const count = data.locationCount as number | undefined;
if (locations && locations.length > 0) {
return `Active from ${locations.length} locations: ${locations.slice(0, 2).join(', ')}${locations.length > 2 ? '...' : ''}`;
}
if (count) {
return `Streaming from ${count} different locations simultaneously`;
}
return 'Streaming from multiple locations simultaneously';
}
case 'device_velocity': {
const ipCount = data.ipCount as number | undefined;
const windowHours = data.windowHours as number | undefined;
if (ipCount && windowHours) {
return `${ipCount} different IPs used in ${windowHours}h window`;
}
return 'Too many unique devices in short period';
}
case 'concurrent_streams': {
const streamCount = data.streamCount as number | undefined;
const maxStreams = data.maxStreams as number | undefined;
if (streamCount && maxStreams) {
return `${streamCount} concurrent streams (limit: ${maxStreams})`;
}
return 'Exceeded concurrent stream limit';
}
case 'geo_restriction': {
const country = data.country as string | undefined;
const blockedCountry = data.blockedCountry as string | undefined;
if (country || blockedCountry) {
return `Streaming from blocked region: ${country || blockedCountry}`;
}
return 'Streaming from restricted location';
}
default:
return 'Rule violation detected';
}
}
function SeverityBadge({ severity }: { severity: string }) {
const variant =
severity === 'critical' || severity === 'high'
? 'destructive'
: severity === 'warning'
? 'warning'
: 'default';
return (
<Badge variant={variant} className="capitalize">
{severity}
</Badge>
);
}
function RuleIcon({ ruleType }: { ruleType: RuleType | undefined }) {
const IconComponent = ruleType ? ruleIcons[ruleType] : AlertTriangle;
return (
<View className="w-8 h-8 rounded-lg bg-surface items-center justify-center">
<IconComponent size={16} color={colors.cyan.core} />
</View>
);
}
function ViolationCard({
violation,
onAcknowledge,
onPress,
unitSystem,
}: {
violation: ViolationWithDetails;
onAcknowledge: () => void;
onPress: () => void;
unitSystem: UnitSystem;
}) {
const username = violation.user?.username || 'Unknown User';
const ruleType = violation.rule?.type as RuleType | undefined;
const ruleName = ruleType ? ruleLabels[ruleType] : violation.rule?.name || 'Unknown Rule';
const description = getViolationDescription(violation, unitSystem);
const timeAgo = formatDistanceToNow(new Date(violation.createdAt), { addSuffix: true });
return (
<Pressable onPress={onPress} className="active:opacity-80">
<Card className="mb-3">
{/* Header: User + Severity */}
<View className="flex-row justify-between items-start mb-3">
<Pressable
className="flex-row items-center gap-2.5 flex-1 active:opacity-70"
onPress={onPress}
>
<UserAvatar
thumbUrl={violation.user?.thumbUrl}
username={username}
size={40}
/>
<View className="flex-1">
<Text className="text-base font-semibold">{username}</Text>
<Text className="text-xs text-muted-foreground">{timeAgo}</Text>
</View>
</Pressable>
<SeverityBadge severity={violation.severity} />
</View>
{/* Content: Rule Type with Icon + Description */}
<View className="flex-row items-start gap-3 mb-3">
<RuleIcon ruleType={ruleType} />
<View className="flex-1">
<Text className="text-sm font-medium text-cyan-core mb-1">
{ruleName}
</Text>
<Text className="text-sm text-secondary leading-5" numberOfLines={2}>
{description}
</Text>
</View>
</View>
{/* Action Button */}
{!violation.acknowledgedAt ? (
<Pressable
className="flex-row items-center justify-center gap-2 bg-cyan-core/15 py-2.5 rounded-lg active:opacity-70"
onPress={(e) => {
e.stopPropagation();
onAcknowledge();
}}
>
<Check size={16} color={colors.cyan.core} />
<Text className="text-sm font-semibold text-cyan-core">Acknowledge</Text>
</Pressable>
) : (
<View className="flex-row items-center justify-center gap-2 bg-success/10 py-2.5 rounded-lg">
<Check size={16} color={colors.success} />
<Text className="text-sm text-success">Acknowledged</Text>
</View>
)}
</Card>
</Pressable>
);
}
export default function AlertsScreen() {
const router = useRouter();
const queryClient = useQueryClient();
const { selectedServerId } = useMediaServer();
// Fetch settings for unit system preference
const { data: settings } = useQuery({
queryKey: ['settings'],
queryFn: api.settings.get,
staleTime: 1000 * 60 * 5, // 5 minutes
});
const unitSystem = settings?.unitSystem ?? 'metric';
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
refetch,
isRefetching,
} = useInfiniteQuery({
queryKey: ['violations', selectedServerId],
queryFn: ({ pageParam = 1 }) =>
api.violations.list({ page: pageParam, pageSize: PAGE_SIZE, serverId: selectedServerId ?? undefined }),
initialPageParam: 1,
getNextPageParam: (lastPage: { page: number; totalPages: number }) => {
if (lastPage.page < lastPage.totalPages) {
return lastPage.page + 1;
}
return undefined;
},
});
const acknowledgeMutation = useMutation({
mutationFn: api.violations.acknowledge,
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['violations', selectedServerId] });
},
});
// Flatten all pages into single array
const violations = data?.pages.flatMap((page) => page.data) || [];
const unacknowledgedCount = violations.filter((v) => !v.acknowledgedAt).length;
const total = data?.pages[0]?.total || 0;
const handleEndReached = () => {
if (hasNextPage && !isFetchingNextPage) {
void fetchNextPage();
}
};
const handleViolationPress = (violation: ViolationWithDetails) => {
// Navigate to user detail page
if (violation.user?.id) {
router.push(`/user/${violation.user.id}` as never);
}
};
return (
<SafeAreaView style={{ flex: 1, backgroundColor: colors.background.dark }} edges={['left', 'right', 'bottom']}>
<FlatList
data={violations}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<ViolationCard
violation={item}
onAcknowledge={() => acknowledgeMutation.mutate(item.id)}
onPress={() => handleViolationPress(item)}
unitSystem={unitSystem}
/>
)}
contentContainerClassName="p-4 pt-3"
onEndReached={handleEndReached}
onEndReachedThreshold={0.5}
refreshControl={
<RefreshControl
refreshing={isRefetching}
onRefresh={refetch}
tintColor={colors.cyan.core}
/>
}
ListHeaderComponent={
<View className="flex-row justify-between items-center mb-3">
<View>
<Text className="text-lg font-semibold">Alerts</Text>
<Text className="text-sm text-muted-foreground">
{total} {total === 1 ? 'violation' : 'violations'} total
</Text>
</View>
{unacknowledgedCount > 0 && (
<View className="bg-destructive/20 px-3 py-1.5 rounded-lg">
<Text className="text-sm font-medium text-destructive">
{unacknowledgedCount} pending
</Text>
</View>
)}
</View>
}
ListFooterComponent={
isFetchingNextPage ? (
<View className="py-4 items-center">
<ActivityIndicator size="small" color={colors.cyan.core} />
</View>
) : null
}
ListEmptyComponent={
<View className="items-center py-16">
<View className="w-20 h-20 rounded-full bg-success/10 border border-success/20 items-center justify-center mb-4">
<Check size={32} color={colors.success} />
</View>
<Text className="text-xl font-semibold mb-2">All Clear</Text>
<Text className="text-sm text-muted-foreground text-center px-8 leading-5">
No rule violations have been detected. Your users are behaving nicely!
</Text>
</View>
}
/>
</SafeAreaView>
);
}

View File

@@ -0,0 +1,208 @@
/**
* Dashboard tab - overview of streaming activity
* Query keys include selectedServerId for proper cache isolation per media server
*/
import { View, ScrollView, RefreshControl } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { useQuery } from '@tanstack/react-query';
import { Ionicons } from '@expo/vector-icons';
import { api } from '@/lib/api';
import { useMediaServer } from '@/providers/MediaServerProvider';
import { useServerStatistics } from '@/hooks/useServerStatistics';
import { StreamMap } from '@/components/map/StreamMap';
import { NowPlayingCard } from '@/components/sessions';
import { ServerResourceCard } from '@/components/server/ServerResourceCard';
import { Text } from '@/components/ui/text';
import { Card } from '@/components/ui/card';
import { colors } from '@/lib/theme';
/**
* Compact stat pill for dashboard summary bar
*/
function StatPill({
icon,
value,
unit,
color = colors.text.secondary.dark,
}: {
icon: keyof typeof Ionicons.glyphMap;
value: string | number;
unit?: string;
color?: string;
}) {
return (
<View
style={{
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.card.dark,
paddingHorizontal: 12,
paddingVertical: 8,
borderRadius: 20,
gap: 6,
}}
>
<Ionicons name={icon} size={14} color={color} />
<Text style={{ fontSize: 13, fontWeight: '600', color: colors.text.primary.dark }}>
{value}
</Text>
{unit && (
<Text style={{ fontSize: 11, color: colors.text.muted.dark }}>{unit}</Text>
)}
</View>
);
}
export default function DashboardScreen() {
const router = useRouter();
const { selectedServerId, selectedServer } = useMediaServer();
const {
data: stats,
refetch,
isRefetching,
} = useQuery({
queryKey: ['dashboard', 'stats', selectedServerId],
queryFn: () => api.stats.dashboard(selectedServerId ?? undefined),
});
const { data: activeSessions } = useQuery({
queryKey: ['sessions', 'active', selectedServerId],
queryFn: () => api.sessions.active(selectedServerId ?? undefined),
staleTime: 1000 * 15, // 15 seconds - match web
refetchInterval: 1000 * 30, // 30 seconds - fallback if WebSocket events missed
});
// Only show server resources for Plex servers
const isPlexServer = selectedServer?.type === 'plex';
// Poll server statistics only when dashboard is visible and we have a Plex server
const {
latest: serverResources,
isLoadingData: resourcesLoading,
error: resourcesError,
} = useServerStatistics(selectedServerId ?? undefined, isPlexServer);
return (
<SafeAreaView style={{ flex: 1, backgroundColor: colors.background.dark }} edges={['left', 'right', 'bottom']}>
<ScrollView
className="flex-1"
contentContainerClassName="pb-8"
refreshControl={
<RefreshControl refreshing={isRefetching} onRefresh={refetch} tintColor={colors.cyan.core} />
}
>
{/* Today's Stats Bar */}
{stats && (
<View className="px-4 pt-3 pb-2">
<View
style={{
flexDirection: 'row',
alignItems: 'center',
gap: 8,
}}
>
<Text style={{ fontSize: 11, color: colors.text.muted.dark, fontWeight: '600', marginRight: 2 }}>
TODAY
</Text>
<StatPill icon="play-circle-outline" value={stats.todayPlays} unit="plays" />
<StatPill icon="time-outline" value={stats.watchTimeHours} unit="hrs" />
<StatPill
icon="warning-outline"
value={stats.alertsLast24h}
unit="alerts"
color={stats.alertsLast24h > 0 ? colors.warning : colors.text.muted.dark}
/>
</View>
</View>
)}
{/* Now Playing - Active Streams */}
<View className="px-4 mb-4">
<View className="flex-row items-center justify-between mb-3">
<View className="flex-row items-center gap-2">
<Ionicons name="tv-outline" size={18} color={colors.cyan.core} />
<Text className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
Now Playing
</Text>
</View>
{activeSessions && activeSessions.length > 0 && (
<View
style={{
backgroundColor: 'rgba(24, 209, 231, 0.15)',
paddingHorizontal: 8,
paddingVertical: 2,
borderRadius: 12,
}}
>
<Text style={{ color: colors.cyan.core, fontSize: 12, fontWeight: '600' }}>
{activeSessions.length} {activeSessions.length === 1 ? 'stream' : 'streams'}
</Text>
</View>
)}
</View>
{activeSessions && activeSessions.length > 0 ? (
<View>
{activeSessions.map((session) => (
<NowPlayingCard
key={session.id}
session={session}
onPress={() => router.push(`/session/${session.id}` as never)}
/>
))}
</View>
) : (
<Card className="py-8">
<View className="items-center">
<View
style={{
backgroundColor: colors.surface.dark,
padding: 16,
borderRadius: 999,
marginBottom: 12,
}}
>
<Ionicons name="tv-outline" size={32} color={colors.text.muted.dark} />
</View>
<Text className="text-base font-semibold">No active streams</Text>
<Text className="text-sm text-muted-foreground mt-1">Streams will appear here when users start watching</Text>
</View>
</Card>
)}
</View>
{/* Stream Map - only show when there are active streams */}
{activeSessions && activeSessions.length > 0 && (
<View className="px-4 mb-4">
<View className="flex-row items-center gap-2 mb-3">
<Ionicons name="location-outline" size={18} color={colors.cyan.core} />
<Text className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
Stream Locations
</Text>
</View>
<StreamMap sessions={activeSessions} height={200} />
</View>
)}
{/* Server Resources - only show if Plex server is active */}
{isPlexServer && (
<View className="px-4">
<View className="flex-row items-center gap-2 mb-3">
<Ionicons name="server-outline" size={18} color={colors.cyan.core} />
<Text className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
Server Resources
</Text>
</View>
<ServerResourceCard
latest={serverResources}
isLoading={resourcesLoading}
error={resourcesError}
/>
</View>
)}
</ScrollView>
</SafeAreaView>
);
}

View File

@@ -0,0 +1,299 @@
/**
* Settings tab - notifications, app info
* Server selection is handled via the global header selector
*/
import { View, ScrollView, Pressable, Switch, Alert, ActivityIndicator } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { ChevronRight, LogOut } from 'lucide-react-native';
import { useAuthStore } from '@/lib/authStore';
import { Text } from '@/components/ui/text';
import { Card } from '@/components/ui/card';
import { cn } from '@/lib/utils';
import { api } from '@/lib/api';
import { colors } from '@/lib/theme';
import Constants from 'expo-constants';
function SettingsRow({
label,
value,
onPress,
showChevron,
leftIcon,
rightIcon,
destructive,
}: {
label: string;
value?: string;
onPress?: () => void;
showChevron?: boolean;
leftIcon?: React.ReactNode;
rightIcon?: React.ReactNode;
destructive?: boolean;
}) {
const content = (
<View className="flex-row justify-between items-center px-4 py-3 min-h-[48px]">
<View className="flex-row items-center flex-1">
{leftIcon && <View className="mr-3">{leftIcon}</View>}
<Text className={cn('text-base flex-1', destructive && 'text-destructive')}>{label}</Text>
</View>
<View className="flex-row items-center">
{value && <Text className="text-base text-muted-foreground text-right ml-4">{value}</Text>}
{rightIcon}
{showChevron && (
<ChevronRight size={20} color={colors.text.muted.dark} className="ml-2" />
)}
</View>
</View>
);
if (onPress) {
return (
<Pressable onPress={onPress} className="active:opacity-70 active:bg-background">
{content}
</Pressable>
);
}
return content;
}
function SettingsToggle({
label,
description,
value,
onValueChange,
disabled,
isLoading,
}: {
label: string;
description?: string;
value: boolean;
onValueChange: (value: boolean) => void;
disabled?: boolean;
isLoading?: boolean;
}) {
return (
<View className="flex-row justify-between items-center px-4 py-3 min-h-[48px]">
<View className="flex-1 mr-4">
<Text className={cn('text-base', disabled && 'opacity-50')}>{label}</Text>
{description && (
<Text className={cn('text-xs text-muted-foreground mt-0.5', disabled && 'opacity-50')}>
{description}
</Text>
)}
</View>
{isLoading ? (
<ActivityIndicator size="small" color={colors.cyan.core} />
) : (
<Switch
value={value}
onValueChange={onValueChange}
disabled={disabled}
trackColor={{ false: colors.switch.trackOff, true: colors.switch.trackOn }}
thumbColor={value ? colors.switch.thumbOn : colors.switch.thumbOff}
/>
)}
</View>
);
}
function SettingsSection({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
return (
<View className="mb-6 px-4">
<Text className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-2">
{title}
</Text>
<Card className="p-0 overflow-hidden">{children}</Card>
</View>
);
}
function Divider() {
return <View className="h-px bg-border ml-4" />;
}
export default function SettingsScreen() {
const router = useRouter();
const queryClient = useQueryClient();
const {
activeServerId,
activeServer,
isLoading: isAuthLoading,
logout,
} = useAuthStore();
const appVersion = Constants.expoConfig?.version || '1.0.0';
// Fetch notification preferences
const {
data: preferences,
isLoading: isLoadingPrefs,
error: prefsError,
} = useQuery({
queryKey: ['notifications', 'preferences'],
queryFn: api.notifications.getPreferences,
staleTime: 1000 * 60, // 1 minute
enabled: !!activeServerId,
});
// Update mutation for quick toggle
const updateMutation = useMutation({
mutationFn: api.notifications.updatePreferences,
onMutate: async (newData) => {
await queryClient.cancelQueries({
queryKey: ['notifications', 'preferences'],
});
const previousData = queryClient.getQueryData([
'notifications',
'preferences',
]);
queryClient.setQueryData(
['notifications', 'preferences'],
(old: typeof preferences) => (old ? { ...old, ...newData } : old)
);
return { previousData };
},
onError: (_err, _newData, context) => {
if (context?.previousData) {
queryClient.setQueryData(
['notifications', 'preferences'],
context.previousData
);
}
},
onSettled: () => {
void queryClient.invalidateQueries({
queryKey: ['notifications', 'preferences'],
});
},
});
const handleDisconnect = () => {
Alert.alert(
'Disconnect from Server',
'Are you sure you want to disconnect? You will need to scan a QR code to reconnect.',
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Disconnect',
style: 'destructive',
onPress: () => {
void (async () => {
await queryClient.cancelQueries();
await logout();
queryClient.clear();
})();
},
},
]
);
};
const handleTogglePush = (value: boolean) => {
updateMutation.mutate({ pushEnabled: value });
};
const navigateToNotificationSettings = () => {
router.push('/settings/notifications');
};
// Count enabled notification events for summary
const enabledEventCount = preferences
? [
preferences.onViolationDetected,
preferences.onStreamStarted,
preferences.onStreamStopped,
preferences.onConcurrentStreams,
preferences.onNewDevice,
preferences.onTrustScoreChanged,
preferences.onServerDown,
preferences.onServerUp,
].filter(Boolean).length
: 0;
return (
<SafeAreaView
style={{ flex: 1, backgroundColor: colors.background.dark }}
edges={['left', 'right', 'bottom']}
>
<ScrollView className="flex-1" contentContainerClassName="py-4">
{/* Connected Server Info */}
{activeServer && (
<SettingsSection title="Connected Server">
<SettingsRow label="Name" value={activeServer.name} />
<Divider />
<SettingsRow label="URL" value={activeServer.url} />
<Divider />
<SettingsRow
label="Disconnect"
onPress={handleDisconnect}
leftIcon={<LogOut size={20} color={colors.error} />}
destructive
/>
</SettingsSection>
)}
{/* Notification Settings */}
{activeServerId && (
<SettingsSection title="Notifications">
{prefsError ? (
<View className="px-4 py-3">
<Text className="text-destructive text-sm">
Failed to load notification settings
</Text>
</View>
) : (
<>
<SettingsToggle
label="Push Notifications"
description="Receive alerts on this device"
value={preferences?.pushEnabled ?? false}
onValueChange={handleTogglePush}
isLoading={isLoadingPrefs}
disabled={updateMutation.isPending}
/>
<Divider />
<SettingsRow
label="Notification Settings"
value={
preferences?.pushEnabled ? `${enabledEventCount} events enabled` : 'Disabled'
}
onPress={navigateToNotificationSettings}
showChevron
/>
<Text className="text-xs text-muted-foreground px-4 py-2 leading-4">
Configure which events trigger notifications, quiet hours, and filters.
</Text>
</>
)}
</SettingsSection>
)}
{/* App Info */}
<SettingsSection title="About">
<SettingsRow label="App Version" value={appVersion} />
<Divider />
<SettingsRow
label="Build"
value={(Constants.expoConfig?.extra?.buildNumber as string | undefined) ?? 'dev'}
/>
</SettingsSection>
{/* Loading indicator */}
{isAuthLoading && (
<View className="px-4 py-8 items-center">
<ActivityIndicator color={colors.cyan.core} />
</View>
)}
</ScrollView>
</SafeAreaView>
);
}

View File

@@ -0,0 +1,149 @@
/**
* Users tab - user list with infinite scroll
* Query keys include selectedServerId for proper cache isolation per media server
*/
import { View, FlatList, RefreshControl, Pressable, ActivityIndicator } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useInfiniteQuery } from '@tanstack/react-query';
import { useRouter } from 'expo-router';
import { api } from '@/lib/api';
import { useMediaServer } from '@/providers/MediaServerProvider';
import { Text } from '@/components/ui/text';
import { Card } from '@/components/ui/card';
import { UserAvatar } from '@/components/ui/user-avatar';
import { cn } from '@/lib/utils';
import { colors } from '@/lib/theme';
import type { ServerUserWithIdentity } from '@tracearr/shared';
const PAGE_SIZE = 50;
function TrustScoreBadge({ score }: { score: number }) {
const variant = score < 50 ? 'destructive' : score < 75 ? 'warning' : 'success';
return (
<View
className={cn(
'px-2 py-1 rounded-sm min-w-[40px] items-center',
variant === 'destructive' && 'bg-destructive/20',
variant === 'warning' && 'bg-warning/20',
variant === 'success' && 'bg-success/20'
)}
>
<Text
className={cn(
'text-sm font-semibold',
variant === 'destructive' && 'text-destructive',
variant === 'warning' && 'text-warning',
variant === 'success' && 'text-success'
)}
>
{score}
</Text>
</View>
);
}
function UserCard({ user, onPress }: { user: ServerUserWithIdentity; onPress: () => void }) {
return (
<Pressable onPress={onPress}>
<Card className="flex-row items-center justify-between mb-2 p-3">
<View className="flex-row items-center gap-3 flex-1">
<UserAvatar thumbUrl={user.thumbUrl} username={user.username} size={48} />
<View className="flex-1">
<Text className="text-base font-semibold">{user.username}</Text>
<Text className="text-sm text-muted-foreground mt-0.5">
{user.role === 'owner' ? 'Owner' : 'User'}
</Text>
</View>
</View>
<TrustScoreBadge score={user.trustScore} />
</Card>
</Pressable>
);
}
export default function UsersScreen() {
const router = useRouter();
const { selectedServerId } = useMediaServer();
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
refetch,
isRefetching,
} = useInfiniteQuery({
queryKey: ['users', selectedServerId],
queryFn: ({ pageParam = 1 }) =>
api.users.list({ page: pageParam, pageSize: PAGE_SIZE, serverId: selectedServerId ?? undefined }),
initialPageParam: 1,
getNextPageParam: (lastPage: { page: number; totalPages: number }) => {
if (lastPage.page < lastPage.totalPages) {
return lastPage.page + 1;
}
return undefined;
},
});
// Flatten all pages into single array
const users = data?.pages.flatMap((page) => page.data) || [];
const total = data?.pages[0]?.total || 0;
const handleEndReached = () => {
if (hasNextPage && !isFetchingNextPage) {
void fetchNextPage();
}
};
return (
<SafeAreaView style={{ flex: 1, backgroundColor: colors.background.dark }} edges={['left', 'right', 'bottom']}>
<FlatList
data={users}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<UserCard
user={item}
onPress={() => router.push(`/user/${item.id}` as never)}
/>
)}
contentContainerClassName="p-4 pt-3"
onEndReached={handleEndReached}
onEndReachedThreshold={0.5}
refreshControl={
<RefreshControl
refreshing={isRefetching}
onRefresh={refetch}
tintColor={colors.cyan.core}
/>
}
ListHeaderComponent={
<View className="flex-row justify-between items-center mb-3">
<Text className="text-lg font-semibold">Users</Text>
<Text className="text-sm text-muted-foreground">
{total} {total === 1 ? 'user' : 'users'}
</Text>
</View>
}
ListFooterComponent={
isFetchingNextPage ? (
<View className="py-4 items-center">
<ActivityIndicator size="small" color={colors.cyan.core} />
</View>
) : null
}
ListEmptyComponent={
<View className="items-center py-12">
<View className="w-16 h-16 rounded-full bg-card border border-border items-center justify-center mb-4">
<Text className="text-2xl text-muted-foreground">0</Text>
</View>
<Text className="text-lg font-semibold mb-1">No Users</Text>
<Text className="text-sm text-muted-foreground text-center px-4">
Users will appear here after syncing with your media server
</Text>
</View>
}
/>
</SafeAreaView>
);
}

View File

@@ -0,0 +1,71 @@
/**
* 404 Not Found screen
*/
import { View, Text, StyleSheet, Pressable } from 'react-native';
import { Stack, useRouter } from 'expo-router';
import { SafeAreaView } from 'react-native-safe-area-context';
import { colors, spacing, borderRadius, typography } from '@/lib/theme';
export default function NotFoundScreen() {
const router = useRouter();
return (
<>
<Stack.Screen options={{ title: 'Page Not Found' }} />
<SafeAreaView style={styles.container}>
<View style={styles.content}>
<Text style={styles.errorCode}>404</Text>
<Text style={styles.title}>Page Not Found</Text>
<Text style={styles.subtitle}>
The page you&apos;re looking for doesn&apos;t exist or has been moved.
</Text>
<Pressable style={styles.button} onPress={() => router.replace('/')}>
<Text style={styles.buttonText}>Go Home</Text>
</Pressable>
</View>
</SafeAreaView>
</>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background.dark,
},
content: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: spacing.lg,
},
errorCode: {
fontSize: 72,
fontWeight: 'bold',
color: colors.cyan.core,
marginBottom: spacing.md,
},
title: {
fontSize: typography.fontSize['2xl'],
fontWeight: 'bold',
color: colors.text.primary.dark,
marginBottom: spacing.sm,
},
subtitle: {
fontSize: typography.fontSize.base,
color: colors.text.secondary.dark,
textAlign: 'center',
marginBottom: spacing.xl,
},
button: {
backgroundColor: colors.cyan.core,
paddingVertical: spacing.md,
paddingHorizontal: spacing.xl,
borderRadius: borderRadius.md,
},
buttonText: {
fontSize: typography.fontSize.base,
fontWeight: '600',
color: colors.blue.core,
},
});

128
apps/mobile/app/_layout.tsx Normal file
View File

@@ -0,0 +1,128 @@
/**
* Root layout - handles auth state and navigation
*/
import { Buffer } from 'buffer';
global.Buffer = Buffer;
import '../global.css';
import { useEffect, useState } from 'react';
import { Stack, useRouter, useSegments } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import { View, ActivityIndicator, StyleSheet } from 'react-native';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import { QueryProvider } from '@/providers/QueryProvider';
import { SocketProvider } from '@/providers/SocketProvider';
import { MediaServerProvider } from '@/providers/MediaServerProvider';
import { ErrorBoundary } from '@/components/ErrorBoundary';
import { useAuthStore } from '@/lib/authStore';
import { usePushNotifications } from '@/hooks/usePushNotifications';
import { colors } from '@/lib/theme';
function RootLayoutNav() {
const { isAuthenticated, isLoading, initialize } = useAuthStore();
const segments = useSegments();
const router = useRouter();
const [hasInitialized, setHasInitialized] = useState(false);
// Initialize push notifications (only when authenticated)
usePushNotifications();
// Initialize auth state on mount
useEffect(() => {
void initialize().finally(() => setHasInitialized(true));
}, [initialize]);
// Handle navigation based on auth state
// Note: We allow authenticated users to access (auth)/pair for adding servers
// Wait for initialization to complete before redirecting (prevents hot reload issues)
useEffect(() => {
if (isLoading || !hasInitialized) return;
const inAuthGroup = segments[0] === '(auth)';
if (!isAuthenticated && !inAuthGroup) {
// Not authenticated and not on auth screen - redirect to pair
router.replace('/(auth)/pair');
}
// Don't redirect away from pair if authenticated - user might be adding a server
}, [isAuthenticated, isLoading, hasInitialized, segments, router]);
// Show loading screen while initializing
if (isLoading) {
return (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={colors.cyan.core} />
</View>
);
}
return (
<>
<StatusBar style="light" backgroundColor={colors.background.dark} translucent={false} />
<Stack
screenOptions={{
headerShown: false,
contentStyle: { backgroundColor: colors.background.dark },
animation: 'fade',
}}
>
<Stack.Screen name="(auth)" options={{ headerShown: false }} />
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen
name="user"
options={{
headerShown: false,
presentation: 'card',
}}
/>
<Stack.Screen
name="session"
options={{
headerShown: false,
presentation: 'card',
}}
/>
<Stack.Screen
name="settings"
options={{
headerShown: false,
presentation: 'modal',
}}
/>
<Stack.Screen name="+not-found" />
</Stack>
</>
);
}
export default function RootLayout() {
return (
<GestureHandlerRootView style={styles.container}>
<SafeAreaProvider>
<ErrorBoundary>
<QueryProvider>
<SocketProvider>
<MediaServerProvider>
<RootLayoutNav />
</MediaServerProvider>
</SocketProvider>
</QueryProvider>
</ErrorBoundary>
</SafeAreaProvider>
</GestureHandlerRootView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background.dark,
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: colors.background.dark,
},
});

View File

@@ -0,0 +1,524 @@
/**
* Session detail screen
* Shows comprehensive information about a specific session/stream
* Query keys include selectedServerId for proper cache isolation per media server
*/
import { useLocalSearchParams, useRouter } from 'expo-router';
import { View, Text, ScrollView, Pressable, ActivityIndicator, Image, Alert } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { format } from 'date-fns';
import { useState, useEffect } from 'react';
import {
Play,
Pause,
Square,
User,
Server,
MapPin,
Smartphone,
Clock,
Gauge,
Tv,
Film,
Music,
Zap,
Globe,
Wifi,
X,
} from 'lucide-react-native';
import { api, getServerUrl } from '@/lib/api';
import { useMediaServer } from '@/providers/MediaServerProvider';
import { colors } from '@/lib/theme';
import { Badge } from '@/components/ui/badge';
import type { SessionWithDetails, SessionState, MediaType } from '@tracearr/shared';
// Safe date parsing helper - handles string dates from API
function safeParseDate(date: Date | string | null | undefined): Date | null {
if (!date) return null;
const parsed = new Date(date);
return isNaN(parsed.getTime()) ? null : parsed;
}
// Safe format date helper
function safeFormatDate(date: Date | string | null | undefined, formatStr: string): string {
const parsed = safeParseDate(date);
if (!parsed) return 'Unknown';
return format(parsed, formatStr);
}
// Get state icon, color, and badge variant
function getStateInfo(state: SessionState, watched?: boolean): {
icon: typeof Play;
color: string;
label: string;
variant: 'success' | 'warning' | 'secondary';
} {
// Show "Watched" for completed sessions where user watched 80%+
if (watched && state === 'stopped') {
return { icon: Play, color: colors.success, label: 'Watched', variant: 'success' };
}
switch (state) {
case 'playing':
return { icon: Play, color: colors.success, label: 'Playing', variant: 'success' };
case 'paused':
return { icon: Pause, color: colors.warning, label: 'Paused', variant: 'warning' };
case 'stopped':
return { icon: Square, color: colors.text.secondary.dark, label: 'Stopped', variant: 'secondary' };
default:
return { icon: Square, color: colors.text.secondary.dark, label: 'Unknown', variant: 'secondary' };
}
}
// Get media type icon
function getMediaIcon(mediaType: MediaType): typeof Film {
switch (mediaType) {
case 'movie':
return Film;
case 'episode':
return Tv;
case 'track':
return Music;
default:
return Film;
}
}
// Format duration
function formatDuration(ms: number | null): string {
if (ms === null) return '-';
const seconds = Math.floor(ms / 1000);
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60;
if (hours > 0) {
return `${hours}h ${minutes}m`;
}
if (minutes > 0) {
return `${minutes}m ${secs}s`;
}
return `${secs}s`;
}
// Format bitrate
function formatBitrate(bitrate: number | null): string {
if (bitrate === null) return '-';
if (bitrate >= 1000) {
return `${(bitrate / 1000).toFixed(1)} Mbps`;
}
return `${bitrate} Kbps`;
}
// Info card component
function InfoCard({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
return (
<View className="bg-card rounded-xl p-4 mb-4">
<Text className="text-muted-foreground text-sm font-medium mb-3">{title}</Text>
{children}
</View>
);
}
// Info row component
function InfoRow({
icon: Icon,
label,
value,
valueColor,
}: {
icon: typeof Play;
label: string;
value: string;
valueColor?: string;
}) {
return (
<View className="flex-row items-center py-2 border-b border-border last:border-b-0">
<Icon size={18} color={colors.text.secondary.dark} />
<Text className="text-muted-foreground text-sm ml-3 flex-1">{label}</Text>
<Text
className="text-sm font-medium"
style={{ color: valueColor || colors.text.primary.dark }}
>
{value}
</Text>
</View>
);
}
// Progress bar component
function ProgressBar({
progress,
total,
}: {
progress: number | null;
total: number | null;
}) {
if (progress === null || total === null || total === 0) {
return null;
}
const percentage = Math.min((progress / total) * 100, 100);
return (
<View style={{ marginTop: 12 }}>
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginBottom: 4 }}>
<Text style={{ color: colors.text.secondary.dark, fontSize: 12 }}>{formatDuration(progress)}</Text>
<Text style={{ color: colors.text.secondary.dark, fontSize: 12 }}>{formatDuration(total)}</Text>
</View>
<View style={{ backgroundColor: '#27272a', height: 8, borderRadius: 4, overflow: 'hidden' }}>
<View
style={{
backgroundColor: colors.cyan.core,
height: '100%',
borderRadius: 4,
width: `${percentage}%`
}}
/>
</View>
<Text style={{ color: '#71717a', fontSize: 12, textAlign: 'center', marginTop: 4 }}>
{percentage.toFixed(1)}% watched
</Text>
</View>
);
}
export default function SessionDetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const router = useRouter();
const queryClient = useQueryClient();
const { selectedServerId } = useMediaServer();
const [serverUrl, setServerUrl] = useState<string | null>(null);
// Load server URL for image paths
useEffect(() => {
void getServerUrl().then(setServerUrl);
}, []);
// Terminate session mutation
const terminateMutation = useMutation({
mutationFn: ({ sessionId, reason }: { sessionId: string; reason?: string }) =>
api.sessions.terminate(sessionId, reason),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['sessions', 'active'] });
Alert.alert('Stream Terminated', 'The playback session has been stopped.');
router.back();
},
onError: (error: Error) => {
Alert.alert('Failed to Terminate', error.message);
},
});
// Handle terminate button press
const handleTerminate = () => {
Alert.prompt(
'Terminate Stream',
'Enter an optional message to show the user (leave empty to skip):',
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Terminate',
style: 'destructive',
onPress: (reason: string | undefined) => {
terminateMutation.mutate({ sessionId: id, reason: reason?.trim() || undefined });
},
},
],
'plain-text',
'',
'default'
);
};
const {
data: session,
isLoading,
error,
} = useQuery<SessionWithDetails>({
queryKey: ['session', id, selectedServerId],
queryFn: async () => {
console.log('[SessionDetail] Fetching session:', id);
try {
const result = await api.sessions.get(id);
console.log('[SessionDetail] Received session data:', JSON.stringify(result, null, 2));
return result;
} catch (err) {
console.error('[SessionDetail] API error:', err);
throw err;
}
},
enabled: !!id,
});
// Debug logging
useEffect(() => {
console.log('[SessionDetail] State:', { id, isLoading, hasError: !!error, hasSession: !!session });
if (error) {
console.error('[SessionDetail] Query error:', error);
}
if (session) {
console.log('[SessionDetail] Session fields:', {
id: session.id,
username: session.username,
mediaTitle: session.mediaTitle,
state: session.state,
});
}
}, [id, isLoading, error, session]);
if (isLoading) {
return (
<SafeAreaView
style={{ flex: 1, backgroundColor: colors.background.dark, justifyContent: 'center', alignItems: 'center' }}
edges={['left', 'right', 'bottom']}
>
<ActivityIndicator size="large" color={colors.cyan.core} />
</SafeAreaView>
);
}
if (error || !session) {
return (
<SafeAreaView
style={{ flex: 1, backgroundColor: colors.background.dark, justifyContent: 'center', alignItems: 'center', padding: 16 }}
edges={['left', 'right', 'bottom']}
>
<Text style={{ color: '#f87171', textAlign: 'center' }}>
{error instanceof Error ? error.message : 'Failed to load session'}
</Text>
</SafeAreaView>
);
}
const stateInfo = getStateInfo(session.state, session.watched);
const MediaIcon = getMediaIcon(session.mediaType);
// Format media title with episode info
const getMediaTitle = (): string => {
if (session.mediaType === 'episode' && session.grandparentTitle) {
const episodeInfo = session.seasonNumber && session.episodeNumber
? `S${session.seasonNumber}E${session.episodeNumber}`
: '';
return `${session.grandparentTitle}${episodeInfo ? `${episodeInfo}` : ''}`;
}
return session.mediaTitle;
};
const getSubtitle = (): string => {
if (session.mediaType === 'episode') {
return session.mediaTitle; // Episode title
}
if (session.year) {
return String(session.year);
}
return '';
};
// Get location string
const getLocation = (): string => {
const parts = [session.geoCity, session.geoRegion, session.geoCountry].filter(Boolean);
return parts.join(', ') || 'Unknown';
};
return (
<SafeAreaView style={{ flex: 1, backgroundColor: colors.background.dark }} edges={['left', 'right', 'bottom']}>
<ScrollView style={{ flex: 1 }} contentContainerStyle={{ padding: 16 }}>
{/* Media Header */}
<View className="bg-card rounded-xl p-4 mb-4">
{/* Terminate button - top right */}
<View className="absolute top-2 right-2 z-10">
<Pressable
onPress={handleTerminate}
disabled={terminateMutation.isPending}
className="w-8 h-8 rounded-full bg-destructive/10 items-center justify-center active:opacity-70"
style={{ opacity: terminateMutation.isPending ? 0.5 : 1 }}
>
<X size={18} color="#ef4444" />
</Pressable>
</View>
<View className="flex-row items-start">
{/* Poster/Thumbnail */}
<View className="w-20 h-28 bg-surface rounded-lg mr-4 overflow-hidden">
{session.thumbPath && serverUrl ? (
<Image
source={{ uri: `${serverUrl}/api/v1/images/proxy?server=${session.serverId}&url=${encodeURIComponent(session.thumbPath)}&width=160&height=224` }}
style={{ width: '100%', height: '100%' }}
resizeMode="cover"
/>
) : (
<View className="w-full h-full justify-center items-center">
<MediaIcon size={32} color={colors.text.secondary.dark} />
</View>
)}
</View>
{/* Media Info */}
<View className="flex-1">
<View className="flex-row items-center mb-2">
<Badge variant={stateInfo.variant}>
{stateInfo.label}
</Badge>
</View>
<Text className="text-white text-lg font-semibold" numberOfLines={2}>
{getMediaTitle()}
</Text>
{getSubtitle() ? (
<Text className="text-muted-foreground text-sm mt-1" numberOfLines={1}>
{getSubtitle()}
</Text>
) : null}
<View className="flex-row items-center mt-2">
<MediaIcon size={14} color={colors.text.secondary.dark} />
<Text className="text-muted-foreground text-xs ml-1 capitalize">
{session.mediaType}
</Text>
</View>
</View>
</View>
{/* Progress bar */}
<ProgressBar progress={session.progressMs} total={session.totalDurationMs} />
</View>
{/* User Card - Tappable */}
<Pressable
onPress={() => router.push(`/user/${session.serverUserId}` as never)}
className="bg-card rounded-xl p-4 mb-4 active:opacity-70"
>
<Text className="text-muted-foreground text-sm font-medium mb-3">User</Text>
<View className="flex-row items-center">
<View className="w-12 h-12 rounded-full bg-surface overflow-hidden">
{session.userThumb ? (
<Image
source={{ uri: session.userThumb }}
className="w-full h-full"
resizeMode="cover"
/>
) : (
<View className="w-full h-full justify-center items-center">
<User size={24} color={colors.text.secondary.dark} />
</View>
)}
</View>
<View className="flex-1 ml-3">
<Text className="text-foreground text-base font-semibold">
{session.username}
</Text>
<Text className="text-muted-foreground text-sm">Tap to view profile</Text>
</View>
<Text className="text-primary text-sm"></Text>
</View>
</Pressable>
{/* Server Info */}
<InfoCard title="Server">
<View className="flex-row items-center">
<Server size={20} color={colors.text.secondary.dark} />
<View className="flex-1 ml-3">
<Text className="text-foreground text-base font-medium">
{session.serverName}
</Text>
<Text className="text-muted-foreground text-sm capitalize">
{session.serverType}
</Text>
</View>
</View>
</InfoCard>
{/* Timing Info */}
<InfoCard title="Timing">
<InfoRow
icon={Clock}
label="Started"
value={safeFormatDate(session.startedAt, 'MMM d, yyyy h:mm a')}
/>
{session.stoppedAt && (
<InfoRow
icon={Square}
label="Stopped"
value={safeFormatDate(session.stoppedAt, 'MMM d, yyyy h:mm a')}
/>
)}
<InfoRow
icon={Play}
label="Watch Time"
value={formatDuration(session.durationMs)}
/>
{(session.pausedDurationMs ?? 0) > 0 && (
<InfoRow
icon={Pause}
label="Paused Time"
value={formatDuration(session.pausedDurationMs)}
/>
)}
</InfoCard>
{/* Location Info */}
<InfoCard title="Location">
<InfoRow icon={Globe} label="IP Address" value={session.ipAddress || 'Unknown'} />
<InfoRow icon={MapPin} label="Location" value={getLocation()} />
{session.geoLat && session.geoLon && (
<InfoRow
icon={MapPin}
label="Coordinates"
value={`${session.geoLat.toFixed(4)}, ${session.geoLon.toFixed(4)}`}
/>
)}
</InfoCard>
{/* Device Info */}
<InfoCard title="Device">
<InfoRow
icon={Smartphone}
label="Player"
value={session.playerName || 'Unknown'}
/>
<InfoRow
icon={Tv}
label="Device"
value={session.device || 'Unknown'}
/>
<InfoRow
icon={Wifi}
label="Platform"
value={session.platform || 'Unknown'}
/>
{session.product && (
<InfoRow icon={Smartphone} label="Product" value={session.product} />
)}
</InfoCard>
{/* Quality Info */}
<InfoCard title="Quality">
<InfoRow
icon={Gauge}
label="Quality"
value={session.quality || 'Unknown'}
/>
<InfoRow
icon={Zap}
label="Transcode"
value={session.isTranscode ? 'Yes' : 'Direct Play'}
valueColor={session.isTranscode ? colors.warning : colors.success}
/>
{session.bitrate && (
<InfoRow icon={Gauge} label="Bitrate" value={formatBitrate(session.bitrate)} />
)}
</InfoCard>
{/* Bottom padding */}
<View className="h-8" />
</ScrollView>
</SafeAreaView>
);
}

View File

@@ -0,0 +1,43 @@
/**
* Session detail stack navigator layout
* Provides navigation for session detail screens
*/
import { Stack, useRouter } from 'expo-router';
import { Pressable } from 'react-native';
import { ChevronLeft } from 'lucide-react-native';
import { colors } from '@/lib/theme';
export default function SessionLayout() {
const router = useRouter();
return (
<Stack
screenOptions={{
headerShown: true,
headerStyle: {
backgroundColor: colors.background.dark,
},
headerTintColor: colors.text.primary.dark,
headerTitleStyle: {
fontWeight: '600',
},
headerBackTitle: 'Back',
headerLeft: () => (
<Pressable onPress={() => router.back()} hitSlop={8}>
<ChevronLeft size={28} color={colors.text.primary.dark} />
</Pressable>
),
contentStyle: {
backgroundColor: colors.background.dark,
},
}}
>
<Stack.Screen
name="[id]"
options={{
title: 'Session',
}}
/>
</Stack>
);
}

View File

@@ -0,0 +1,43 @@
/**
* Settings stack navigator layout
* Provides navigation between settings sub-screens
*/
import { Stack, useRouter } from 'expo-router';
import { Pressable } from 'react-native';
import { ChevronLeft } from 'lucide-react-native';
import { colors } from '@/lib/theme';
export default function SettingsLayout() {
const router = useRouter();
return (
<Stack
screenOptions={{
headerShown: true,
headerStyle: {
backgroundColor: colors.background.dark,
},
headerTintColor: colors.text.primary.dark,
headerTitleStyle: {
fontWeight: '600',
},
headerBackTitle: 'Back',
headerLeft: () => (
<Pressable onPress={() => router.back()} hitSlop={8}>
<ChevronLeft size={28} color={colors.text.primary.dark} />
</Pressable>
),
contentStyle: {
backgroundColor: colors.background.dark,
},
}}
>
<Stack.Screen
name="notifications"
options={{
title: 'Notification Settings',
}}
/>
</Stack>
);
}

View File

@@ -0,0 +1,538 @@
/**
* Notification Settings Screen
* Per-device push notification configuration
*/
import { View, ScrollView, Switch, Pressable, ActivityIndicator, Alert } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
Bell,
ShieldAlert,
Play,
Square,
Monitor,
Smartphone,
AlertTriangle,
ServerCrash,
ServerCog,
Moon,
Flame,
type LucideIcon,
} from 'lucide-react-native';
import { Text } from '@/components/ui/text';
import { Card } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { api } from '@/lib/api';
import { useAuthStore } from '@/lib/authStore';
import { colors } from '@/lib/theme';
import type { NotificationPreferences } from '@tracearr/shared';
// Rule types for violation filtering
const RULE_TYPES = [
{ value: 'impossible_travel', label: 'Impossible Travel' },
{ value: 'simultaneous_locations', label: 'Simultaneous Locations' },
{ value: 'device_velocity', label: 'Device Velocity' },
{ value: 'concurrent_streams', label: 'Concurrent Streams' },
{ value: 'geo_restriction', label: 'Geo Restriction' },
] as const;
// Severity levels
const SEVERITY_LEVELS = [
{ value: 1, label: 'All (Low, Warning, High)' },
{ value: 2, label: 'Warning & High only' },
{ value: 3, label: 'High severity only' },
] as const;
function Divider() {
return <View className="h-px bg-border ml-4" />;
}
function SettingsSection({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
return (
<View className="mb-6">
<Text className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-2">
{title}
</Text>
<Card className="p-0 overflow-hidden">{children}</Card>
</View>
);
}
function SettingRow({
icon: Icon,
label,
description,
value,
onValueChange,
disabled,
}: {
icon?: LucideIcon;
label: string;
description?: string;
value: boolean;
onValueChange: (value: boolean) => void;
disabled?: boolean;
}) {
return (
<View className="flex-row justify-between items-center px-4 py-3 min-h-[52px]">
<View className="flex-1 mr-4">
<View className="flex-row items-center">
{Icon && (
<Icon
size={18}
color={disabled ? colors.text.muted.dark : colors.text.secondary.dark}
style={{ marginRight: 10 }}
/>
)}
<Text className={cn('text-base', disabled && 'opacity-50')}>{label}</Text>
</View>
{description && (
<Text className={cn('text-xs text-muted-foreground mt-0.5', Icon && 'ml-7', disabled && 'opacity-50')}>
{description}
</Text>
)}
</View>
<Switch
value={value}
onValueChange={onValueChange}
disabled={disabled}
trackColor={{ false: colors.switch.trackOff, true: colors.switch.trackOn }}
thumbColor={value ? colors.switch.thumbOn : colors.switch.thumbOff}
/>
</View>
);
}
function SelectRow({
label,
value,
options,
onChange,
disabled,
}: {
label: string;
value: number;
options: ReadonlyArray<{ value: number; label: string }>;
onChange: (value: number) => void;
disabled?: boolean;
}) {
const currentOption = options.find((o) => o.value === value);
const handlePress = () => {
if (disabled) return;
Alert.alert(
label,
undefined,
options.map((option) => ({
text: option.label,
onPress: () => onChange(option.value),
style: option.value === value ? 'cancel' : 'default',
}))
);
};
return (
<Pressable
onPress={handlePress}
disabled={disabled}
className={cn('px-4 py-3 min-h-[52px]', 'active:opacity-70')}
>
<Text className={cn('text-sm text-muted-foreground mb-1', disabled && 'opacity-50')}>
{label}
</Text>
<Text className={cn('text-base', disabled && 'opacity-50')}>
{currentOption?.label ?? 'Select...'}
</Text>
</Pressable>
);
}
function MultiSelectRow({
selectedValues,
options,
onChange,
disabled,
}: {
selectedValues: string[];
options: ReadonlyArray<{ value: string; label: string }>;
onChange: (values: string[]) => void;
disabled?: boolean;
}) {
const toggleValue = (value: string) => {
if (disabled) return;
if (selectedValues.includes(value)) {
onChange(selectedValues.filter((v) => v !== value));
} else {
onChange([...selectedValues, value]);
}
};
const allSelected = selectedValues.length === 0;
return (
<View className="px-4 py-3">
<View className="flex-row flex-wrap gap-2.5">
<Pressable
onPress={() => onChange([])}
disabled={disabled}
className={cn(
'px-3 py-1.5 rounded-full border',
allSelected
? 'bg-cyan-core border-cyan-core'
: 'border-border bg-card',
disabled && 'opacity-50'
)}
>
<Text
className={cn(
'text-sm',
allSelected ? 'text-background' : 'text-foreground'
)}
>
All Types
</Text>
</Pressable>
{options.map((option) => {
const isSelected = selectedValues.includes(option.value);
return (
<Pressable
key={option.value}
onPress={() => toggleValue(option.value)}
disabled={disabled}
className={cn(
'px-3 py-1.5 rounded-full border',
isSelected
? 'bg-cyan-core border-cyan-core'
: 'border-border bg-card',
disabled && 'opacity-50'
)}
>
<Text
className={cn(
'text-sm',
isSelected ? 'text-background' : 'text-foreground'
)}
>
{option.label}
</Text>
</Pressable>
);
})}
</View>
</View>
);
}
function RateLimitStatus({
remainingMinute,
remainingHour,
maxPerMinute,
maxPerHour,
}: {
remainingMinute?: number;
remainingHour?: number;
maxPerMinute: number;
maxPerHour: number;
}) {
return (
<View className="px-4 py-3">
<Text className="text-sm text-muted-foreground mb-2">Current Rate Limit Status</Text>
<View className="flex-row gap-4">
<View className="flex-1 p-3 rounded-lg bg-surface">
<Text className="text-xs text-muted-foreground mb-1">Per Minute</Text>
<Text className="text-lg font-semibold">
{remainingMinute ?? maxPerMinute} / {maxPerMinute}
</Text>
</View>
<View className="flex-1 p-3 rounded-lg bg-surface">
<Text className="text-xs text-muted-foreground mb-1">Per Hour</Text>
<Text className="text-lg font-semibold">
{remainingHour ?? maxPerHour} / {maxPerHour}
</Text>
</View>
</View>
</View>
);
}
export default function NotificationSettingsScreen() {
const queryClient = useQueryClient();
const { activeServerId } = useAuthStore();
// Fetch current preferences (per-device, not per-server)
const {
data: preferences,
isLoading,
error,
} = useQuery({
queryKey: ['notifications', 'preferences'],
queryFn: api.notifications.getPreferences,
enabled: !!activeServerId, // Still need auth
});
// Update mutation with optimistic updates
const updateMutation = useMutation({
mutationFn: api.notifications.updatePreferences,
onMutate: async (newData) => {
await queryClient.cancelQueries({ queryKey: ['notifications', 'preferences'] });
const previousData = queryClient.getQueryData<NotificationPreferences>([
'notifications',
'preferences',
]);
queryClient.setQueryData(['notifications', 'preferences'], (old: NotificationPreferences | undefined) =>
old ? { ...old, ...newData } : old
);
return { previousData };
},
onError: (_err, _newData, context) => {
if (context?.previousData) {
queryClient.setQueryData(['notifications', 'preferences'], context.previousData);
}
},
onSettled: () => {
void queryClient.invalidateQueries({ queryKey: ['notifications', 'preferences'] });
},
});
// Test notification mutation
const testMutation = useMutation({
mutationFn: api.notifications.sendTest,
onSuccess: (result) => {
Alert.alert(
result.success ? 'Test Sent' : 'Test Failed',
result.message
);
},
onError: (error: Error) => {
Alert.alert('Error', error.message || 'Failed to send test notification');
},
});
const handleUpdate = (
key: keyof Omit<NotificationPreferences, 'id' | 'mobileSessionId' | 'createdAt' | 'updatedAt'>,
value: boolean | number | string[]
) => {
updateMutation.mutate({ [key]: value });
};
if (isLoading) {
return (
<SafeAreaView style={{ flex: 1, backgroundColor: colors.background.dark }} edges={['left', 'right', 'bottom']}>
<View className="flex-1 items-center justify-center">
<ActivityIndicator size="large" color={colors.cyan.core} />
<Text className="mt-4 text-muted-foreground">Loading preferences...</Text>
</View>
</SafeAreaView>
);
}
if (error || !preferences) {
return (
<SafeAreaView style={{ flex: 1, backgroundColor: colors.background.dark }} edges={['left', 'right', 'bottom']}>
<View className="flex-1 items-center justify-center px-8">
<Text className="text-xl font-semibold text-center mb-2">
Unable to Load Preferences
</Text>
<Text className="text-muted-foreground text-center">
{error instanceof Error ? error.message : 'An error occurred'}
</Text>
</View>
</SafeAreaView>
);
}
const pushEnabled = preferences.pushEnabled;
return (
<SafeAreaView style={{ flex: 1, backgroundColor: colors.background.dark }} edges={['left', 'right', 'bottom']}>
<ScrollView className="flex-1" contentContainerClassName="p-4">
{/* Master Toggle */}
<SettingsSection title="Push Notifications">
<SettingRow
icon={Bell}
label="Enable Push Notifications"
description="Receive alerts on this device"
value={pushEnabled}
onValueChange={(v) => handleUpdate('pushEnabled', v)}
/>
</SettingsSection>
{/* Event Toggles */}
<SettingsSection title="Notification Events">
<SettingRow
icon={ShieldAlert}
label="Violation Detected"
description="Rule violation triggered"
value={preferences.onViolationDetected}
onValueChange={(v) => handleUpdate('onViolationDetected', v)}
disabled={!pushEnabled}
/>
<Divider />
<SettingRow
icon={Play}
label="Stream Started"
description="New playback began"
value={preferences.onStreamStarted}
onValueChange={(v) => handleUpdate('onStreamStarted', v)}
disabled={!pushEnabled}
/>
<Divider />
<SettingRow
icon={Square}
label="Stream Stopped"
description="Playback ended"
value={preferences.onStreamStopped}
onValueChange={(v) => handleUpdate('onStreamStopped', v)}
disabled={!pushEnabled}
/>
<Divider />
<SettingRow
icon={Monitor}
label="Concurrent Streams"
description="User exceeded stream limit"
value={preferences.onConcurrentStreams}
onValueChange={(v) => handleUpdate('onConcurrentStreams', v)}
disabled={!pushEnabled}
/>
<Divider />
<SettingRow
icon={Smartphone}
label="New Device"
description="New device detected for user"
value={preferences.onNewDevice}
onValueChange={(v) => handleUpdate('onNewDevice', v)}
disabled={!pushEnabled}
/>
<Divider />
<SettingRow
icon={AlertTriangle}
label="Trust Score Changed"
description="User trust score degraded"
value={preferences.onTrustScoreChanged}
onValueChange={(v) => handleUpdate('onTrustScoreChanged', v)}
disabled={!pushEnabled}
/>
<Divider />
<SettingRow
icon={ServerCrash}
label="Server Down"
description="Media server unreachable"
value={preferences.onServerDown}
onValueChange={(v) => handleUpdate('onServerDown', v)}
disabled={!pushEnabled}
/>
<Divider />
<SettingRow
icon={ServerCog}
label="Server Up"
description="Media server back online"
value={preferences.onServerUp}
onValueChange={(v) => handleUpdate('onServerUp', v)}
disabled={!pushEnabled}
/>
</SettingsSection>
{/* Violation Filters - Only show if violation notifications are enabled */}
{pushEnabled && preferences.onViolationDetected && (
<SettingsSection title="Violation Filters">
<MultiSelectRow
selectedValues={preferences.violationRuleTypes}
options={RULE_TYPES}
onChange={(values) => handleUpdate('violationRuleTypes', values)}
/>
<Divider />
<SelectRow
label="Minimum Severity"
value={preferences.violationMinSeverity}
options={SEVERITY_LEVELS}
onChange={(value) => handleUpdate('violationMinSeverity', value)}
/>
</SettingsSection>
)}
{/* Quiet Hours */}
<SettingsSection title="Quiet Hours">
<SettingRow
icon={Moon}
label="Enable Quiet Hours"
description="Pause non-critical notifications during set hours"
value={preferences.quietHoursEnabled}
onValueChange={(v) => handleUpdate('quietHoursEnabled', v)}
disabled={!pushEnabled}
/>
{pushEnabled && preferences.quietHoursEnabled && (
<>
<Divider />
<View className="px-4 py-3">
<View className="flex-row justify-between items-center">
<View>
<Text className="text-sm text-muted-foreground">Start Time</Text>
<Text className="text-base">{preferences.quietHoursStart ?? '23:00'}</Text>
</View>
<Text className="text-muted-foreground mx-4">to</Text>
<View>
<Text className="text-sm text-muted-foreground">End Time</Text>
<Text className="text-base">{preferences.quietHoursEnd ?? '08:00'}</Text>
</View>
</View>
<Text className="text-xs text-muted-foreground mt-2">
Timezone: {preferences.quietHoursTimezone || 'UTC'}
</Text>
</View>
<Divider />
<SettingRow
icon={Flame}
label="Override for Critical"
description="High-severity violations still notify during quiet hours"
value={preferences.quietHoursOverrideCritical}
onValueChange={(v) => handleUpdate('quietHoursOverrideCritical', v)}
/>
</>
)}
</SettingsSection>
{/* Rate Limiting */}
<SettingsSection title="Rate Limiting">
<RateLimitStatus
remainingMinute={preferences.rateLimitStatus?.remainingMinute}
remainingHour={preferences.rateLimitStatus?.remainingHour}
maxPerMinute={preferences.maxPerMinute}
maxPerHour={preferences.maxPerHour}
/>
<Divider />
<View className="px-4 py-2">
<Text className="text-xs text-muted-foreground leading-4">
Rate limits prevent notification spam. Current limits: {preferences.maxPerMinute}/min, {preferences.maxPerHour}/hour.
</Text>
</View>
</SettingsSection>
{/* Test Notification */}
<View className="mt-2 mb-4">
<Button
onPress={() => testMutation.mutate()}
disabled={!pushEnabled || testMutation.isPending}
className={cn(!pushEnabled && 'opacity-50')}
>
{testMutation.isPending ? (
<ActivityIndicator size="small" color={colors.background.dark} />
) : (
<Text className="text-background font-semibold">Send Test Notification</Text>
)}
</Button>
<Text className="text-xs text-muted-foreground text-center mt-2">
Verify that push notifications are working correctly
</Text>
</View>
</ScrollView>
</SafeAreaView>
);
}

View File

@@ -0,0 +1,815 @@
/**
* User Detail Screen
* Shows comprehensive user information with web feature parity
* Query keys include selectedServerId for proper cache isolation per media server
*/
import { View, ScrollView, RefreshControl, Pressable, ActivityIndicator, Image } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useLocalSearchParams, useNavigation, useRouter } from 'expo-router';
import { useQuery, useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { formatDistanceToNow, format } from 'date-fns';
import {
Crown,
Play,
Clock,
AlertTriangle,
Globe,
MapPin,
Smartphone,
Monitor,
Tv,
ChevronRight,
Users,
Zap,
Check,
Film,
Music,
XCircle,
User,
Bot,
type LucideIcon,
} from 'lucide-react-native';
import { useEffect, useState } from 'react';
import { api, getServerUrl } from '@/lib/api';
import { useMediaServer } from '@/providers/MediaServerProvider';
import { Text } from '@/components/ui/text';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { UserAvatar } from '@/components/ui/user-avatar';
import { cn } from '@/lib/utils';
import { colors } from '@/lib/theme';
import type {
Session,
ViolationWithDetails,
UserLocation,
UserDevice,
RuleType,
TerminationLogWithDetails,
} from '@tracearr/shared';
const PAGE_SIZE = 10;
// Safe date parsing helper - handles string dates from API
function safeParseDate(date: Date | string | null | undefined): Date | null {
if (!date) return null;
const parsed = new Date(date);
return isNaN(parsed.getTime()) ? null : parsed;
}
// Safe format distance helper
function safeFormatDistanceToNow(date: Date | string | null | undefined): string {
const parsed = safeParseDate(date);
if (!parsed) return 'Unknown';
return formatDistanceToNow(parsed, { addSuffix: true });
}
// Safe format date helper
function safeFormatDate(date: Date | string | null | undefined, formatStr: string): string {
const parsed = safeParseDate(date);
if (!parsed) return 'Unknown';
return format(parsed, formatStr);
}
// Rule type icons mapping
const ruleIcons: Record<RuleType, LucideIcon> = {
impossible_travel: MapPin,
simultaneous_locations: Users,
device_velocity: Zap,
concurrent_streams: Monitor,
geo_restriction: Globe,
};
// Rule type display names
const ruleLabels: Record<RuleType, string> = {
impossible_travel: 'Impossible Travel',
simultaneous_locations: 'Simultaneous Locations',
device_velocity: 'Device Velocity',
concurrent_streams: 'Concurrent Streams',
geo_restriction: 'Geo Restriction',
};
function TrustScoreBadge({ score, showLabel = false }: { score: number; showLabel?: boolean }) {
const variant = score < 50 ? 'destructive' : score < 75 ? 'warning' : 'success';
const label = score < 50 ? 'Low' : score < 75 ? 'Medium' : 'High';
return (
<View className="flex-row items-center gap-2">
<View
className={cn(
'px-2.5 py-1 rounded-md min-w-[45px] items-center',
variant === 'destructive' && 'bg-destructive/20',
variant === 'warning' && 'bg-warning/20',
variant === 'success' && 'bg-success/20'
)}
>
<Text
className={cn(
'text-base font-bold',
variant === 'destructive' && 'text-destructive',
variant === 'warning' && 'text-warning',
variant === 'success' && 'text-success'
)}
>
{score}
</Text>
</View>
{showLabel && (
<Text className="text-sm text-muted-foreground">{label} Trust</Text>
)}
</View>
);
}
function StatCard({ icon: Icon, label, value, subValue }: {
icon: LucideIcon;
label: string;
value: string | number;
subValue?: string;
}) {
return (
<View className="flex-1 bg-surface rounded-lg p-3 border border-border">
<View className="flex-row items-center gap-2 mb-1">
<Icon size={14} color={colors.text.muted.dark} />
<Text className="text-xs text-muted-foreground">{label}</Text>
</View>
<Text className="text-xl font-bold">{value}</Text>
{subValue && <Text className="text-xs text-muted-foreground mt-0.5">{subValue}</Text>}
</View>
);
}
function SeverityBadge({ severity }: { severity: string }) {
const variant =
severity === 'critical' || severity === 'high'
? 'destructive'
: severity === 'warning'
? 'warning'
: 'default';
return (
<Badge variant={variant} className="capitalize">
{severity}
</Badge>
);
}
function formatDuration(ms: number | null): string {
if (!ms) return '-';
const totalSeconds = Math.floor(ms / 1000);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
if (hours > 0) return `${hours}h ${minutes}m`;
if (minutes > 0) return `${minutes}m ${seconds}s`;
return `${seconds}s`;
}
function LocationCard({ location }: { location: UserLocation }) {
const locationText = [location.city, location.region, location.country]
.filter(Boolean)
.join(', ') || 'Unknown Location';
return (
<View className="flex-row items-center gap-3 py-3 border-b border-border">
<View className="w-8 h-8 rounded-full bg-cyan-core/10 items-center justify-center">
<MapPin size={16} color={colors.cyan.core} />
</View>
<View className="flex-1">
<Text className="text-sm font-medium">{locationText}</Text>
<Text className="text-xs text-muted-foreground">
{location.sessionCount} {location.sessionCount === 1 ? 'session' : 'sessions'}
{' • '}
{safeFormatDistanceToNow(location.lastSeenAt)}
</Text>
</View>
</View>
);
}
function DeviceCard({ device }: { device: UserDevice }) {
const deviceName = device.playerName || device.device || device.product || 'Unknown Device';
const platform = device.platform || 'Unknown Platform';
return (
<View className="flex-row items-center gap-3 py-3 border-b border-border">
<View className="w-8 h-8 rounded-full bg-cyan-core/10 items-center justify-center">
<Smartphone size={16} color={colors.cyan.core} />
</View>
<View className="flex-1">
<Text className="text-sm font-medium">{deviceName}</Text>
<Text className="text-xs text-muted-foreground">
{platform} {device.sessionCount} {device.sessionCount === 1 ? 'session' : 'sessions'}
</Text>
<Text className="text-xs text-muted-foreground">
Last seen {safeFormatDistanceToNow(device.lastSeenAt)}
</Text>
</View>
</View>
);
}
function getMediaIcon(mediaType: string): typeof Film {
switch (mediaType) {
case 'movie':
return Film;
case 'episode':
return Tv;
case 'track':
return Music;
default:
return Film;
}
}
function SessionCard({ session, onPress, serverUrl }: { session: Session; onPress?: () => void; serverUrl: string | null }) {
const locationText = [session.geoCity, session.geoCountry].filter(Boolean).join(', ');
const MediaIcon = getMediaIcon(session.mediaType);
// Build poster URL - need serverId and thumbPath
const hasPoster = serverUrl && session.thumbPath && session.serverId;
const posterUrl = hasPoster
? `${serverUrl}/api/v1/images/proxy?server=${session.serverId}&url=${encodeURIComponent(session.thumbPath!)}&width=80&height=120`
: null;
// Determine display state - show "Watched" for completed sessions that reached 80%+
const getDisplayState = () => {
if (session.watched) return { label: 'Watched', variant: 'success' as const };
if (session.state === 'playing') return { label: 'Playing', variant: 'success' as const };
if (session.state === 'paused') return { label: 'Paused', variant: 'warning' as const };
if (session.state === 'stopped') return { label: 'Stopped', variant: 'secondary' as const };
return { label: session.state || 'Unknown', variant: 'secondary' as const };
};
const displayState = getDisplayState();
return (
<Pressable onPress={onPress} className="py-3 border-b border-border active:opacity-70">
<View className="flex-row">
{/* Poster */}
<View className="w-10 h-14 rounded-md bg-surface overflow-hidden mr-3">
{posterUrl ? (
<Image
source={{ uri: posterUrl }}
style={{ width: '100%', height: '100%' }}
resizeMode="cover"
/>
) : (
<View className="w-full h-full items-center justify-center">
<MediaIcon size={18} color={colors.text.muted.dark} />
</View>
)}
</View>
{/* Content */}
<View className="flex-1">
<View className="flex-row justify-between items-start mb-1">
<View className="flex-1 mr-2">
<Text className="text-sm font-medium" numberOfLines={1}>
{session.mediaTitle}
</Text>
<Text className="text-xs text-muted-foreground capitalize">{session.mediaType}</Text>
</View>
<Badge variant={displayState.variant}>
{displayState.label}
</Badge>
</View>
<View className="flex-row items-center gap-4 mt-1">
<View className="flex-row items-center gap-1">
<Clock size={12} color={colors.text.muted.dark} />
<Text className="text-xs text-muted-foreground">{formatDuration(session.durationMs)}</Text>
</View>
<View className="flex-row items-center gap-1">
<Tv size={12} color={colors.text.muted.dark} />
<Text className="text-xs text-muted-foreground">{session.platform || 'Unknown'}</Text>
</View>
{locationText && (
<View className="flex-row items-center gap-1">
<Globe size={12} color={colors.text.muted.dark} />
<Text className="text-xs text-muted-foreground">{locationText}</Text>
</View>
)}
</View>
</View>
</View>
</Pressable>
);
}
function ViolationCard({
violation,
onAcknowledge,
}: {
violation: ViolationWithDetails;
onAcknowledge: () => void;
}) {
const ruleType = violation.rule?.type as RuleType | undefined;
const ruleName = ruleType ? ruleLabels[ruleType] : violation.rule?.name || 'Unknown Rule';
const IconComponent = ruleType ? ruleIcons[ruleType] : AlertTriangle;
const timeAgo = safeFormatDistanceToNow(violation.createdAt);
return (
<View className="py-3 border-b border-border">
<View className="flex-row justify-between items-start mb-2">
<View className="flex-row items-center gap-2 flex-1">
<View className="w-7 h-7 rounded-md bg-surface items-center justify-center">
<IconComponent size={14} color={colors.cyan.core} />
</View>
<View className="flex-1">
<Text className="text-sm font-medium">{ruleName}</Text>
<Text className="text-xs text-muted-foreground">{timeAgo}</Text>
</View>
</View>
<SeverityBadge severity={violation.severity} />
</View>
{!violation.acknowledgedAt ? (
<Pressable
className="flex-row items-center justify-center gap-1.5 bg-cyan-core/15 py-2 rounded-md mt-2 active:opacity-70"
onPress={onAcknowledge}
>
<Check size={14} color={colors.cyan.core} />
<Text className="text-xs font-semibold text-cyan-core">Acknowledge</Text>
</Pressable>
) : (
<View className="flex-row items-center gap-1.5 mt-2">
<Check size={14} color={colors.success} />
<Text className="text-xs text-success">Acknowledged</Text>
</View>
)}
</View>
);
}
function TerminationCard({ termination }: { termination: TerminationLogWithDetails }) {
const timeAgo = safeFormatDistanceToNow(termination.createdAt);
const isManual = termination.trigger === 'manual';
return (
<View className="py-3 border-b border-border">
<View className="flex-row justify-between items-start mb-2">
<View className="flex-row items-center gap-2 flex-1">
<View className="w-7 h-7 rounded-md bg-surface items-center justify-center">
{isManual ? (
<User size={14} color={colors.cyan.core} />
) : (
<Bot size={14} color={colors.cyan.core} />
)}
</View>
<View className="flex-1">
<Text className="text-sm font-medium" numberOfLines={1}>
{termination.mediaTitle ?? 'Unknown Media'}
</Text>
<Text className="text-xs text-muted-foreground capitalize">
{termination.mediaType ?? 'unknown'} {timeAgo}
</Text>
</View>
</View>
<Badge variant={isManual ? 'default' : 'secondary'}>
{isManual ? 'Manual' : 'Rule'}
</Badge>
</View>
<View className="ml-9">
<Text className="text-xs text-muted-foreground">
{isManual
? `By @${termination.triggeredByUsername ?? 'Unknown'}`
: termination.ruleName ?? 'Unknown rule'}
</Text>
{termination.reason && (
<Text className="text-xs text-muted-foreground mt-1" numberOfLines={2}>
Reason: {termination.reason}
</Text>
)}
<View className="flex-row items-center gap-1 mt-1">
{termination.success ? (
<>
<Check size={12} color={colors.success} />
<Text className="text-xs text-success">Success</Text>
</>
) : (
<>
<XCircle size={12} color={colors.error} />
<Text className="text-xs text-destructive">Failed</Text>
</>
)}
</View>
</View>
</View>
);
}
export default function UserDetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const navigation = useNavigation();
const router = useRouter();
const queryClient = useQueryClient();
const { selectedServerId } = useMediaServer();
const [serverUrl, setServerUrl] = useState<string | null>(null);
// Load server URL for image proxy
useEffect(() => {
void getServerUrl().then(setServerUrl);
}, []);
// Fetch user detail - query keys include selectedServerId for cache isolation
const {
data: user,
isLoading: userLoading,
refetch: refetchUser,
isRefetching: userRefetching,
} = useQuery({
queryKey: ['user', id, selectedServerId],
queryFn: () => api.users.get(id),
enabled: !!id,
});
// Update header title with username
useEffect(() => {
if (user?.username) {
navigation.setOptions({ title: user.username });
}
}, [user?.username, navigation]);
// Fetch user sessions
const {
data: sessionsData,
isLoading: sessionsLoading,
fetchNextPage: fetchMoreSessions,
hasNextPage: hasMoreSessions,
isFetchingNextPage: fetchingMoreSessions,
} = useInfiniteQuery({
queryKey: ['user', id, 'sessions', selectedServerId],
queryFn: ({ pageParam = 1 }) => api.users.sessions(id, { page: pageParam, pageSize: PAGE_SIZE }),
initialPageParam: 1,
getNextPageParam: (lastPage: { page: number; totalPages: number }) => {
if (lastPage.page < lastPage.totalPages) {
return lastPage.page + 1;
}
return undefined;
},
enabled: !!id,
});
// Fetch user violations
const {
data: violationsData,
isLoading: violationsLoading,
fetchNextPage: fetchMoreViolations,
hasNextPage: hasMoreViolations,
isFetchingNextPage: fetchingMoreViolations,
} = useInfiniteQuery({
queryKey: ['violations', { userId: id }, selectedServerId],
queryFn: ({ pageParam = 1 }) => api.violations.list({ userId: id, page: pageParam, pageSize: PAGE_SIZE }),
initialPageParam: 1,
getNextPageParam: (lastPage: { page: number; totalPages: number }) => {
if (lastPage.page < lastPage.totalPages) {
return lastPage.page + 1;
}
return undefined;
},
enabled: !!id,
});
// Fetch user locations
const { data: locations, isLoading: locationsLoading } = useQuery({
queryKey: ['user', id, 'locations', selectedServerId],
queryFn: () => api.users.locations(id),
enabled: !!id,
});
// Fetch user devices
const { data: devices, isLoading: devicesLoading } = useQuery({
queryKey: ['user', id, 'devices', selectedServerId],
queryFn: () => api.users.devices(id),
enabled: !!id,
});
// Fetch user terminations
const {
data: terminationsData,
isLoading: terminationsLoading,
fetchNextPage: fetchMoreTerminations,
hasNextPage: hasMoreTerminations,
isFetchingNextPage: fetchingMoreTerminations,
} = useInfiniteQuery({
queryKey: ['user', id, 'terminations', selectedServerId],
queryFn: ({ pageParam = 1 }) =>
api.users.terminations(id, { page: pageParam, pageSize: PAGE_SIZE }),
initialPageParam: 1,
getNextPageParam: (lastPage: { page: number; totalPages: number }) => {
if (lastPage.page < lastPage.totalPages) {
return lastPage.page + 1;
}
return undefined;
},
enabled: !!id,
});
// Acknowledge mutation
const acknowledgeMutation = useMutation({
mutationFn: api.violations.acknowledge,
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['violations', { userId: id }, selectedServerId] });
},
});
const sessions = sessionsData?.pages.flatMap((page) => page.data) || [];
const violations = violationsData?.pages.flatMap((page) => page.data) || [];
const terminations = terminationsData?.pages.flatMap((page) => page.data) || [];
const totalSessions = sessionsData?.pages[0]?.total || 0;
const totalViolations = violationsData?.pages[0]?.total || 0;
const totalTerminations = terminationsData?.pages[0]?.total || 0;
const handleRefresh = () => {
void refetchUser();
void queryClient.invalidateQueries({ queryKey: ['user', id, 'sessions', selectedServerId] });
void queryClient.invalidateQueries({ queryKey: ['violations', { userId: id }, selectedServerId] });
void queryClient.invalidateQueries({ queryKey: ['user', id, 'locations', selectedServerId] });
void queryClient.invalidateQueries({ queryKey: ['user', id, 'devices', selectedServerId] });
void queryClient.invalidateQueries({ queryKey: ['user', id, 'terminations', selectedServerId] });
};
const handleSessionPress = (session: Session) => {
router.push(`/session/${session.id}` as never);
};
if (userLoading) {
return (
<SafeAreaView style={{ flex: 1, backgroundColor: colors.background.dark }} edges={['left', 'right', 'bottom']}>
<View className="flex-1 items-center justify-center">
<ActivityIndicator size="large" color={colors.cyan.core} />
</View>
</SafeAreaView>
);
}
if (!user) {
return (
<SafeAreaView style={{ flex: 1, backgroundColor: colors.background.dark }} edges={['left', 'right', 'bottom']}>
<View className="flex-1 items-center justify-center px-8">
<Text className="text-xl font-semibold text-center mb-2">User Not Found</Text>
<Text className="text-muted-foreground text-center">This user may have been removed.</Text>
</View>
</SafeAreaView>
);
}
return (
<SafeAreaView style={{ flex: 1, backgroundColor: colors.background.dark }} edges={['left', 'right', 'bottom']}>
<ScrollView
className="flex-1"
contentContainerClassName="p-4"
refreshControl={
<RefreshControl
refreshing={userRefetching}
onRefresh={handleRefresh}
tintColor={colors.cyan.core}
/>
}
>
{/* User Info Card */}
<Card className="mb-4">
<View className="flex-row items-start gap-4">
<UserAvatar
thumbUrl={user.thumbUrl}
username={user.username}
size={64}
/>
<View className="flex-1">
<View className="flex-row items-center gap-2 mb-1">
<Text className="text-xl font-bold">{user.username}</Text>
{user.role === 'owner' && (
<Crown size={18} color={colors.warning} />
)}
</View>
{user.email && (
<Text className="text-sm text-muted-foreground mb-2">{user.email}</Text>
)}
<TrustScoreBadge score={user.trustScore} showLabel />
</View>
</View>
</Card>
{/* Stats Grid */}
<View className="flex-row gap-3 mb-4">
<StatCard
icon={Play}
label="Sessions"
value={totalSessions}
/>
<StatCard
icon={AlertTriangle}
label="Violations"
value={totalViolations}
/>
</View>
<View className="flex-row gap-3 mb-4">
<StatCard
icon={Clock}
label="Joined"
value={safeFormatDate(user.createdAt, 'MMM d, yyyy')}
/>
<StatCard
icon={Globe}
label="Locations"
value={locations?.length || 0}
/>
</View>
{/* Locations */}
<Card className="mb-4">
<CardHeader>
<View className="flex-row justify-between items-center">
<CardTitle>Locations</CardTitle>
<Text className="text-xs text-muted-foreground">
{locations?.length || 0} {locations?.length === 1 ? 'location' : 'locations'}
</Text>
</View>
</CardHeader>
<CardContent>
{locationsLoading ? (
<ActivityIndicator size="small" color={colors.cyan.core} />
) : locations && locations.length > 0 ? (
locations.slice(0, 5).map((location, index) => (
<LocationCard key={`${location.city}-${location.country}-${index}`} location={location} />
))
) : (
<Text className="text-sm text-muted-foreground py-4 text-center">No locations recorded</Text>
)}
{locations && locations.length > 5 && (
<View className="pt-3 items-center">
<Text className="text-xs text-muted-foreground">
+{locations.length - 5} more locations
</Text>
</View>
)}
</CardContent>
</Card>
{/* Devices */}
<Card className="mb-4">
<CardHeader>
<View className="flex-row justify-between items-center">
<CardTitle>Devices</CardTitle>
<Text className="text-xs text-muted-foreground">
{devices?.length || 0} {devices?.length === 1 ? 'device' : 'devices'}
</Text>
</View>
</CardHeader>
<CardContent>
{devicesLoading ? (
<ActivityIndicator size="small" color={colors.cyan.core} />
) : devices && devices.length > 0 ? (
devices.slice(0, 5).map((device, index) => (
<DeviceCard key={device.deviceId || index} device={device} />
))
) : (
<Text className="text-sm text-muted-foreground py-4 text-center">No devices recorded</Text>
)}
{devices && devices.length > 5 && (
<View className="pt-3 items-center">
<Text className="text-xs text-muted-foreground">
+{devices.length - 5} more devices
</Text>
</View>
)}
</CardContent>
</Card>
{/* Recent Sessions */}
<Card className="mb-4">
<CardHeader>
<View className="flex-row justify-between items-center">
<CardTitle>Recent Sessions</CardTitle>
<Text className="text-xs text-muted-foreground">{totalSessions} total</Text>
</View>
</CardHeader>
<CardContent>
{sessionsLoading ? (
<ActivityIndicator size="small" color={colors.cyan.core} />
) : sessions.length > 0 ? (
<>
{sessions.map((session) => (
<SessionCard
key={session.id}
session={session}
serverUrl={serverUrl}
onPress={() => handleSessionPress(session)}
/>
))}
{hasMoreSessions && (
<Pressable
className="py-3 items-center active:opacity-70"
onPress={() => void fetchMoreSessions()}
disabled={fetchingMoreSessions}
>
{fetchingMoreSessions ? (
<ActivityIndicator size="small" color={colors.cyan.core} />
) : (
<View className="flex-row items-center gap-1">
<Text className="text-sm text-cyan-core font-medium">Load More</Text>
<ChevronRight size={16} color={colors.cyan.core} />
</View>
)}
</Pressable>
)}
</>
) : (
<Text className="text-sm text-muted-foreground py-4 text-center">No sessions found</Text>
)}
</CardContent>
</Card>
{/* Violations */}
<Card className="mb-8">
<CardHeader>
<View className="flex-row justify-between items-center">
<CardTitle>Violations</CardTitle>
<Text className="text-xs text-muted-foreground">{totalViolations} total</Text>
</View>
</CardHeader>
<CardContent>
{violationsLoading ? (
<ActivityIndicator size="small" color={colors.cyan.core} />
) : violations.length > 0 ? (
<>
{violations.map((violation) => (
<ViolationCard
key={violation.id}
violation={violation}
onAcknowledge={() => acknowledgeMutation.mutate(violation.id)}
/>
))}
{hasMoreViolations && (
<Pressable
className="py-3 items-center active:opacity-70"
onPress={() => void fetchMoreViolations()}
disabled={fetchingMoreViolations}
>
{fetchingMoreViolations ? (
<ActivityIndicator size="small" color={colors.cyan.core} />
) : (
<View className="flex-row items-center gap-1">
<Text className="text-sm text-cyan-core font-medium">Load More</Text>
<ChevronRight size={16} color={colors.cyan.core} />
</View>
)}
</Pressable>
)}
</>
) : (
<View className="py-4 items-center">
<View className="w-12 h-12 rounded-full bg-success/10 items-center justify-center mb-2">
<Check size={24} color={colors.success} />
</View>
<Text className="text-sm text-muted-foreground">No violations</Text>
</View>
)}
</CardContent>
</Card>
{/* Termination History */}
<Card className="mb-8">
<CardHeader>
<View className="flex-row justify-between items-center">
<View className="flex-row items-center gap-2">
<XCircle size={18} color={colors.text.primary.dark} />
<CardTitle>Termination History</CardTitle>
</View>
<Text className="text-xs text-muted-foreground">{totalTerminations} total</Text>
</View>
</CardHeader>
<CardContent>
{terminationsLoading ? (
<ActivityIndicator size="small" color={colors.cyan.core} />
) : terminations.length > 0 ? (
<>
{terminations.map((termination) => (
<TerminationCard key={termination.id} termination={termination} />
))}
{hasMoreTerminations && (
<Pressable
className="py-3 items-center active:opacity-70"
onPress={() => void fetchMoreTerminations()}
disabled={fetchingMoreTerminations}
>
{fetchingMoreTerminations ? (
<ActivityIndicator size="small" color={colors.cyan.core} />
) : (
<View className="flex-row items-center gap-1">
<Text className="text-sm text-cyan-core font-medium">Load More</Text>
<ChevronRight size={16} color={colors.cyan.core} />
</View>
)}
</Pressable>
)}
</>
) : (
<Text className="text-sm text-muted-foreground py-4 text-center">
No stream terminations
</Text>
)}
</CardContent>
</Card>
</ScrollView>
</SafeAreaView>
);
}

View File

@@ -0,0 +1,43 @@
/**
* User detail stack navigator layout
* Provides navigation for user detail screens
*/
import { Stack, useRouter } from 'expo-router';
import { Pressable } from 'react-native';
import { ChevronLeft } from 'lucide-react-native';
import { colors } from '@/lib/theme';
export default function UserLayout() {
const router = useRouter();
return (
<Stack
screenOptions={{
headerShown: true,
headerStyle: {
backgroundColor: colors.background.dark,
},
headerTintColor: colors.text.primary.dark,
headerTitleStyle: {
fontWeight: '600',
},
headerBackTitle: 'Back',
headerLeft: () => (
<Pressable onPress={() => router.back()} hitSlop={8}>
<ChevronLeft size={28} color={colors.text.primary.dark} />
</Pressable>
),
contentStyle: {
backgroundColor: colors.background.dark,
},
}}
>
<Stack.Screen
name="[id]"
options={{
title: 'User',
}}
/>
</Stack>
);
}