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
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:
18
apps/mobile/app/(auth)/_layout.tsx
Normal file
18
apps/mobile/app/(auth)/_layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
408
apps/mobile/app/(auth)/pair.tsx
Normal file
408
apps/mobile/app/(auth)/pair.tsx
Normal 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',
|
||||
},
|
||||
});
|
||||
116
apps/mobile/app/(tabs)/_layout.tsx
Normal file
116
apps/mobile/app/(tabs)/_layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
157
apps/mobile/app/(tabs)/activity.tsx
Normal file
157
apps/mobile/app/(tabs)/activity.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
326
apps/mobile/app/(tabs)/alerts.tsx
Normal file
326
apps/mobile/app/(tabs)/alerts.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
208
apps/mobile/app/(tabs)/index.tsx
Normal file
208
apps/mobile/app/(tabs)/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
299
apps/mobile/app/(tabs)/settings.tsx
Normal file
299
apps/mobile/app/(tabs)/settings.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
149
apps/mobile/app/(tabs)/users.tsx
Normal file
149
apps/mobile/app/(tabs)/users.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
71
apps/mobile/app/+not-found.tsx
Normal file
71
apps/mobile/app/+not-found.tsx
Normal 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're looking for doesn'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
128
apps/mobile/app/_layout.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
524
apps/mobile/app/session/[id].tsx
Normal file
524
apps/mobile/app/session/[id].tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
43
apps/mobile/app/session/_layout.tsx
Normal file
43
apps/mobile/app/session/_layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
43
apps/mobile/app/settings/_layout.tsx
Normal file
43
apps/mobile/app/settings/_layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
538
apps/mobile/app/settings/notifications.tsx
Normal file
538
apps/mobile/app/settings/notifications.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
815
apps/mobile/app/user/[id].tsx
Normal file
815
apps/mobile/app/user/[id].tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
43
apps/mobile/app/user/_layout.tsx
Normal file
43
apps/mobile/app/user/_layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user