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

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

8
apps/mobile/.env.example Normal file
View File

@@ -0,0 +1,8 @@
# Tracearr Mobile Environment Variables
# Copy this file to .env and fill in your values
# Your Tracearr server URL (set during QR pairing, but can override for development)
# EXPO_PUBLIC_API_URL=http://localhost:3000
# EAS Project ID (from expo.dev)
EXPO_PUBLIC_PROJECT_ID=your-eas-project-id

View File

@@ -0,0 +1,5 @@
babel.config.js
metro.config.js
postcss.config.mjs
.expo/
plugins/

45
apps/mobile/.gitignore vendored Normal file
View File

@@ -0,0 +1,45 @@
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
# dependencies
node_modules/
# Expo
.expo/
dist/
web-build/
expo-env.d.ts
# Native
.kotlin/
*.orig.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
# Metro
.metro-health-check*
# debug
npm-debug.*
yarn-debug.*
yarn-error.*
# macOS
.DS_Store
*.pem
# local env files
.env
.env*.local
# credentials (generated in CI)
credentials/
# typescript
*.tsbuildinfo
# generated native folders
/ios
/android

34
apps/mobile/app.config.js Normal file
View File

@@ -0,0 +1,34 @@
/**
* Dynamic Expo config that extends app.json
* Allows injecting secrets from environment variables at build time
*/
const baseConfig = require('./app.json');
module.exports = ({ config }) => {
// Merge base config with dynamic values
return {
...baseConfig.expo,
...config,
plugins: [
// Keep existing plugins but update expo-maps with API key from env
...baseConfig.expo.plugins.map((plugin) => {
// Handle expo-maps plugin
if (Array.isArray(plugin) && plugin[0] === 'expo-maps') {
return [
'expo-maps',
{
...plugin[1],
android: {
...plugin[1]?.android,
// Inject Google Maps API key from EAS secrets or env var
googleMapsApiKey: process.env.GOOGLE_MAPS_API_KEY || '',
},
},
];
}
return plugin;
}),
],
};
};

102
apps/mobile/app.json Normal file
View File

@@ -0,0 +1,102 @@
{
"expo": {
"name": "Tracearr",
"slug": "tracearr",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/icon.png",
"scheme": "tracearr",
"userInterfaceStyle": "automatic",
"newArchEnabled": true,
"splash": {
"image": "./assets/splash-icon-transparent.png",
"resizeMode": "contain",
"backgroundColor": "#051723"
},
"ios": {
"supportsTablet": true,
"bundleIdentifier": "com.tracearr.app",
"infoPlist": {
"UIBackgroundModes": [
"remote-notification",
"fetch"
],
"NSCameraUsageDescription": "Tracearr needs camera access to scan QR codes for server pairing.",
"NSLocationWhenInUseUsageDescription": "Location permission is not required for this app.",
"ITSAppUsesNonExemptEncryption": false,
"NSAppTransportSecurity": {
"NSAllowsArbitraryLoads": true,
"NSAllowsLocalNetworking": true
},
"NSLocalNetworkUsageDescription": "Tracearr needs local network access to connect to your self-hosted server."
}
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon-transparent.png",
"backgroundColor": "#051723"
},
"edgeToEdgeEnabled": true,
"package": "com.tracearr.app",
"permissions": [
"RECEIVE_BOOT_COMPLETED",
"VIBRATE",
"android.permission.CAMERA",
"android.permission.POST_NOTIFICATIONS"
]
},
"plugins": [
[
"./plugins/withGradleProperties",
{
"org.gradle.jvmargs": "-Xmx4g -XX:MaxMetaspaceSize=1g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8",
"org.gradle.daemon": "false"
}
],
"expo-router",
[
"expo-notifications",
{
"icon": "./assets/notification-icon.png",
"color": "#18D1E7",
"defaultChannel": "violations",
"enableBackgroundRemoteNotifications": true
}
],
[
"expo-camera",
{
"cameraPermission": "Allow Tracearr to access your camera for QR code scanning.",
"recordAudioAndroid": false
}
],
[
"expo-maps",
{
"requestLocationPermission": false
}
],
"expo-secure-store",
"react-native-quick-crypto"
],
"experiments": {
"typedRoutes": true
},
"extra": {
"router": {
"origin": false
},
"eas": {
"projectId": "2e0b9595-ac62-493f-9a10-4f8758bb4b2d"
},
"buildNumber": "1"
},
"owner": "gallopo-solutions",
"runtimeVersion": {
"policy": "fingerprint"
},
"updates": {
"url": "https://u.expo.dev/2e0b9595-ac62-493f-9a10-4f8758bb4b2d"
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 329 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 510 KiB

BIN
apps/mobile/assets/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 510 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 510 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

View File

@@ -0,0 +1,19 @@
module.exports = function (api) {
api.cache(true);
return {
presets: ['babel-preset-expo'],
plugins: [
[
'module-resolver',
{
root: ['.'],
alias: {
'@': './src',
},
extensions: ['.ios.js', '.android.js', '.js', '.ts', '.tsx', '.json'],
},
],
'react-native-reanimated/plugin',
],
};
};

76
apps/mobile/eas.json Normal file
View File

@@ -0,0 +1,76 @@
{
"cli": {
"version": ">= 12.0.0",
"appVersionSource": "remote"
},
"build": {
"development": {
"developmentClient": true,
"distribution": "internal",
"ios": {
"simulator": false
},
"android": {
"buildType": "apk"
}
},
"preview": {
"ios": {
"simulator": false,
"autoIncrement": true
},
"android": {
"buildType": "app-bundle"
},
"env": {
"EXPO_PUBLIC_API_URL": "https://tracearr.example.com"
},
"channel": "preview"
},
"preview-apk": {
"distribution": "internal",
"android": {
"buildType": "apk"
},
"channel": "preview"
},
"production": {
"ios": {
"resourceClass": "m-medium"
},
"android": {
"buildType": "app-bundle"
},
"env": {
"EXPO_PUBLIC_API_URL": "https://tracearr.example.com"
},
"channel": "production",
"autoIncrement": true
}
},
"submit": {
"preview": {
"ios": {
"appleId": "connor.gallopo@me.com",
"ascAppId": "6755941553",
"appleTeamId": "6DA3FJF5G5"
},
"android": {
"serviceAccountKeyPath": "./credentials/google-service-account.json",
"track": "internal",
"releaseStatus": "draft"
}
},
"production": {
"ios": {
"appleId": "connor.gallopo@me.com",
"ascAppId": "6755941553",
"appleTeamId": "6DA3FJF5G5"
},
"android": {
"serviceAccountKeyPath": "./credentials/google-service-account.json",
"track": "production"
}
}
}
}

62
apps/mobile/global.css Normal file
View File

@@ -0,0 +1,62 @@
@import "tailwindcss/theme.css" layer(theme);
@import "tailwindcss/preflight.css" layer(base);
@import "tailwindcss/utilities.css";
@import "nativewind/theme";
/* Tracearr brand colors - matching web dark mode exactly */
@theme {
/* Brand colors */
--color-cyan-core: #18D1E7;
--color-cyan-deep: #0EAFC8;
--color-cyan-dark: #0A7C96;
--color-blue-core: #0B1A2E;
--color-blue-steel: #162840;
--color-blue-soft: #1E3A5C;
/* Background colors - matching web dark mode exactly */
--color-background: #050A12;
--color-card: #0B1A2E;
--color-card-foreground: #FFFFFF;
--color-surface: #0F2338;
--color-popover: #162840;
--color-popover-foreground: #FFFFFF;
/* Text colors */
--color-foreground: #FFFFFF;
--color-muted: #162840;
--color-muted-foreground: #94A3B8;
/* Primary (cyan) */
--color-primary: #18D1E7;
--color-primary-foreground: #0B1A2E;
/* Secondary */
--color-secondary: #162840;
--color-secondary-foreground: #FFFFFF;
/* Accent (same as primary) */
--color-accent: #18D1E7;
--color-accent-foreground: #0B1A2E;
/* Form inputs */
--color-input: #162840;
--color-ring: #18D1E7;
--color-border: #162840;
/* Semantic colors */
--color-destructive: #EF4444;
--color-destructive-foreground: #FFFFFF;
--color-success: #22C55E;
--color-warning: #F59E0B;
--color-danger: #EF4444;
/* Icon colors */
--color-icon: #8CA3B8;
--color-icon-active: #18D1E7;
--color-icon-danger: #FF4C4C;
/* Border radius */
--radius-lg: 0.5rem;
--radius-md: calc(var(--radius-lg) - 2px);
--radius-sm: calc(var(--radius-lg) - 4px);
}

View File

@@ -0,0 +1,38 @@
const { getDefaultConfig } = require('expo/metro-config');
const { withNativewind } = require('nativewind/metro');
const path = require('path');
// Find the project and workspace directories
const projectRoot = __dirname;
const monorepoRoot = path.resolve(projectRoot, '../..');
const config = getDefaultConfig(projectRoot);
// 1. Watch all files within the monorepo (include default watchFolders)
config.watchFolders = [...(config.watchFolders || []), monorepoRoot];
// 2. Let Metro know where to resolve packages from
config.resolver.nodeModulesPaths = [
path.resolve(projectRoot, 'node_modules'),
path.resolve(monorepoRoot, 'node_modules'),
];
// 3. Enable symlink support for pnpm
config.resolver.unstable_enableSymlinks = true;
// 4. Handle .js imports that should resolve to .ts files (NodeNext compatibility)
// TypeScript with moduleResolution: NodeNext requires .js extensions in imports
// even for .ts source files. Metro needs help resolving these correctly.
config.resolver.resolveRequest = (context, moduleName, platform) => {
if (moduleName.startsWith('.') && moduleName.endsWith('.js')) {
const tsModuleName = moduleName.replace(/\.js$/, '.ts');
try {
return context.resolveRequest(context, tsModuleName, platform);
} catch {
// Fall through to default resolution if .ts doesn't exist
}
}
return context.resolveRequest(context, moduleName, platform);
};
module.exports = withNativewind(config, { input: './global.css' });

4
apps/mobile/nativewind-env.d.ts vendored Normal file
View File

@@ -0,0 +1,4 @@
/// <reference types="react-native-css/types" />
// NOTE: This file should not be edited and should be committed with your source code.
// It is generated by react-native-css. If you need to move or disable this file,
// please see the documentation.

86
apps/mobile/package.json Normal file
View File

@@ -0,0 +1,86 @@
{
"name": "@tracearr/mobile",
"version": "1.0.0",
"main": "expo-router/entry",
"scripts": {
"start": "expo start",
"android": "expo run:android",
"ios": "expo run:ios",
"web": "expo start --web",
"dev": "expo start",
"lint": "eslint . --ext .ts,.tsx --ignore-pattern '*.config.*' --ignore-pattern '.expo/'",
"typecheck": "tsc --noEmit",
"build:dev": "eas build --profile development --platform all",
"build:dev:ios": "eas build --profile development --platform ios",
"build:dev:android": "eas build --profile development --platform android",
"build:preview": "eas build --profile preview --platform all",
"build:preview:ios": "eas build --profile preview --platform ios",
"build:preview:android": "eas build --profile preview --platform android",
"build:prod": "eas build --profile production --platform all",
"build:prod:ios": "eas build --profile production --platform ios",
"build:prod:android": "eas build --profile production --platform android",
"submit:ios": "eas submit --platform ios",
"submit:android": "eas submit --platform android",
"update": "eas update"
},
"dependencies": {
"@expo-google-fonts/inter": "^0.4.2",
"@expo/metro-runtime": "~6.1.2",
"@expo/vector-icons": "^15.0.3",
"@react-navigation/native": "7.1.22",
"@shopify/react-native-skia": "^2.2.12",
"@tanstack/react-query": "5.60.6",
"@tracearr/shared": "workspace:*",
"axios": "^1.12.0",
"buffer": "^6.0.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"expo": "54.0.25",
"expo-camera": "17.0.9",
"expo-constants": "18.0.10",
"expo-dev-client": "~6.0.18",
"expo-device": "8.0.9",
"expo-font": "^14.0.9",
"expo-image": "3.0.10",
"expo-linking": "8.0.9",
"expo-maps": "^0.12.8",
"expo-notifications": "0.32.13",
"expo-router": "6.0.15",
"expo-secure-store": "15.0.7",
"expo-splash-screen": "31.0.11",
"expo-status-bar": "3.0.8",
"expo-system-ui": "6.0.8",
"expo-task-manager": "~14.0.8",
"expo-updates": "~29.0.13",
"expo-web-browser": "15.0.9",
"lucide-react-native": "^0.555.0",
"nativewind": "5.0.0-preview.2",
"react": "19.1.0",
"react-native": "0.81.5",
"react-native-css": "^3.0.1",
"react-native-gesture-handler": "2.28.0",
"react-native-quick-crypto": "^1.0.0",
"react-native-reanimated": "4.1.5",
"react-native-safe-area-context": "5.6.2",
"react-native-screens": "4.16.0",
"react-native-svg": "15.15.0",
"react-native-worklets": "0.5.1",
"socket.io-client": "4.8.1",
"tailwind-merge": "^2.6.0",
"tailwindcss": "^4.1.17",
"victory-native": "41.20.2",
"zustand": "5.0.2"
},
"devDependencies": {
"@babel/core": "7.26.0",
"@tailwindcss/postcss": "^4.1.17",
"@types/react": "19.1.17",
"babel-plugin-module-resolver": "5.0.2",
"babel-preset-expo": "^54.0.7",
"postcss": "^8.5.6",
"tailwindcss-animate": "^1.0.7",
"typescript": "5.9.2"
},
"private": true
}

View File

@@ -0,0 +1,26 @@
const { withGradleProperties } = require('expo/config-plugins');
/**
* Config plugin to customize Android gradle.properties
* Used to set JVM memory args for builds with many native dependencies
*/
module.exports = function withCustomGradleProperties(config, props) {
return withGradleProperties(config, (config) => {
for (const [key, value] of Object.entries(props)) {
const existingIndex = config.modResults.findIndex(
(p) => p.type === 'property' && p.key === key
);
if (existingIndex !== -1) {
config.modResults[existingIndex].value = value;
} else {
config.modResults.push({
type: 'property',
key,
value,
});
}
}
return config;
});
};

View File

@@ -0,0 +1,5 @@
export default {
plugins: {
"@tailwindcss/postcss": {},
},
};

View File

@@ -0,0 +1,163 @@
/**
* Error Boundary component for catching and displaying React errors
*/
import React, { Component, type ReactNode } from 'react';
import { View, Text, StyleSheet, TouchableOpacity, ScrollView } from 'react-native';
import { AlertTriangle, RefreshCw } from 'lucide-react-native';
import { colors } from '@/lib/theme';
interface Props {
children: ReactNode;
fallback?: ReactNode;
onError?: (error: Error, errorInfo: React.ErrorInfo) => void;
}
interface State {
hasError: boolean;
error: Error | null;
errorInfo: React.ErrorInfo | null;
}
export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
hasError: false,
error: null,
errorInfo: null,
};
}
static getDerivedStateFromError(error: Error): Partial<State> {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void {
this.setState({ errorInfo });
// Log error for debugging
console.error('ErrorBoundary caught an error:', error, errorInfo);
// Call optional error handler
this.props.onError?.(error, errorInfo);
}
handleReset = (): void => {
this.setState({
hasError: false,
error: null,
errorInfo: null,
});
};
render(): ReactNode {
if (this.state.hasError) {
// Custom fallback provided
if (this.props.fallback) {
return this.props.fallback;
}
// Default error UI
return (
<View style={styles.container}>
<View style={styles.content}>
<AlertTriangle size={48} color={colors.error} strokeWidth={2} />
<Text style={styles.title}>Something went wrong</Text>
<Text style={styles.message}>
An unexpected error occurred. Please try again.
</Text>
{__DEV__ && this.state.error && (
<ScrollView style={styles.errorContainer}>
<Text style={styles.errorTitle}>Error Details:</Text>
<Text style={styles.errorText}>{this.state.error.message}</Text>
{this.state.errorInfo?.componentStack && (
<>
<Text style={styles.errorTitle}>Component Stack:</Text>
<Text style={styles.stackText}>
{this.state.errorInfo.componentStack}
</Text>
</>
)}
</ScrollView>
)}
<TouchableOpacity style={styles.button} onPress={this.handleReset}>
<RefreshCw size={20} color={colors.text.primary.dark} />
<Text style={styles.buttonText}>Try Again</Text>
</TouchableOpacity>
</View>
</View>
);
}
return this.props.children;
}
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background.dark,
justifyContent: 'center',
alignItems: 'center',
padding: 24,
},
content: {
alignItems: 'center',
maxWidth: 320,
},
title: {
fontSize: 20,
fontWeight: '600',
color: colors.text.primary.dark,
marginTop: 16,
marginBottom: 8,
},
message: {
fontSize: 14,
color: colors.text.secondary.dark,
textAlign: 'center',
lineHeight: 20,
},
errorContainer: {
maxHeight: 200,
marginTop: 16,
padding: 12,
backgroundColor: colors.card.dark,
borderRadius: 8,
width: '100%',
},
errorTitle: {
fontSize: 12,
fontWeight: '600',
color: colors.error,
marginBottom: 4,
marginTop: 8,
},
errorText: {
fontSize: 12,
color: colors.text.secondary.dark,
fontFamily: 'monospace',
},
stackText: {
fontSize: 10,
color: colors.text.muted.dark,
fontFamily: 'monospace',
},
button: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
backgroundColor: colors.cyan.core,
paddingVertical: 12,
paddingHorizontal: 24,
borderRadius: 8,
marginTop: 24,
},
buttonText: {
fontSize: 16,
fontWeight: '600',
color: colors.text.primary.dark,
},
});

View File

@@ -0,0 +1,126 @@
/**
* Server selector component for header
* Tappable button that shows current server, opens modal to switch
*/
import { useState } from 'react';
import {
View,
Text,
TouchableOpacity,
Modal,
Pressable,
ActivityIndicator,
} from 'react-native';
import { Server, ChevronDown, Check } from 'lucide-react-native';
import { useMediaServer } from '../providers/MediaServerProvider';
import { colors } from '../lib/theme';
export function ServerSelector() {
const { servers, selectedServer, selectedServerId, selectServer, isLoading } = useMediaServer();
const [modalVisible, setModalVisible] = useState(false);
// Don't show if loading or no servers
if (isLoading) {
return (
<View className="flex-row items-center px-3">
<ActivityIndicator size="small" color={colors.text.muted.dark} />
</View>
);
}
// Don't show selector if only one server
if (servers.length <= 1) {
if (servers.length === 1) {
return (
<View className="flex-row items-center px-3">
<Server size={16} color={colors.text.primary.dark} />
<Text className="ml-2 text-sm text-white font-medium" numberOfLines={1}>
{servers[0]?.name}
</Text>
</View>
);
}
return null;
}
const handleSelect = (serverId: string) => {
selectServer(serverId);
setModalVisible(false);
};
return (
<>
<TouchableOpacity
onPress={() => setModalVisible(true)}
className="flex-row items-center px-3 py-2"
activeOpacity={0.7}
>
<Server size={16} color={colors.cyan.core} />
<Text className="ml-2 text-sm font-medium text-white" numberOfLines={1}>
{selectedServer?.name ?? 'Select Server'}
</Text>
<ChevronDown size={16} color={colors.text.muted.dark} className="ml-1" />
</TouchableOpacity>
<Modal
visible={modalVisible}
transparent
animationType="fade"
onRequestClose={() => setModalVisible(false)}
>
<Pressable
className="flex-1 justify-center items-center bg-black/60"
onPress={() => setModalVisible(false)}
>
<Pressable
className="w-4/5 max-w-sm bg-gray-900 rounded-xl overflow-hidden"
onPress={(e) => e.stopPropagation()}
>
<View className="px-4 py-3 border-b border-gray-800">
<Text className="text-lg font-semibold text-white">Select Server</Text>
</View>
<View className="py-2">
{servers.map((server) => (
<TouchableOpacity
key={server.id}
onPress={() => handleSelect(server.id)}
className="flex-row items-center justify-between px-4 py-3"
activeOpacity={0.7}
>
<View className="flex-row items-center flex-1">
<Server
size={20}
color={
server.id === selectedServerId
? colors.cyan.core
: colors.text.muted.dark
}
/>
<View className="ml-3 flex-1">
<Text
className={`text-base ${
server.id === selectedServerId
? 'text-cyan-400 font-medium'
: 'text-white'
}`}
numberOfLines={1}
>
{server.name}
</Text>
<Text className="text-xs text-gray-500 capitalize">
{server.type}
</Text>
</View>
</View>
{server.id === selectedServerId && (
<Check size={20} color={colors.cyan.core} />
)}
</TouchableOpacity>
))}
</View>
</Pressable>
</Pressable>
</Modal>
</>
);
}

View File

@@ -0,0 +1,161 @@
/* eslint-disable @typescript-eslint/no-deprecated */
/**
* Bar chart showing plays by day of week with touch interaction
*/
import React, { useState, useCallback } from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { CartesianChart, Bar, useChartPressState } from 'victory-native';
import { Circle } from '@shopify/react-native-skia';
import { useAnimatedReaction, runOnJS } from 'react-native-reanimated';
import type { SharedValue } from 'react-native-reanimated';
import { colors, spacing, borderRadius, typography } from '../../lib/theme';
import { useChartFont } from './useChartFont';
interface DayOfWeekChartProps {
data: { day: number; name: string; count: number }[];
height?: number;
}
const DAY_ABBREV = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
function ToolTip({ x, y }: { x: SharedValue<number>; y: SharedValue<number> }) {
return <Circle cx={x} cy={y} r={5} color={colors.cyan.core} />;
}
export function DayOfWeekChart({ data, height = 180 }: DayOfWeekChartProps) {
const font = useChartFont(10);
const { state, isActive } = useChartPressState({ x: 0, y: { count: 0 } });
// React state to display values (synced from SharedValues)
const [displayValue, setDisplayValue] = useState<{
day: number;
count: number;
} | null>(null);
// Sync SharedValue changes to React state
const updateDisplayValue = useCallback(
(day: number, count: number) => {
setDisplayValue({ day: Math.round(day), count: Math.round(count) });
},
[]
);
const clearDisplayValue = useCallback(() => {
setDisplayValue(null);
}, []);
// Watch for changes in chart press state
useAnimatedReaction(
() => ({
active: isActive,
x: state.x.value.value,
y: state.y.count.value.value,
}),
(current, previous) => {
if (current.active) {
runOnJS(updateDisplayValue)(current.x, current.y);
} else if (previous?.active && !current.active) {
runOnJS(clearDisplayValue)();
}
},
[isActive]
);
// Transform data for victory-native
const chartData = data.map((d) => ({
x: d.day,
count: d.count,
name: d.name,
}));
if (chartData.length === 0) {
return (
<View style={[styles.container, styles.emptyContainer, { height }]}>
<Text style={styles.emptyText}>No data available</Text>
</View>
);
}
// Find the selected day name from React state
const selectedDay = displayValue
? chartData.find((d) => d.x === displayValue.day)
: null;
return (
<View style={[styles.container, { height }]}>
{/* Active value display */}
<View style={styles.valueDisplay}>
{displayValue && selectedDay ? (
<>
<Text style={styles.valueText}>{displayValue.count} plays</Text>
<Text style={styles.dayText}>{selectedDay.name}</Text>
</>
) : null}
</View>
<CartesianChart
data={chartData}
xKey="x"
yKeys={['count']}
domainPadding={{ left: 25, right: 25, top: 20 }}
chartPressState={state}
axisOptions={{
font,
tickCount: { x: 7, y: 4 },
lineColor: colors.border.dark,
labelColor: colors.text.muted.dark,
formatXLabel: (value) => DAY_ABBREV[Math.round(value)] || '',
formatYLabel: (value) => String(Math.round(value)),
}}
>
{({ points, chartBounds }) => (
<>
<Bar
points={points.count}
chartBounds={chartBounds}
color={colors.cyan.core}
roundedCorners={{ topLeft: 4, topRight: 4 }}
animate={{ type: 'timing', duration: 500 }}
/>
{isActive && (
<ToolTip x={state.x.position} y={state.y.count.position} />
)}
</>
)}
</CartesianChart>
</View>
);
}
const styles = StyleSheet.create({
container: {
backgroundColor: colors.card.dark,
borderRadius: borderRadius.lg,
padding: spacing.sm,
},
emptyContainer: {
justifyContent: 'center',
alignItems: 'center',
},
emptyText: {
color: colors.text.muted.dark,
fontSize: typography.fontSize.sm,
},
valueDisplay: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: spacing.xs,
marginBottom: spacing.xs,
minHeight: 18,
},
valueText: {
color: colors.cyan.core,
fontSize: typography.fontSize.sm,
fontWeight: '600',
},
dayText: {
color: colors.text.muted.dark,
fontSize: typography.fontSize.xs,
},
});

View File

@@ -0,0 +1,163 @@
/* eslint-disable @typescript-eslint/no-deprecated */
/**
* Bar chart showing plays by hour of day with touch interaction
*/
import React, { useState, useCallback } from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { CartesianChart, Bar, useChartPressState } from 'victory-native';
import { Circle } from '@shopify/react-native-skia';
import { useAnimatedReaction, runOnJS } from 'react-native-reanimated';
import type { SharedValue } from 'react-native-reanimated';
import { colors, spacing, borderRadius, typography } from '../../lib/theme';
import { useChartFont } from './useChartFont';
interface HourOfDayChartProps {
data: { hour: number; count: number }[];
height?: number;
}
function ToolTip({ x, y }: { x: SharedValue<number>; y: SharedValue<number> }) {
return <Circle cx={x} cy={y} r={5} color={colors.purple} />;
}
function formatHour(hour: number): string {
if (hour === 0) return '12am';
if (hour === 12) return '12pm';
return hour < 12 ? `${hour}am` : `${hour - 12}pm`;
}
export function HourOfDayChart({ data, height = 180 }: HourOfDayChartProps) {
const font = useChartFont(9);
const { state, isActive } = useChartPressState({ x: 0, y: { count: 0 } });
// React state to display values (synced from SharedValues)
const [displayValue, setDisplayValue] = useState<{
hour: number;
count: number;
} | null>(null);
// Sync SharedValue changes to React state
const updateDisplayValue = useCallback((hour: number, count: number) => {
setDisplayValue({ hour: Math.round(hour), count: Math.round(count) });
}, []);
const clearDisplayValue = useCallback(() => {
setDisplayValue(null);
}, []);
// Watch for changes in chart press state
useAnimatedReaction(
() => ({
active: isActive,
x: state.x.value.value,
y: state.y.count.value.value,
}),
(current, previous) => {
if (current.active) {
runOnJS(updateDisplayValue)(current.x, current.y);
} else if (previous?.active && !current.active) {
runOnJS(clearDisplayValue)();
}
},
[isActive]
);
// Transform data for victory-native
const chartData = data.map((d) => ({
x: d.hour,
count: d.count,
}));
if (chartData.length === 0) {
return (
<View style={[styles.container, styles.emptyContainer, { height }]}>
<Text style={styles.emptyText}>No data available</Text>
</View>
);
}
return (
<View style={[styles.container, { height }]}>
{/* Active value display */}
<View style={styles.valueDisplay}>
{displayValue ? (
<>
<Text style={styles.valueText}>{displayValue.count} plays</Text>
<Text style={styles.hourText}>{formatHour(displayValue.hour)}</Text>
</>
) : null}
</View>
<CartesianChart
data={chartData}
xKey="x"
yKeys={['count']}
domainPadding={{ left: 10, right: 10, top: 20 }}
chartPressState={state}
axisOptions={{
font,
tickCount: { x: 6, y: 4 },
lineColor: colors.border.dark,
labelColor: colors.text.muted.dark,
formatXLabel: (value) => {
const hour = Math.round(value);
// Only show labels for 0, 6, 12, 18 to avoid crowding
if (hour % 6 === 0) {
return formatHour(hour);
}
return '';
},
formatYLabel: (value) => String(Math.round(value)),
}}
>
{({ points, chartBounds }) => (
<>
<Bar
points={points.count}
chartBounds={chartBounds}
color={colors.purple}
roundedCorners={{ topLeft: 2, topRight: 2 }}
animate={{ type: 'timing', duration: 500 }}
/>
{isActive && (
<ToolTip x={state.x.position} y={state.y.count.position} />
)}
</>
)}
</CartesianChart>
</View>
);
}
const styles = StyleSheet.create({
container: {
backgroundColor: colors.card.dark,
borderRadius: borderRadius.lg,
padding: spacing.sm,
},
emptyContainer: {
justifyContent: 'center',
alignItems: 'center',
},
emptyText: {
color: colors.text.muted.dark,
fontSize: typography.fontSize.sm,
},
valueDisplay: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: spacing.xs,
marginBottom: spacing.xs,
minHeight: 18,
},
valueText: {
color: colors.purple,
fontSize: typography.fontSize.sm,
fontWeight: '600',
},
hourText: {
color: colors.text.muted.dark,
fontSize: typography.fontSize.xs,
},
});

View File

@@ -0,0 +1,130 @@
/**
* Donut chart showing plays by platform (matches web implementation)
* Note: Touch interactions not yet supported on PolarChart (victory-native issue #252)
*/
import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { Pie, PolarChart } from 'victory-native';
import { colors, spacing, borderRadius, typography } from '../../lib/theme';
interface PlatformChartProps {
data: { platform: string; count: number }[];
height?: number;
}
// Colors for pie slices - all visible against dark card background
const CHART_COLORS = [
colors.cyan.core, // #18D1E7 - Cyan
colors.info, // #3B82F6 - Bright Blue (not blue.core which matches bg!)
colors.success, // #22C55E - Green
colors.warning, // #F59E0B - Orange/Yellow
colors.purple, // #8B5CF6 - Purple
colors.error, // #EF4444 - Red
];
export function PlatformChart({ data }: PlatformChartProps) {
// Sort by count and take top 5
const sortedData = [...data]
.sort((a, b) => b.count - a.count)
.slice(0, 5)
.map((d, index) => ({
label: d.platform.replace('Plex for ', '').replace('Jellyfin ', ''),
value: d.count,
color: CHART_COLORS[index % CHART_COLORS.length],
}));
if (sortedData.length === 0) {
return (
<View style={[styles.container, styles.emptyContainer]}>
<Text style={styles.emptyText}>No platform data available</Text>
</View>
);
}
const total = sortedData.reduce((sum, item) => sum + item.value, 0);
return (
<View style={styles.container}>
{/* Pie Chart */}
<View style={styles.chartContainer}>
<PolarChart
data={sortedData}
labelKey="label"
valueKey="value"
colorKey="color"
>
<Pie.Chart
innerRadius="50%"
circleSweepDegrees={360}
startAngle={0}
/>
</PolarChart>
</View>
{/* Legend with percentages */}
<View style={styles.legend}>
{sortedData.map((item) => (
<View key={item.label} style={styles.legendItem}>
<View style={[styles.legendDot, { backgroundColor: item.color }]} />
<Text style={styles.legendText} numberOfLines={1}>
{item.label}
</Text>
<Text style={styles.legendPercent}>
{Math.round((item.value / total) * 100)}%
</Text>
</View>
))}
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
backgroundColor: colors.card.dark,
borderRadius: borderRadius.lg,
padding: spacing.sm,
},
emptyContainer: {
justifyContent: 'center',
alignItems: 'center',
minHeight: 150,
},
emptyText: {
color: colors.text.muted.dark,
fontSize: typography.fontSize.sm,
},
chartContainer: {
height: 160,
},
legend: {
flexDirection: 'row',
flexWrap: 'wrap',
justifyContent: 'center',
gap: spacing.md,
marginTop: spacing.sm,
paddingTop: spacing.sm,
borderTopWidth: 1,
borderTopColor: colors.border.dark,
},
legendItem: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
},
legendDot: {
width: 8,
height: 8,
borderRadius: 4,
},
legendText: {
fontSize: typography.fontSize.xs,
color: colors.text.muted.dark,
maxWidth: 60,
},
legendPercent: {
fontSize: typography.fontSize.xs,
color: colors.text.secondary.dark,
fontWeight: '500',
},
});

View File

@@ -0,0 +1,164 @@
/* eslint-disable @typescript-eslint/no-deprecated */
/**
* Area chart showing plays over time with touch-to-reveal tooltip
*/
import React, { useState, useCallback } from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { CartesianChart, Area, useChartPressState } from 'victory-native';
import { Circle } from '@shopify/react-native-skia';
import { useAnimatedReaction, runOnJS } from 'react-native-reanimated';
import type { SharedValue } from 'react-native-reanimated';
import { colors, spacing, borderRadius, typography } from '../../lib/theme';
import { useChartFont } from './useChartFont';
interface PlaysChartProps {
data: { date: string; count: number }[];
height?: number;
}
function ToolTip({ x, y }: { x: SharedValue<number>; y: SharedValue<number> }) {
return <Circle cx={x} cy={y} r={6} color={colors.cyan.core} />;
}
export function PlaysChart({ data, height = 200 }: PlaysChartProps) {
const font = useChartFont(10);
const { state, isActive } = useChartPressState({ x: 0, y: { count: 0 } });
// React state to display values (synced from SharedValues)
const [displayValue, setDisplayValue] = useState<{
index: number;
count: number;
} | null>(null);
// Transform data for victory-native
const chartData = data.map((d, index) => ({
x: index,
count: d.count,
label: d.date,
}));
// Sync SharedValue changes to React state
const updateDisplayValue = useCallback((index: number, count: number) => {
setDisplayValue({ index: Math.round(index), count: Math.round(count) });
}, []);
const clearDisplayValue = useCallback(() => {
setDisplayValue(null);
}, []);
// Watch for changes in chart press state
useAnimatedReaction(
() => ({
active: isActive,
x: state.x.value.value,
y: state.y.count.value.value,
}),
(current, previous) => {
if (current.active) {
runOnJS(updateDisplayValue)(current.x, current.y);
} else if (previous?.active && !current.active) {
runOnJS(clearDisplayValue)();
}
},
[isActive]
);
if (chartData.length === 0) {
return (
<View style={[styles.container, styles.emptyContainer, { height }]}>
<Text style={styles.emptyText}>No play data available</Text>
</View>
);
}
// Get date label from React state
const dateLabel = displayValue && chartData[displayValue.index]?.label
? new Date(chartData[displayValue.index].label).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
})
: '';
return (
<View style={[styles.container, { height }]}>
{/* Active value display */}
<View style={styles.valueDisplay}>
{displayValue ? (
<>
<Text style={styles.valueText}>{displayValue.count} plays</Text>
<Text style={styles.dateText}>{dateLabel}</Text>
</>
) : null}
</View>
<CartesianChart
data={chartData}
xKey="x"
yKeys={['count']}
domainPadding={{ top: 20, bottom: 10, left: 5, right: 5 }}
chartPressState={state}
axisOptions={{
font,
tickCount: { x: 5, y: 4 },
lineColor: colors.border.dark,
labelColor: colors.text.muted.dark,
formatXLabel: (value) => {
const item = chartData[Math.round(value)];
if (!item) return '';
const date = new Date(item.label);
return `${date.getMonth() + 1}/${date.getDate()}`;
},
formatYLabel: (value) => String(Math.round(value)),
}}
>
{({ points, chartBounds }) => (
<>
<Area
points={points.count}
y0={chartBounds.bottom}
color={colors.cyan.core}
opacity={0.6}
animate={{ type: 'timing', duration: 500 }}
/>
{isActive && (
<ToolTip x={state.x.position} y={state.y.count.position} />
)}
</>
)}
</CartesianChart>
</View>
);
}
const styles = StyleSheet.create({
container: {
backgroundColor: colors.card.dark,
borderRadius: borderRadius.lg,
padding: spacing.sm,
},
emptyContainer: {
justifyContent: 'center',
alignItems: 'center',
},
emptyText: {
color: colors.text.muted.dark,
fontSize: typography.fontSize.sm,
},
valueDisplay: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: spacing.xs,
marginBottom: spacing.xs,
minHeight: 20,
},
valueText: {
color: colors.cyan.core,
fontSize: typography.fontSize.sm,
fontWeight: '600',
},
dateText: {
color: colors.text.muted.dark,
fontSize: typography.fontSize.xs,
},
});

View File

@@ -0,0 +1,107 @@
/**
* Simple chart showing direct play vs transcode breakdown
*/
import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { colors, spacing, borderRadius, typography } from '../../lib/theme';
interface QualityChartProps {
directPlay: number;
transcode: number;
directPlayPercent: number;
transcodePercent: number;
height?: number;
}
export function QualityChart({
directPlay,
transcode,
directPlayPercent,
transcodePercent,
height = 120,
}: QualityChartProps) {
const total = directPlay + transcode;
if (total === 0) {
return (
<View style={[styles.container, styles.emptyContainer, { height }]}>
<Text style={styles.emptyText}>No playback data available</Text>
</View>
);
}
return (
<View style={[styles.container, { height }]}>
{/* Progress bar */}
<View style={styles.barContainer}>
<View style={[styles.directBar, { flex: directPlayPercent || 1 }]} />
<View style={[styles.transcodeBar, { flex: transcodePercent || 1 }]} />
</View>
{/* Legend */}
<View style={styles.legend}>
<View style={styles.legendItem}>
<View style={[styles.legendDot, { backgroundColor: colors.success }]} />
<Text style={styles.legendLabel}>Direct Play</Text>
<Text style={styles.legendValue}>{directPlay} ({directPlayPercent}%)</Text>
</View>
<View style={styles.legendItem}>
<View style={[styles.legendDot, { backgroundColor: colors.warning }]} />
<Text style={styles.legendLabel}>Transcode</Text>
<Text style={styles.legendValue}>{transcode} ({transcodePercent}%)</Text>
</View>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
backgroundColor: colors.card.dark,
borderRadius: borderRadius.lg,
padding: spacing.md,
justifyContent: 'center',
},
emptyContainer: {
alignItems: 'center',
},
emptyText: {
color: colors.text.muted.dark,
fontSize: typography.fontSize.sm,
},
barContainer: {
flexDirection: 'row',
height: 24,
borderRadius: borderRadius.md,
overflow: 'hidden',
marginBottom: spacing.md,
},
directBar: {
backgroundColor: colors.success,
},
transcodeBar: {
backgroundColor: colors.warning,
},
legend: {
gap: spacing.sm,
},
legendItem: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
},
legendDot: {
width: 10,
height: 10,
borderRadius: 5,
},
legendLabel: {
flex: 1,
color: colors.text.primary.dark,
fontSize: typography.fontSize.sm,
},
legendValue: {
color: colors.text.muted.dark,
fontSize: typography.fontSize.sm,
},
});

View File

@@ -0,0 +1,6 @@
export { PlaysChart } from './PlaysChart';
export { PlatformChart } from './PlatformChart';
export { DayOfWeekChart } from './DayOfWeekChart';
export { HourOfDayChart } from './HourOfDayChart';
export { QualityChart } from './QualityChart';
export { useChartFont } from './useChartFont';

View File

@@ -0,0 +1,13 @@
/**
* Hook to load fonts for chart axis labels
* Uses @shopify/react-native-skia's useFont with Inter font
*/
import { useFont } from '@shopify/react-native-skia';
// eslint-disable-next-line @typescript-eslint/no-require-imports
const InterMedium = require('../../../assets/fonts/Inter_500Medium.ttf') as number;
export function useChartFont(size: number = 10) {
const font = useFont(InterMedium, size);
return font;
}

View File

@@ -0,0 +1,207 @@
/**
* Interactive map showing active stream locations
* Uses expo-maps with Apple Maps on iOS, Google Maps on Android
*
* Note: expo-maps doesn't support custom tile providers, so we can't
* match the web's dark theme exactly. Using default map styles.
*/
import React, { Component, type ReactNode } from 'react';
import { View, Text, StyleSheet, Platform } from 'react-native';
import { AppleMaps, GoogleMaps } from 'expo-maps';
import { Ionicons } from '@expo/vector-icons';
import type { ActiveSession } from '@tracearr/shared';
import { colors, borderRadius, typography } from '../../lib/theme';
/**
* Error boundary to catch map crashes (e.g., missing Google Maps API key on Android)
* This prevents the entire app from crashing if the map fails to render
*/
class MapErrorBoundary extends Component<
{ children: ReactNode; height: number },
{ hasError: boolean; error: Error | null }
> {
constructor(props: { children: ReactNode; height: number }) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error) {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error('StreamMap crashed:', error.message);
console.error('Component stack:', errorInfo.componentStack);
}
render() {
if (this.state.hasError) {
return (
<View style={[styles.container, styles.errorContainer, { height: this.props.height }]}>
<Ionicons name="map-outline" size={32} color={colors.text.muted.dark} />
<Text style={styles.errorText}>Map unavailable</Text>
{__DEV__ && this.state.error && (
<Text style={styles.errorDetail}>{this.state.error.message}</Text>
)}
</View>
);
}
return this.props.children;
}
}
interface StreamMapProps {
sessions: ActiveSession[];
height?: number;
}
/** Session with guaranteed geo coordinates */
type SessionWithLocation = ActiveSession & {
geoLat: number;
geoLon: number;
};
/** Type guard to filter sessions with valid coordinates */
function hasLocation(session: ActiveSession): session is SessionWithLocation {
return session.geoLat != null && session.geoLon != null;
}
export function StreamMap({ sessions, height = 300 }: StreamMapProps) {
// Filter sessions with valid geo coordinates (type guard narrows to SessionWithLocation[])
const sessionsWithLocation = sessions.filter(hasLocation);
if (sessionsWithLocation.length === 0) {
return (
<View style={[styles.container, styles.emptyContainer, { height }]}>
<Text style={styles.emptyText}>No location data available</Text>
</View>
);
}
// Calculate center point from all sessions
const avgLat = sessionsWithLocation.reduce((sum, s) => sum + s.geoLat, 0) / sessionsWithLocation.length;
const avgLon = sessionsWithLocation.reduce((sum, s) => sum + s.geoLon, 0) / sessionsWithLocation.length;
// Create markers for each session with enhanced info
const markers = sessionsWithLocation.map((session) => {
const username = session.user?.username || 'Unknown';
const location = [session.geoCity, session.geoCountry].filter(Boolean).join(', ') || 'Unknown location';
const mediaTitle = session.mediaTitle || 'Unknown';
// Truncate long media titles for snippet
const truncatedTitle = mediaTitle.length > 40
? mediaTitle.substring(0, 37) + '...'
: mediaTitle;
return {
id: session.sessionKey || session.id,
coordinates: {
latitude: session.geoLat,
longitude: session.geoLon,
},
// Title shows username prominently
title: username,
// Snippet shows media and location
snippet: `${truncatedTitle}\n${location}`,
// Use cyan tint to match app theme
tintColor: colors.cyan.core,
// iOS: Use SF Symbol for streaming indicator
...(Platform.OS === 'ios' && {
systemImage: 'play.circle.fill',
}),
};
});
// Calculate appropriate zoom based on marker spread
const calculateZoom = () => {
if (sessionsWithLocation.length === 1) return 10;
// Calculate spread of coordinates
const lats = sessionsWithLocation.map(s => s.geoLat);
const lons = sessionsWithLocation.map(s => s.geoLon);
const latSpread = Math.max(...lats) - Math.min(...lats);
const lonSpread = Math.max(...lons) - Math.min(...lons);
const maxSpread = Math.max(latSpread, lonSpread);
// Adjust zoom based on spread
if (maxSpread > 100) return 2;
if (maxSpread > 50) return 3;
if (maxSpread > 20) return 4;
if (maxSpread > 10) return 5;
if (maxSpread > 5) return 6;
if (maxSpread > 1) return 8;
return 10;
};
const cameraPosition = {
coordinates: {
latitude: avgLat || 39.8283,
longitude: avgLon || -98.5795,
},
zoom: calculateZoom(),
};
// Use platform-specific map component
const MapComponent = Platform.OS === 'ios' ? AppleMaps.View : GoogleMaps.View;
return (
<MapErrorBoundary height={height}>
<View style={[styles.container, { height }]}>
<MapComponent
style={styles.map}
cameraPosition={cameraPosition}
markers={markers.map((m) => ({
id: m.id,
coordinates: m.coordinates,
title: m.title,
snippet: m.snippet,
tintColor: m.tintColor,
...(Platform.OS === 'ios' && m.systemImage && { systemImage: m.systemImage }),
}))}
uiSettings={{
compassEnabled: false,
scaleBarEnabled: false,
rotationGesturesEnabled: false,
tiltGesturesEnabled: false,
}}
/>
</View>
</MapErrorBoundary>
);
}
const styles = StyleSheet.create({
container: {
borderRadius: borderRadius.lg,
overflow: 'hidden',
backgroundColor: colors.card.dark,
},
map: {
flex: 1,
},
emptyContainer: {
justifyContent: 'center',
alignItems: 'center',
},
emptyText: {
color: colors.text.muted.dark,
fontSize: typography.fontSize.sm,
},
errorContainer: {
justifyContent: 'center',
alignItems: 'center',
gap: 8,
},
errorText: {
color: colors.text.muted.dark,
fontSize: typography.fontSize.sm,
fontWeight: '500',
},
errorDetail: {
color: colors.error,
fontSize: typography.fontSize.xs,
textAlign: 'center',
paddingHorizontal: 16,
marginTop: 4,
},
});

View File

@@ -0,0 +1,246 @@
/**
* Server resource monitoring card (CPU + RAM)
* Displays real-time server resource utilization with progress bars
* Note: Section header is rendered by parent - this is just the card content
*/
import { View, Animated, StyleSheet } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useEffect, useRef } from 'react';
import { Text } from '@/components/ui/text';
import { colors, spacing, borderRadius, typography } from '@/lib/theme';
// Bar colors matching web app
const BAR_COLORS = {
process: '#00b4e4', // Plex-style cyan for "Plex Media Server"
system: '#cc7b9f', // Pink/purple for "System"
};
interface ResourceBarProps {
label: string;
processValue: number;
systemValue: number;
icon: keyof typeof Ionicons.glyphMap;
}
function ResourceBar({ label, processValue, systemValue, icon }: ResourceBarProps) {
const processWidth = useRef(new Animated.Value(0)).current;
const systemWidth = useRef(new Animated.Value(0)).current;
useEffect(() => {
Animated.parallel([
Animated.timing(processWidth, {
toValue: processValue,
duration: 300,
useNativeDriver: false,
}),
Animated.timing(systemWidth, {
toValue: systemValue,
duration: 300,
useNativeDriver: false,
}),
]).start();
}, [processValue, systemValue, processWidth, systemWidth]);
return (
<View style={styles.resourceBar}>
{/* Header row */}
<View style={styles.resourceHeader}>
<Ionicons name={icon} size={14} color={colors.text.secondary.dark} />
<Text style={styles.resourceLabel}>{label}</Text>
</View>
{/* Process bar (Plex Media Server) */}
<View style={styles.barSection}>
<View style={styles.barLabelRow}>
<Text style={styles.barLabelText}>Plex Media Server</Text>
<Text style={styles.barValueText}>{processValue}%</Text>
</View>
<View style={styles.barTrack}>
<Animated.View
style={[
styles.barFill,
{
backgroundColor: BAR_COLORS.process,
width: processWidth.interpolate({
inputRange: [0, 100],
outputRange: ['0%', '100%'],
}),
},
]}
/>
</View>
</View>
{/* System bar */}
<View style={styles.barSection}>
<View style={styles.barLabelRow}>
<Text style={styles.barLabelText}>System</Text>
<Text style={styles.barValueText}>{systemValue}%</Text>
</View>
<View style={styles.barTrack}>
<Animated.View
style={[
styles.barFill,
{
backgroundColor: BAR_COLORS.system,
width: systemWidth.interpolate({
inputRange: [0, 100],
outputRange: ['0%', '100%'],
}),
},
]}
/>
</View>
</View>
</View>
);
}
interface ServerResourceCardProps {
latest: {
hostCpu: number;
processCpu: number;
hostMemory: number;
processMemory: number;
} | null;
isLoading?: boolean;
error?: Error | null;
}
export function ServerResourceCard({ latest, isLoading, error }: ServerResourceCardProps) {
if (isLoading) {
return (
<View style={styles.container}>
<View style={styles.loadingContainer}>
<Text style={styles.loadingText}>Loading...</Text>
</View>
</View>
);
}
if (error) {
return (
<View style={styles.container}>
<View style={styles.emptyContainer}>
<View style={[styles.emptyIconContainer, { backgroundColor: 'rgba(239, 68, 68, 0.1)' }]}>
<Ionicons name="alert-circle-outline" size={24} color="#ef4444" />
</View>
<Text style={styles.emptyText}>Failed to load</Text>
<Text style={styles.emptySubtext}>{error.message}</Text>
</View>
</View>
);
}
if (!latest) {
return (
<View style={styles.container}>
<View style={styles.emptyContainer}>
<View style={styles.emptyIconContainer}>
<Ionicons name="server-outline" size={24} color={colors.text.muted.dark} />
</View>
<Text style={styles.emptyText}>No resource data</Text>
<Text style={styles.emptySubtext}>Waiting for server statistics...</Text>
</View>
</View>
);
}
return (
<View style={styles.container}>
<ResourceBar
label="CPU"
icon="speedometer-outline"
processValue={latest.processCpu}
systemValue={latest.hostCpu}
/>
<ResourceBar
label="RAM"
icon="hardware-chip-outline"
processValue={latest.processMemory}
systemValue={latest.hostMemory}
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
backgroundColor: colors.card.dark,
borderRadius: borderRadius.lg,
padding: spacing.sm,
},
loadingContainer: {
height: 80,
justifyContent: 'center',
alignItems: 'center',
},
loadingText: {
fontSize: typography.fontSize.xs,
color: colors.text.muted.dark,
},
emptyContainer: {
paddingVertical: spacing.lg,
justifyContent: 'center',
alignItems: 'center',
},
emptyIconContainer: {
backgroundColor: colors.surface.dark,
padding: spacing.sm,
borderRadius: borderRadius.full,
marginBottom: spacing.sm,
},
emptyText: {
fontSize: typography.fontSize.sm,
fontWeight: '600',
color: colors.text.primary.dark,
},
emptySubtext: {
fontSize: typography.fontSize.xs,
color: colors.text.muted.dark,
marginTop: 2,
},
resourceBar: {
marginBottom: spacing.sm,
},
resourceHeader: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: spacing.xs,
},
resourceLabel: {
marginLeft: spacing.xs,
fontSize: typography.fontSize.xs,
fontWeight: '600',
color: colors.text.primary.dark,
},
barSection: {
marginBottom: spacing.xs,
},
barLabelRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 3,
},
barLabelText: {
fontSize: 10,
color: colors.text.muted.dark,
},
barValueText: {
fontSize: 10,
fontWeight: '600',
color: colors.text.primary.dark,
},
barTrack: {
height: 4,
backgroundColor: colors.surface.dark,
borderRadius: 2,
overflow: 'hidden',
},
barFill: {
height: '100%',
borderRadius: 2,
},
});

View File

@@ -0,0 +1,256 @@
/**
* Compact card showing an active streaming session
* Displays poster, title, user, progress bar, and play/pause status
*/
import React from 'react';
import { View, Image, Pressable, StyleSheet } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { Text } from '@/components/ui/text';
import { UserAvatar } from '@/components/ui/user-avatar';
import { useAuthStore } from '@/lib/authStore';
import { useEstimatedProgress } from '@/hooks/useEstimatedProgress';
import { colors, spacing, borderRadius, typography } from '@/lib/theme';
import type { ActiveSession } from '@tracearr/shared';
interface NowPlayingCardProps {
session: ActiveSession;
onPress?: (session: ActiveSession) => void;
}
/**
* Format duration in ms to readable string (HH:MM:SS or MM:SS)
*/
function formatDuration(ms: number | null): string {
if (!ms) 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}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}
return `${minutes}:${secs.toString().padStart(2, '0')}`;
}
/**
* Get display title for media (handles TV shows vs movies)
*/
function getMediaDisplay(session: ActiveSession): { title: string; subtitle: string | null } {
if (session.mediaType === 'episode' && session.grandparentTitle) {
// TV Show episode
const episodeInfo =
session.seasonNumber && session.episodeNumber
? `S${session.seasonNumber.toString().padStart(2, '0')}E${session.episodeNumber.toString().padStart(2, '0')}`
: '';
return {
title: session.grandparentTitle,
subtitle: episodeInfo ? `${episodeInfo} · ${session.mediaTitle}` : session.mediaTitle,
};
}
// Movie or music
return {
title: session.mediaTitle,
subtitle: session.year ? `${session.year}` : null,
};
}
export function NowPlayingCard({ session, onPress }: NowPlayingCardProps) {
const { serverUrl } = useAuthStore();
const { title, subtitle } = getMediaDisplay(session);
// Use estimated progress for smooth updates between SSE/poll events
const { estimatedProgressMs, progressPercent } = useEstimatedProgress(session);
// Build poster URL using image proxy
const posterUrl =
serverUrl && session.thumbPath
? `${serverUrl}/api/v1/images/proxy?server=${session.serverId}&url=${encodeURIComponent(session.thumbPath)}&width=80&height=120`
: null;
const isPaused = session.state === 'paused';
const username = session.user?.username || 'Unknown';
const userThumbUrl = session.user?.thumbUrl || null;
return (
<Pressable
style={({ pressed }) => [styles.container, pressed && styles.pressed]}
onPress={() => onPress?.(session)}
>
{/* Main content row */}
<View style={styles.contentRow}>
{/* Poster */}
<View style={styles.posterContainer}>
{posterUrl ? (
<Image source={{ uri: posterUrl }} style={styles.poster} resizeMode="cover" />
) : (
<View style={[styles.poster, styles.posterPlaceholder]}>
<Ionicons name="film-outline" size={24} color={colors.text.muted.dark} />
</View>
)}
{/* Paused overlay */}
{isPaused && (
<View style={styles.pausedOverlay}>
<Ionicons name="pause" size={20} color={colors.text.primary.dark} />
</View>
)}
</View>
{/* Info section */}
<View style={styles.info}>
{/* Title + subtitle */}
<Text style={styles.title} numberOfLines={1}>
{title}
</Text>
{subtitle && (
<Text style={styles.subtitle} numberOfLines={1}>
{subtitle}
</Text>
)}
{/* User + time row combined */}
<View style={styles.userTimeRow}>
<View style={styles.userSection}>
<UserAvatar thumbUrl={userThumbUrl} username={username} size={16} />
<Text style={styles.username} numberOfLines={1}>
{username}
</Text>
{session.isTranscode && (
<Ionicons name="flash" size={10} color={colors.warning} />
)}
</View>
<View style={styles.timeSection}>
<View style={[styles.statusDot, isPaused && styles.statusDotPaused]}>
<Ionicons
name={isPaused ? 'pause' : 'play'}
size={6}
color={isPaused ? colors.warning : colors.cyan.core}
/>
</View>
<Text style={[styles.timeText, isPaused && styles.pausedText]}>
{isPaused
? 'Paused'
: `${formatDuration(estimatedProgressMs)} / ${formatDuration(session.totalDurationMs)}`}
</Text>
</View>
</View>
</View>
{/* Chevron */}
<View style={styles.chevron}>
<Ionicons name="chevron-forward" size={16} color={colors.text.muted.dark} />
</View>
</View>
{/* Bottom progress bar - full width */}
<View style={styles.progressBar}>
<View style={[styles.progressFill, { width: `${progressPercent}%` }]} />
</View>
</Pressable>
);
}
const styles = StyleSheet.create({
container: {
backgroundColor: colors.card.dark,
borderRadius: borderRadius.lg,
marginBottom: spacing.sm,
overflow: 'hidden',
},
pressed: {
opacity: 0.7,
},
contentRow: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs,
},
posterContainer: {
position: 'relative',
marginRight: spacing.sm,
},
poster: {
width: 50,
height: 75,
borderRadius: borderRadius.md,
backgroundColor: colors.surface.dark,
},
posterPlaceholder: {
justifyContent: 'center',
alignItems: 'center',
},
pausedOverlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0, 0, 0, 0.6)',
borderRadius: borderRadius.md,
justifyContent: 'center',
alignItems: 'center',
},
info: {
flex: 1,
justifyContent: 'center',
gap: 2,
},
title: {
fontSize: typography.fontSize.sm,
fontWeight: '600',
color: colors.text.primary.dark,
lineHeight: 16,
},
subtitle: {
fontSize: typography.fontSize.xs,
color: colors.text.muted.dark,
},
userTimeRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginTop: 2,
},
userSection: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
flex: 1,
},
username: {
fontSize: typography.fontSize.xs,
color: colors.text.secondary.dark,
},
timeSection: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
},
statusDot: {
width: 12,
height: 12,
borderRadius: 6,
backgroundColor: 'rgba(24, 209, 231, 0.15)',
justifyContent: 'center',
alignItems: 'center',
},
statusDotPaused: {
backgroundColor: 'rgba(245, 158, 11, 0.15)',
},
timeText: {
fontSize: typography.fontSize.xs,
color: colors.text.muted.dark,
},
pausedText: {
color: colors.warning,
},
progressBar: {
height: 3,
backgroundColor: colors.surface.dark,
},
progressFill: {
height: '100%',
backgroundColor: colors.cyan.core,
},
chevron: {
marginLeft: 4,
opacity: 0.5,
},
});

View File

@@ -0,0 +1 @@
export { NowPlayingCard } from './NowPlayingCard';

View File

@@ -0,0 +1,55 @@
import * as React from 'react';
import { View, type ViewProps } from 'react-native';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
import { Text } from './text';
const badgeVariants = cva('flex-row items-center rounded-full px-2.5 py-0.5', {
variants: {
variant: {
default: 'bg-primary',
secondary: 'bg-secondary',
destructive: 'bg-destructive',
outline: 'border border-border bg-transparent',
success: 'bg-success/15',
warning: 'bg-warning/15',
danger: 'bg-danger/15',
},
},
defaultVariants: {
variant: 'default',
},
});
const badgeTextVariants = cva('text-xs font-semibold', {
variants: {
variant: {
default: 'text-primary-foreground',
secondary: 'text-secondary-foreground',
destructive: 'text-destructive-foreground',
outline: 'text-foreground',
success: 'text-success',
warning: 'text-warning',
danger: 'text-danger',
},
},
defaultVariants: {
variant: 'default',
},
});
interface BadgeProps extends ViewProps, VariantProps<typeof badgeVariants> {
children: React.ReactNode;
}
const Badge: React.FC<BadgeProps> = ({ className, variant, children, ...props }) => (
<View className={cn(badgeVariants({ variant, className }))} {...props}>
{typeof children === 'string' ? (
<Text className={badgeTextVariants({ variant })}>{children}</Text>
) : (
children
)}
</View>
);
export { Badge, badgeVariants, badgeTextVariants };

View File

@@ -0,0 +1,70 @@
import * as React from 'react';
import { Pressable, type PressableProps } from 'react-native';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
import { Text } from './text';
const buttonVariants = cva('flex-row items-center justify-center rounded-md', {
variants: {
variant: {
default: 'bg-primary',
destructive: 'bg-destructive',
outline: 'border border-border bg-transparent',
secondary: 'bg-secondary',
ghost: 'bg-transparent',
link: 'bg-transparent',
},
size: {
default: 'h-10 px-4 py-2',
sm: 'h-9 px-3',
lg: 'h-11 px-8',
icon: 'h-10 w-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
});
const buttonTextVariants = cva('font-medium', {
variants: {
variant: {
default: 'text-primary-foreground',
destructive: 'text-destructive-foreground',
outline: 'text-foreground',
secondary: 'text-secondary-foreground',
ghost: 'text-foreground',
link: 'text-primary underline',
},
size: {
default: 'text-sm',
sm: 'text-xs',
lg: 'text-base',
icon: 'text-sm',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
});
interface ButtonProps extends PressableProps, VariantProps<typeof buttonVariants> {
children: React.ReactNode;
}
const Button = React.forwardRef<React.ComponentRef<typeof Pressable>, ButtonProps>(
({ className, variant, size, children, ...props }, ref) => (
<Pressable ref={ref} className={cn(buttonVariants({ variant, size, className }))} {...props}>
{typeof children === 'string' ? (
<Text className={cn(buttonTextVariants({ variant, size }))}>{children}</Text>
) : (
children
)}
</Pressable>
)
);
Button.displayName = 'Button';
export { Button, buttonVariants, buttonTextVariants };

View File

@@ -0,0 +1,40 @@
import * as React from 'react';
import { View, type ViewProps, type Text as RNText, type TextProps } from 'react-native';
import { cn } from '@/lib/utils';
import { Text } from './text';
const Card = React.forwardRef<View, ViewProps>(({ className, ...props }, ref) => (
<View
ref={ref}
className={cn('rounded-lg border border-border bg-card p-4', className)}
{...props}
/>
));
Card.displayName = 'Card';
const CardHeader = React.forwardRef<View, ViewProps>(({ className, ...props }, ref) => (
<View ref={ref} className={cn('pb-2', className)} {...props} />
));
CardHeader.displayName = 'CardHeader';
const CardTitle = React.forwardRef<RNText, TextProps>(({ className, ...props }, ref) => (
<Text ref={ref} className={cn('text-lg font-semibold', className)} {...props} />
));
CardTitle.displayName = 'CardTitle';
const CardDescription = React.forwardRef<RNText, TextProps>(({ className, ...props }, ref) => (
<Text ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
));
CardDescription.displayName = 'CardDescription';
const CardContent = React.forwardRef<View, ViewProps>(({ className, ...props }, ref) => (
<View ref={ref} className={cn('pt-2', className)} {...props} />
));
CardContent.displayName = 'CardContent';
const CardFooter = React.forwardRef<View, ViewProps>(({ className, ...props }, ref) => (
<View ref={ref} className={cn('flex-row items-center pt-4', className)} {...props} />
));
CardFooter.displayName = 'CardFooter';
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter };

View File

@@ -0,0 +1,4 @@
export { Text } from './text';
export { Button, buttonVariants, buttonTextVariants } from './button';
export { Badge, badgeVariants, badgeTextVariants } from './badge';
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from './card';

View File

@@ -0,0 +1,68 @@
/**
* Segmented control for selecting time periods (7d, 30d, 1y)
*/
import React from 'react';
import { View, Pressable, StyleSheet } from 'react-native';
import { Text } from './text';
import { colors, spacing, borderRadius } from '@/lib/theme';
export type StatsPeriod = 'week' | 'month' | 'year';
interface PeriodSelectorProps {
value: StatsPeriod;
onChange: (value: StatsPeriod) => void;
}
const PERIODS: { value: StatsPeriod; label: string }[] = [
{ value: 'week', label: '7d' },
{ value: 'month', label: '30d' },
{ value: 'year', label: '1y' },
];
export function PeriodSelector({ value, onChange }: PeriodSelectorProps) {
return (
<View style={styles.container}>
{PERIODS.map((period) => {
const isSelected = value === period.value;
return (
<Pressable
key={period.value}
onPress={() => onChange(period.value)}
style={[styles.button, isSelected && styles.buttonSelected]}
>
<Text
style={[styles.buttonText, isSelected && styles.buttonTextSelected]}
>
{period.label}
</Text>
</Pressable>
);
})}
</View>
);
}
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
backgroundColor: colors.surface.dark,
borderRadius: borderRadius.lg,
padding: 4,
},
button: {
paddingHorizontal: spacing.md,
paddingVertical: (spacing.xs as number) + 2,
borderRadius: borderRadius.md,
},
buttonSelected: {
backgroundColor: colors.card.dark,
},
buttonText: {
fontSize: 13,
fontWeight: '500',
color: colors.text.muted.dark,
},
buttonTextSelected: {
color: colors.text.primary.dark,
},
});

View File

@@ -0,0 +1,10 @@
import * as React from 'react';
import { Text as RNText, type TextProps } from 'react-native';
import { cn } from '@/lib/utils';
const Text = React.forwardRef<RNText, TextProps>(({ className, ...props }, ref) => (
<RNText ref={ref} className={cn('text-foreground text-base', className)} {...props} />
));
Text.displayName = 'Text';
export { Text };

View File

@@ -0,0 +1,68 @@
/**
* User avatar component with image and fallback to initials
*/
import React from 'react';
import { View, Image, StyleSheet } from 'react-native';
import { Text } from './text';
import { colors } from '@/lib/theme';
interface UserAvatarProps {
/** User's avatar URL (can be null) */
thumbUrl?: string | null;
/** Username for generating initials fallback */
username: string;
/** Size of the avatar (default: 40) */
size?: number;
}
export function UserAvatar({ thumbUrl, username, size = 40 }: UserAvatarProps) {
const initials = username.slice(0, 2).toUpperCase();
const fontSize = Math.max(size * 0.4, 10);
const borderRadiusValue = size / 2;
if (thumbUrl) {
return (
<Image
source={{ uri: thumbUrl }}
style={[
styles.image,
{
width: size,
height: size,
borderRadius: borderRadiusValue,
},
]}
/>
);
}
return (
<View
style={[
styles.fallback,
{
width: size,
height: size,
borderRadius: borderRadiusValue,
},
]}
>
<Text style={[styles.initials, { fontSize }]}>{initials}</Text>
</View>
);
}
const styles = StyleSheet.create({
image: {
backgroundColor: colors.surface.dark,
},
fallback: {
backgroundColor: colors.cyan.dark,
justifyContent: 'center',
alignItems: 'center',
},
initials: {
fontWeight: '600',
color: colors.text.primary.dark,
},
});

View File

@@ -0,0 +1,76 @@
import { useState, useEffect, useRef } from 'react';
import type { ActiveSession } from '@tracearr/shared';
/**
* Hook that estimates playback progress client-side for smooth UI updates.
*
* When state is "playing", progress increments every second based on elapsed time.
* When state is "paused" or "stopped", progress stays at last known value.
*
* Resets estimation when:
* - Session ID changes
* - Server-side progressMs changes (new data from SSE/poll)
* - State changes
*
* @param session - The active session to estimate progress for
* @returns Object with estimated progressMs and progress percentage
*/
export function useEstimatedProgress(session: ActiveSession) {
const [estimatedProgressMs, setEstimatedProgressMs] = useState(session.progressMs ?? 0);
// Track the last known server values to detect changes
const lastServerProgress = useRef(session.progressMs);
const lastSessionId = useRef(session.id);
const lastState = useRef(session.state);
const estimationStartTime = useRef(Date.now());
const estimationStartProgress = useRef(session.progressMs ?? 0);
// Reset estimation when server data changes
useEffect(() => {
const serverProgressChanged = session.progressMs !== lastServerProgress.current;
const sessionChanged = session.id !== lastSessionId.current;
const stateChanged = session.state !== lastState.current;
if (sessionChanged || serverProgressChanged || stateChanged) {
// Reset to server value
const newProgress = session.progressMs ?? 0;
setEstimatedProgressMs(newProgress);
// Update refs
lastServerProgress.current = session.progressMs;
lastSessionId.current = session.id;
lastState.current = session.state;
estimationStartTime.current = Date.now();
estimationStartProgress.current = newProgress;
}
}, [session.id, session.progressMs, session.state]);
// Tick progress when playing
useEffect(() => {
if (session.state !== 'playing') {
return;
}
const intervalId = setInterval(() => {
const elapsedMs = Date.now() - estimationStartTime.current;
const estimated = estimationStartProgress.current + elapsedMs;
// Cap at total duration if available
const maxProgress = session.totalDurationMs ?? Infinity;
setEstimatedProgressMs(Math.min(estimated, maxProgress));
}, 1000);
return () => clearInterval(intervalId);
}, [session.state, session.totalDurationMs]);
// Calculate percentage
const progressPercent = session.totalDurationMs
? Math.min((estimatedProgressMs / session.totalDurationMs) * 100, 100)
: 0;
return {
estimatedProgressMs,
progressPercent,
isEstimating: session.state === 'playing',
};
}

View File

@@ -0,0 +1,304 @@
/**
* Push notifications hook for violation alerts
*
* Handles push notification registration, foreground notifications,
* background task registration, and payload decryption.
*/
import { useEffect, useRef, useState, useCallback } from 'react';
import * as Notifications from 'expo-notifications';
import * as Device from 'expo-device';
import Constants from 'expo-constants';
import { Platform } from 'react-native';
import { useRouter } from 'expo-router';
import { useSocket } from '../providers/SocketProvider';
import { useMediaServer } from '../providers/MediaServerProvider';
import type { ViolationWithDetails, EncryptedPushPayload } from '@tracearr/shared';
import {
registerBackgroundNotificationTask,
unregisterBackgroundNotificationTask,
} from '../lib/backgroundTasks';
import { decryptPushPayload, isEncryptionAvailable, getDeviceSecret } from '../lib/crypto';
import { api } from '../lib/api';
// Configure notification behavior
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: true,
shouldPlaySound: true,
shouldSetBadge: true,
shouldShowBanner: true,
shouldShowList: true,
}),
});
// Check if notification payload is encrypted
function isEncrypted(data: unknown): data is EncryptedPushPayload {
if (!data || typeof data !== 'object') return false;
const payload = data as Record<string, unknown>;
return (
payload.v === 1 &&
typeof payload.iv === 'string' &&
typeof payload.ct === 'string' &&
typeof payload.tag === 'string'
);
}
export function usePushNotifications() {
const [expoPushToken, setExpoPushToken] = useState<string | null>(null);
const [notification, setNotification] = useState<Notifications.Notification | null>(null);
const notificationListener = useRef<Notifications.EventSubscription | null>(null);
const responseListener = useRef<Notifications.EventSubscription | null>(null);
const router = useRouter();
const { socket } = useSocket();
const { selectServer, servers } = useMediaServer();
// Register for push notifications
const registerForPushNotifications = useCallback(async (): Promise<string | null> => {
if (!Device.isDevice) {
console.log('Push notifications require a physical device');
return null;
}
// Check existing permissions
const { status: existingStatus } = await Notifications.getPermissionsAsync();
let finalStatus = existingStatus;
// Request permissions if not granted
if (existingStatus !== Notifications.PermissionStatus.GRANTED) {
const { status } = await Notifications.requestPermissionsAsync();
finalStatus = status;
}
if (finalStatus !== Notifications.PermissionStatus.GRANTED) {
console.log('Push notification permission not granted');
return null;
}
// Get Expo push token
try {
const projectId = Constants.expoConfig?.extra?.eas?.projectId;
if (!projectId) {
console.error('No EAS project ID found in app config');
return null;
}
const tokenData = await Notifications.getExpoPushTokenAsync({ projectId });
return tokenData.data;
} catch (error) {
console.error('Failed to get push token:', error);
return null;
}
}, []);
// Show local notification for violations
const showViolationNotification = useCallback(async (violation: ViolationWithDetails) => {
const ruleTypeLabels: Record<string, string> = {
impossible_travel: 'Impossible Travel',
simultaneous_locations: 'Simultaneous Locations',
device_velocity: 'Device Velocity',
concurrent_streams: 'Concurrent Streams',
geo_restriction: 'Geo Restriction',
};
const severityLabels: Record<string, string> = {
low: 'Low',
warning: 'Warning',
high: 'High',
critical: 'Critical',
};
const title = `${severityLabels[violation.severity] || 'Warning'} Violation`;
const ruleType = violation.rule?.type || '';
const body = `${violation.user?.username || 'Unknown user'}: ${ruleTypeLabels[ruleType] || 'Rule Violation'}`;
await Notifications.scheduleNotificationAsync({
content: {
title,
body,
data: {
type: 'violation',
violationId: violation.id,
serverUserId: violation.serverUserId,
},
sound: true,
},
trigger: null, // Show immediately
});
}, []);
// Process notification data (handle encryption if needed)
const processNotificationData = useCallback(
async (data: Record<string, unknown>): Promise<Record<string, unknown>> => {
if (isEncrypted(data) && isEncryptionAvailable()) {
try {
return await decryptPushPayload(data);
} catch (error) {
console.error('Failed to decrypt notification:', error);
return data; // Fall back to encrypted data
}
}
return data;
},
[]
);
// Initialize push notifications
useEffect(() => {
const initializePushNotifications = async () => {
const token = await registerForPushNotifications();
if (token) {
setExpoPushToken(token);
// Register push token with server, including device secret for encryption
try {
const deviceSecret = isEncryptionAvailable() ? await getDeviceSecret() : undefined;
await api.registerPushToken(token, deviceSecret);
console.log('Push token registered with server');
} catch (error) {
console.error('Failed to register push token with server:', error);
}
}
};
void initializePushNotifications();
// Register background notification task
void registerBackgroundNotificationTask();
// Listen for notifications received while app is foregrounded
notificationListener.current = Notifications.addNotificationReceivedListener(
(receivedNotification) => {
// Process/decrypt the notification data if needed
const rawData = receivedNotification.request.content.data;
if (rawData && typeof rawData === 'object') {
void (async () => {
const processedData = await processNotificationData(
rawData as Record<string, unknown>
);
// Update the notification with processed data
const processedNotification = {
...receivedNotification,
request: {
...receivedNotification.request,
content: {
...receivedNotification.request.content,
data: processedData,
},
},
};
setNotification(processedNotification as Notifications.Notification);
})();
} else {
setNotification(receivedNotification);
}
}
);
// Listen for notification taps
responseListener.current = Notifications.addNotificationResponseReceivedListener(
(response) => {
const rawData = response.notification.request.content.data;
void (async () => {
let data = rawData;
// Decrypt if needed
if (rawData && isEncrypted(rawData) && isEncryptionAvailable()) {
try {
data = await decryptPushPayload(rawData);
} catch {
// Use raw data if decryption fails
}
}
// Auto-select the server related to this notification if provided
const notificationServerId = data?.serverId as string | undefined;
if (notificationServerId && servers.some((s) => s.id === notificationServerId)) {
selectServer(notificationServerId);
}
// Navigate based on notification type
if (data?.type === 'violation_detected') {
router.push('/(tabs)/alerts');
} else if (data?.type === 'stream_started' || data?.type === 'stream_stopped') {
router.push('/(tabs)/activity');
} else if (data?.type === 'server_down' || data?.type === 'server_up') {
router.push('/(tabs)');
}
})();
}
);
return () => {
if (notificationListener.current) {
notificationListener.current.remove();
}
if (responseListener.current) {
responseListener.current.remove();
}
// Note: We don't unregister background task on unmount
// as it needs to persist for background notifications
};
}, [registerForPushNotifications, router, processNotificationData, selectServer, servers]);
// Listen for violation events from socket
useEffect(() => {
if (!socket) return;
const handleViolation = (violation: ViolationWithDetails) => {
void showViolationNotification(violation);
};
socket.on('violation:new', handleViolation);
return () => {
socket.off('violation:new', handleViolation);
};
}, [socket, showViolationNotification]);
// Configure Android notification channels for different notification types
useEffect(() => {
if (Platform.OS === 'android') {
// Violations channel - high priority
void Notifications.setNotificationChannelAsync('violations', {
name: 'Violation Alerts',
description: 'Alerts when rule violations are detected',
importance: Notifications.AndroidImportance.HIGH,
vibrationPattern: [0, 250, 250, 250],
lightColor: '#22D3EE',
sound: 'default',
});
// Sessions channel - default priority
void Notifications.setNotificationChannelAsync('sessions', {
name: 'Stream Activity',
description: 'Notifications for stream start/stop events',
importance: Notifications.AndroidImportance.DEFAULT,
vibrationPattern: [0, 100, 100, 100],
lightColor: '#10B981',
});
// Alerts channel - high priority (server status)
void Notifications.setNotificationChannelAsync('alerts', {
name: 'Server Alerts',
description: 'Server online/offline notifications',
importance: Notifications.AndroidImportance.HIGH,
vibrationPattern: [0, 500],
lightColor: '#EF4444',
sound: 'default',
});
}
}, []);
// Cleanup function for logout
const cleanup = useCallback(async () => {
await unregisterBackgroundNotificationTask();
}, []);
return {
expoPushToken,
notification,
showViolationNotification,
cleanup,
isEncryptionAvailable: isEncryptionAvailable(),
};
}

View File

@@ -0,0 +1,126 @@
/**
* Hook for fetching server resource statistics (CPU/RAM)
* Only polls when:
* 1. App is in foreground (AppState === 'active')
* 2. Dashboard tab is focused (useIsFocused)
*/
import { useRef, useCallback, useEffect, useState } from 'react';
import { AppState, type AppStateStatus } from 'react-native';
import { useIsFocused } from '@react-navigation/native';
import { useQuery } from '@tanstack/react-query';
import { SERVER_STATS_CONFIG, type ServerResourceDataPoint, type ServerResourceStats } from '@tracearr/shared';
import { api } from '@/lib/api';
/**
* Hook for fetching server resource statistics with fixed 2-minute window
* Polls every 6 seconds, displays last 2 minutes of data (20 points)
*
* @param serverId - Server ID to fetch stats for
* @param enabled - Additional enable condition (e.g., server exists)
*/
export function useServerStatistics(serverId: string | undefined, enabled: boolean = true) {
const isFocused = useIsFocused();
const [appState, setAppState] = useState<AppStateStatus>(AppState.currentState);
// Track app state changes
useEffect(() => {
const subscription = AppState.addEventListener('change', (nextAppState) => {
setAppState(nextAppState);
});
return () => {
subscription.remove();
};
}, []);
// Only poll when app is active AND screen is focused
const shouldPoll = enabled && !!serverId && appState === 'active' && isFocused;
// Accumulate data points across polls, keyed by timestamp for deduplication
const dataMapRef = useRef<Map<number, ServerResourceDataPoint>>(new Map());
// Merge new data with existing, keep most recent DATA_POINTS
const mergeData = useCallback((newData: ServerResourceDataPoint[]) => {
const map = dataMapRef.current;
// Add/update data points
for (const point of newData) {
map.set(point.at, point);
}
// Sort by timestamp descending (newest first), keep DATA_POINTS
const sorted = Array.from(map.values())
.sort((a, b) => b.at - a.at)
.slice(0, SERVER_STATS_CONFIG.DATA_POINTS);
// Rebuild map with only kept points
dataMapRef.current = new Map(sorted.map((p) => [p.at, p]));
// Return in ascending order (oldest first) for chart rendering
return sorted.reverse();
}, []);
const query = useQuery<ServerResourceStats>({
queryKey: ['servers', 'statistics', serverId],
queryFn: async (): Promise<ServerResourceStats> => {
if (!serverId) throw new Error('Server ID required');
const response = await api.servers.statistics(serverId);
// Merge with accumulated data
const mergedData = mergeData(response.data);
return {
...response,
data: mergedData,
};
},
enabled: shouldPoll,
// Poll every 6 seconds (matches SERVER_STATS_CONFIG.POLL_INTERVAL_SECONDS)
refetchInterval: shouldPoll ? SERVER_STATS_CONFIG.POLL_INTERVAL_SECONDS * 1000 : false,
// Keep previous data while fetching new
placeholderData: (prev) => prev,
// Data is fresh until next poll
staleTime: (SERVER_STATS_CONFIG.POLL_INTERVAL_SECONDS * 1000) - 500,
});
// Calculate averages from windowed data
const dataPoints = query.data?.data;
const dataLength = dataPoints?.length ?? 0;
const averages = dataPoints && dataLength > 0
? {
hostCpu: Math.round(
dataPoints.reduce((sum: number, p) => sum + p.hostCpuUtilization, 0) / dataLength
),
processCpu: Math.round(
dataPoints.reduce((sum: number, p) => sum + p.processCpuUtilization, 0) / dataLength
),
hostMemory: Math.round(
dataPoints.reduce((sum: number, p) => sum + p.hostMemoryUtilization, 0) / dataLength
),
processMemory: Math.round(
dataPoints.reduce((sum: number, p) => sum + p.processMemoryUtilization, 0) / dataLength
),
}
: null;
// Get latest values (most recent data point)
const lastDataPoint = query.data?.data?.[query.data.data.length - 1];
const latest = lastDataPoint
? {
hostCpu: Math.round(lastDataPoint.hostCpuUtilization),
processCpu: Math.round(lastDataPoint.processCpuUtilization),
hostMemory: Math.round(lastDataPoint.hostMemoryUtilization),
processMemory: Math.round(lastDataPoint.processMemoryUtilization),
}
: null;
return {
...query,
averages,
latest,
isPolling: shouldPoll,
// Provide more specific loading state:
// - isLoading: true only on initial fetch with no cached data
// - isFetching: true during any fetch (initial or refetch)
// We want to show loading UI while waiting for first data
isLoadingData: query.isLoading || (query.isFetching && !query.data),
};
}

545
apps/mobile/src/lib/api.ts Normal file
View File

@@ -0,0 +1,545 @@
/**
* API client for Tracearr mobile app
* Uses axios with automatic token refresh
* Supports multiple servers with active server selection
*/
import axios from 'axios';
import type { AxiosInstance, AxiosError, InternalAxiosRequestConfig } from 'axios';
import { storage } from './storage';
import type {
ActiveSession,
DashboardStats,
ServerUserWithIdentity,
ServerUserDetail,
Session,
SessionWithDetails,
UserLocation,
UserDevice,
Violation,
ViolationWithDetails,
Rule,
Server,
Settings,
MobilePairResponse,
PaginatedResponse,
NotificationPreferences,
NotificationPreferencesWithStatus,
ServerResourceStats,
TerminationLogWithDetails,
} from '@tracearr/shared';
// Cache of API clients per server
const apiClients = new Map<string, AxiosInstance>();
let activeServerId: string | null = null;
/**
* Initialize or get the API client for the active server
*/
export async function getApiClient(): Promise<AxiosInstance> {
const serverId = await storage.getActiveServerId();
if (!serverId) {
throw new Error('No server configured');
}
// If server changed, update active
if (activeServerId !== serverId) {
activeServerId = serverId;
}
// Check cache
const cached = apiClients.get(serverId);
if (cached) {
return cached;
}
// Get server info
const server = await storage.getServer(serverId);
if (!server) {
throw new Error('Server not found');
}
const client = createApiClient(server.url, serverId);
apiClients.set(serverId, client);
return client;
}
/**
* Create a new API client for a given server
*/
export function createApiClient(baseURL: string, serverId: string): AxiosInstance {
const client = axios.create({
baseURL: `${baseURL}/api/v1`,
timeout: 30000,
headers: {
'Content-Type': 'application/json',
},
});
// Request interceptor - add auth token for this server
client.interceptors.request.use(
async (config: InternalAxiosRequestConfig) => {
const credentials = await storage.getServerCredentials(serverId);
if (credentials) {
config.headers.Authorization = `Bearer ${credentials.accessToken}`;
}
return config;
},
(error: unknown) => Promise.reject(error instanceof Error ? error : new Error(String(error)))
);
// Response interceptor - handle token refresh for this server
client.interceptors.response.use(
(response) => response,
async (error: AxiosError) => {
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
// If 401 and not already retrying, attempt token refresh
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
try {
const credentials = await storage.getServerCredentials(serverId);
if (!credentials?.refreshToken) {
throw new Error('No refresh token');
}
const response = await client.post<{ accessToken: string; refreshToken: string }>(
'/mobile/refresh',
{ refreshToken: credentials.refreshToken }
);
await storage.updateServerTokens(
serverId,
response.data.accessToken,
response.data.refreshToken
);
// Retry original request with new token
originalRequest.headers.Authorization = `Bearer ${response.data.accessToken}`;
return await client(originalRequest);
} catch {
// Refresh failed - remove this server's client from cache
apiClients.delete(serverId);
throw new Error('Session expired');
}
}
return Promise.reject(error);
}
);
return client;
}
/**
* Reset the API client cache (call when switching servers or logging out)
*/
export function resetApiClient(): void {
apiClients.clear();
activeServerId = null;
}
/**
* Remove a specific server's client from cache
*/
export function removeApiClient(serverId: string): void {
apiClients.delete(serverId);
}
/**
* Get the current server URL (for building absolute URLs like images)
*/
export async function getServerUrl(): Promise<string | null> {
return storage.getServerUrl();
}
/**
* API methods organized by domain
* All methods use the active server's client
*/
export const api = {
/**
* Pair with server using mobile token
* This is called before we have a client, so it uses direct axios
*/
pair: async (
serverUrl: string,
token: string,
deviceName: string,
deviceId: string,
platform: 'ios' | 'android',
deviceSecret?: string
): Promise<MobilePairResponse> => {
try {
const response = await axios.post<MobilePairResponse>(
`${serverUrl}/api/v1/mobile/pair`,
{ token, deviceName, deviceId, platform, deviceSecret },
{ timeout: 15000 }
);
return response.data;
} catch (error) {
if (axios.isAxiosError(error)) {
// Extract server's error message if available
const serverMessage =
error.response?.data?.message ||
error.response?.data?.error;
if (serverMessage) {
throw new Error(serverMessage);
}
// Handle specific HTTP status codes
if (error.response?.status === 429) {
throw new Error('Too many pairing attempts. Please wait a few minutes.');
}
if (error.response?.status === 401) {
throw new Error('Invalid or expired pairing token.');
}
if (error.response?.status === 400) {
throw new Error('Invalid pairing request. Check your token.');
}
// Handle network errors
if (error.code === 'ECONNABORTED') {
throw new Error('Connection timed out. Check your server URL.');
}
if (error.code === 'ERR_NETWORK' || !error.response) {
throw new Error('Cannot reach server. Check URL and network connection.');
}
// Fallback to axios message
throw new Error(error.message);
}
throw error;
}
},
/**
* Register push token for notifications
*/
registerPushToken: async (
expoPushToken: string,
deviceSecret?: string
): Promise<{ success: boolean; updatedSessions: number }> => {
const client = await getApiClient();
const response = await client.post<{ success: boolean; updatedSessions: number }>(
'/mobile/push-token',
{ expoPushToken, deviceSecret }
);
return response.data;
},
/**
* Dashboard stats
*/
stats: {
dashboard: async (serverId?: string): Promise<DashboardStats> => {
const client = await getApiClient();
const response = await client.get<DashboardStats>('/stats/dashboard', {
params: serverId ? { serverId } : undefined,
});
return response.data;
},
plays: async (params?: {
period?: string;
serverId?: string;
}): Promise<{ data: { date: string; count: number }[] }> => {
const client = await getApiClient();
const response = await client.get<{ data: { date: string; count: number }[] }>(
'/stats/plays',
{ params }
);
return response.data;
},
playsByDayOfWeek: async (params?: {
period?: string;
serverId?: string;
}): Promise<{ data: { day: number; name: string; count: number }[] }> => {
const client = await getApiClient();
const response = await client.get<{ data: { day: number; name: string; count: number }[] }>(
'/stats/plays-by-dayofweek',
{ params }
);
return response.data;
},
playsByHourOfDay: async (params?: {
period?: string;
serverId?: string;
}): Promise<{ data: { hour: number; count: number }[] }> => {
const client = await getApiClient();
const response = await client.get<{ data: { hour: number; count: number }[] }>(
'/stats/plays-by-hourofday',
{ params }
);
return response.data;
},
platforms: async (params?: {
period?: string;
serverId?: string;
}): Promise<{ data: { platform: string; count: number }[] }> => {
const client = await getApiClient();
const response = await client.get<{ data: { platform: string; count: number }[] }>(
'/stats/platforms',
{ params }
);
return response.data;
},
quality: async (params?: { period?: string; serverId?: string }): Promise<{
directPlay: number;
transcode: number;
total: number;
directPlayPercent: number;
transcodePercent: number;
}> => {
const client = await getApiClient();
const response = await client.get<{
directPlay: number;
transcode: number;
total: number;
directPlayPercent: number;
transcodePercent: number;
}>('/stats/quality', { params });
return response.data;
},
concurrent: async (params?: {
period?: string;
serverId?: string;
}): Promise<{ data: { hour: string; maxConcurrent: number }[] }> => {
const client = await getApiClient();
const response = await client.get<{ data: { hour: string; maxConcurrent: number }[] }>(
'/stats/concurrent',
{ params }
);
return response.data;
},
locations: async (params?: {
serverId?: string;
userId?: string;
}): Promise<{
data: {
latitude: number;
longitude: number;
city: string;
country: string;
playCount: number;
}[];
}> => {
const client = await getApiClient();
const response = await client.get<{
data: {
latitude: number;
longitude: number;
city: string;
country: string;
playCount: number;
}[];
}>('/stats/locations', { params });
return response.data;
},
},
/**
* Sessions
*/
sessions: {
active: async (serverId?: string): Promise<ActiveSession[]> => {
const client = await getApiClient();
const response = await client.get<{ data: ActiveSession[] }>('/sessions/active', {
params: serverId ? { serverId } : undefined,
});
return response.data.data;
},
list: async (params?: {
page?: number;
pageSize?: number;
userId?: string;
serverId?: string;
}) => {
const client = await getApiClient();
const response = await client.get<PaginatedResponse<ActiveSession>>('/sessions', { params });
return response.data;
},
get: async (id: string): Promise<SessionWithDetails> => {
const client = await getApiClient();
const response = await client.get<SessionWithDetails>(`/sessions/${id}`);
return response.data;
},
terminate: async (
id: string,
reason?: string
): Promise<{ success: boolean; terminationLogId: string; message: string }> => {
const client = await getApiClient();
const response = await client.post<{
success: boolean;
terminationLogId: string;
message: string;
}>(`/mobile/streams/${id}/terminate`, { reason });
return response.data;
},
},
/**
* Users
*/
users: {
list: async (params?: { page?: number; pageSize?: number; serverId?: string }) => {
const client = await getApiClient();
const response = await client.get<PaginatedResponse<ServerUserWithIdentity>>('/users', {
params,
});
return response.data;
},
get: async (id: string): Promise<ServerUserDetail> => {
const client = await getApiClient();
const response = await client.get<ServerUserDetail>(`/users/${id}`);
return response.data;
},
sessions: async (id: string, params?: { page?: number; pageSize?: number }) => {
const client = await getApiClient();
const response = await client.get<PaginatedResponse<Session>>(`/users/${id}/sessions`, {
params,
});
return response.data;
},
locations: async (id: string): Promise<UserLocation[]> => {
const client = await getApiClient();
const response = await client.get<{ data: UserLocation[] }>(`/users/${id}/locations`);
return response.data.data;
},
devices: async (id: string): Promise<UserDevice[]> => {
const client = await getApiClient();
const response = await client.get<{ data: UserDevice[] }>(`/users/${id}/devices`);
return response.data.data;
},
terminations: async (
id: string,
params?: { page?: number; pageSize?: number }
): Promise<PaginatedResponse<TerminationLogWithDetails>> => {
const client = await getApiClient();
const response = await client.get<PaginatedResponse<TerminationLogWithDetails>>(
`/users/${id}/terminations`,
{ params }
);
return response.data;
},
},
/**
* Violations
*/
violations: {
list: async (params?: {
page?: number;
pageSize?: number;
userId?: string;
severity?: string;
acknowledged?: boolean;
serverId?: string;
}) => {
const client = await getApiClient();
const response = await client.get<PaginatedResponse<ViolationWithDetails>>('/violations', {
params,
});
return response.data;
},
acknowledge: async (id: string): Promise<Violation> => {
const client = await getApiClient();
const response = await client.patch<Violation>(`/violations/${id}`);
return response.data;
},
dismiss: async (id: string): Promise<void> => {
const client = await getApiClient();
await client.delete(`/violations/${id}`);
},
},
/**
* Rules
*/
rules: {
list: async (serverId?: string): Promise<Rule[]> => {
const client = await getApiClient();
const response = await client.get<{ data: Rule[] }>('/rules', {
params: serverId ? { serverId } : undefined,
});
return response.data.data;
},
toggle: async (id: string, isActive: boolean): Promise<Rule> => {
const client = await getApiClient();
const response = await client.patch<Rule>(`/rules/${id}`, { isActive });
return response.data;
},
},
/**
* Servers
*/
servers: {
list: async (): Promise<Server[]> => {
const client = await getApiClient();
const response = await client.get<{ data: Server[] }>('/servers');
return response.data.data;
},
statistics: async (id: string): Promise<ServerResourceStats> => {
const client = await getApiClient();
const response = await client.get<ServerResourceStats>(`/servers/${id}/statistics`);
return response.data;
},
},
/**
* Notification preferences (per-device settings)
*/
notifications: {
/**
* Get notification preferences for current device
* Returns preferences with live rate limit status from Redis
*/
getPreferences: async (): Promise<NotificationPreferencesWithStatus> => {
const client = await getApiClient();
const response = await client.get<NotificationPreferencesWithStatus>(
'/notifications/preferences'
);
return response.data;
},
/**
* Update notification preferences for current device
* Supports partial updates - only send fields you want to change
*/
updatePreferences: async (
data: Partial<
Omit<NotificationPreferences, 'id' | 'mobileSessionId' | 'createdAt' | 'updatedAt'>
>
): Promise<NotificationPreferences> => {
const client = await getApiClient();
const response = await client.patch<NotificationPreferences>(
'/notifications/preferences',
data
);
return response.data;
},
/**
* Send a test notification to verify push is working
*/
sendTest: async (): Promise<{ success: boolean; message: string }> => {
const client = await getApiClient();
const response = await client.post<{ success: boolean; message: string }>(
'/notifications/test'
);
return response.data;
},
},
/**
* Global settings (display preferences, etc.)
*/
settings: {
get: async (): Promise<Settings> => {
const client = await getApiClient();
const response = await client.get<Settings>('/settings');
return response.data;
},
},
};

View File

@@ -0,0 +1,308 @@
/**
* Authentication state store using Zustand
* Supports multiple server connections with active server selection
*/
import { create } from 'zustand';
import { storage, type ServerInfo } from './storage';
import { api, resetApiClient } from './api';
import * as Device from 'expo-device';
import { Platform } from 'react-native';
import { isEncryptionAvailable, getDeviceSecret } from './crypto';
interface AuthState {
// Multi-server state
servers: ServerInfo[];
activeServerId: string | null;
activeServer: ServerInfo | null;
// Legacy compatibility
isAuthenticated: boolean;
isLoading: boolean;
serverUrl: string | null;
serverName: string | null;
error: string | null;
// Actions
initialize: () => Promise<void>;
pair: (serverUrl: string, token: string) => Promise<void>;
addServer: (serverUrl: string, token: string) => Promise<void>;
removeServer: (serverId: string) => Promise<void>;
selectServer: (serverId: string) => Promise<void>;
/** @deprecated Use removeServer(serverId) instead for clarity. This removes the active server. */
logout: () => Promise<void>;
removeActiveServer: () => Promise<void>;
clearError: () => void;
}
export const useAuthStore = create<AuthState>((set, get) => ({
// Initial state
servers: [],
activeServerId: null,
activeServer: null,
isAuthenticated: false,
isLoading: true,
serverUrl: null,
serverName: null,
error: null,
/**
* Initialize auth state from stored credentials
* Handles migration from legacy single-server storage
*/
initialize: async () => {
try {
set({ isLoading: true, error: null });
// Check for and migrate legacy storage
await storage.migrateFromLegacy();
// Load servers and active selection
const servers = await storage.getServers();
const activeServerId = await storage.getActiveServerId();
const activeServer = activeServerId
? servers.find((s) => s.id === activeServerId) ?? null
: null;
// If we have servers but no active selection, select first one
if (servers.length > 0 && !activeServer) {
const firstServer = servers[0]!;
await storage.setActiveServerId(firstServer.id);
set({
servers,
activeServerId: firstServer.id,
activeServer: firstServer,
isAuthenticated: true,
serverUrl: firstServer.url,
serverName: firstServer.name,
isLoading: false,
});
} else if (activeServer) {
set({
servers,
activeServerId,
activeServer,
isAuthenticated: true,
serverUrl: activeServer.url,
serverName: activeServer.name,
isLoading: false,
});
} else {
set({
servers: [],
activeServerId: null,
activeServer: null,
isAuthenticated: false,
serverUrl: null,
serverName: null,
isLoading: false,
});
}
} catch (error) {
console.error('Auth initialization failed:', error);
set({
servers: [],
activeServerId: null,
activeServer: null,
isAuthenticated: false,
isLoading: false,
error: 'Failed to initialize authentication',
});
}
},
/**
* Pair with server using mobile token (legacy method, adds as first/only server)
*/
pair: async (serverUrl: string, token: string) => {
// Delegate to addServer
await get().addServer(serverUrl, token);
},
/**
* Add a new server connection
*/
addServer: async (serverUrl: string, token: string) => {
try {
set({ isLoading: true, error: null });
// Get device info
const deviceName =
Device.deviceName || `${Device.brand || 'Unknown'} ${Device.modelName || 'Device'}`;
const deviceId = Device.osBuildId || `${Platform.OS}-${Date.now()}`;
const platform = Platform.OS === 'ios' ? 'ios' : 'android';
// Normalize URL (remove trailing slash)
const normalizedUrl = serverUrl.replace(/\/$/, '');
// Get device secret for push notification encryption (if available)
let deviceSecret: string | undefined;
if (isEncryptionAvailable()) {
try {
deviceSecret = await getDeviceSecret();
} catch (error) {
console.warn('Failed to get device secret for encryption:', error);
}
}
// Call pair API
const response = await api.pair(
normalizedUrl,
token,
deviceName,
deviceId,
platform,
deviceSecret
);
// Create server info
const serverInfo: ServerInfo = {
id: response.server.id,
url: normalizedUrl,
name: response.server.name,
type: response.server.type,
addedAt: new Date().toISOString(),
};
// Store server and credentials
await storage.addServer(serverInfo, {
accessToken: response.accessToken,
refreshToken: response.refreshToken,
});
// Set as active server
await storage.setActiveServerId(serverInfo.id);
// Reset API client to use new server
resetApiClient();
// Update state
const servers = await storage.getServers();
set({
servers,
activeServerId: serverInfo.id,
activeServer: serverInfo,
isAuthenticated: true,
serverUrl: normalizedUrl,
serverName: serverInfo.name,
isLoading: false,
});
} catch (error) {
console.error('Adding server failed:', error);
set({
isLoading: false,
error: error instanceof Error ? error.message : 'Failed to add server. Check URL and token.',
});
throw error;
}
},
/**
* Remove a server connection
*/
removeServer: async (serverId: string) => {
try {
set({ isLoading: true });
await storage.removeServer(serverId);
// Reload state
const servers = await storage.getServers();
const activeServerId = await storage.getActiveServerId();
const activeServer = activeServerId
? servers.find((s) => s.id === activeServerId) ?? null
: null;
// Reset API client
resetApiClient();
if (servers.length === 0) {
set({
servers: [],
activeServerId: null,
activeServer: null,
isAuthenticated: false,
serverUrl: null,
serverName: null,
isLoading: false,
error: null,
});
} else {
set({
servers,
activeServerId,
activeServer,
isAuthenticated: true,
serverUrl: activeServer?.url ?? null,
serverName: activeServer?.name ?? null,
isLoading: false,
});
}
} catch (error) {
console.error('Removing server failed:', error);
set({
isLoading: false,
error: 'Failed to remove server',
});
}
},
/**
* Switch to a different server
*/
selectServer: async (serverId: string) => {
try {
const { servers } = get();
const server = servers.find((s) => s.id === serverId);
if (!server) {
throw new Error('Server not found');
}
// Set as active
await storage.setActiveServerId(serverId);
// Reset API client to use new server
resetApiClient();
set({
activeServerId: serverId,
activeServer: server,
serverUrl: server.url,
serverName: server.name,
});
} catch (error) {
console.error('Selecting server failed:', error);
set({
error: 'Failed to switch server',
});
}
},
/**
* Remove the currently active server
* @deprecated Use removeServer(serverId) instead for clarity
*/
logout: async () => {
const { activeServerId } = get();
if (activeServerId) {
await get().removeServer(activeServerId);
}
},
/**
* Remove the currently active server (alias for logout with clearer name)
*/
removeActiveServer: async () => {
const { activeServerId } = get();
if (activeServerId) {
await get().removeServer(activeServerId);
}
},
/**
* Clear error message
*/
clearError: () => {
set({ error: null });
},
}));

View File

@@ -0,0 +1,186 @@
/**
* Background Task Handler for Push Notifications
*
* Handles push notifications when the app is in the background or killed.
* Uses expo-task-manager to register background tasks that process
* incoming notifications.
*/
import * as TaskManager from 'expo-task-manager';
import * as Notifications from 'expo-notifications';
import { decryptPushPayload, type DecryptedPayload } from './crypto';
import type { EncryptedPushPayload } from '@tracearr/shared';
// Task identifier for background notification handling
export const BACKGROUND_NOTIFICATION_TASK = 'BACKGROUND_NOTIFICATION_TASK';
// Check if notification payload is encrypted
function isEncrypted(data: unknown): data is EncryptedPushPayload {
if (!data || typeof data !== 'object') return false;
const payload = data as Record<string, unknown>;
return (
payload.v === 1 &&
typeof payload.iv === 'string' &&
typeof payload.ct === 'string' &&
typeof payload.tag === 'string'
);
}
/**
* Process notification payload (decrypt if needed)
*/
async function processPayload(
data: Record<string, unknown>
): Promise<DecryptedPayload | null> {
// Check if payload is encrypted
if (isEncrypted(data)) {
try {
return await decryptPushPayload(data);
} catch (error) {
console.error('[BackgroundTask] Failed to decrypt payload:', error);
return null;
}
}
// Not encrypted, return as-is
return data as DecryptedPayload;
}
/**
* Handle different notification types in background
*/
async function handleNotificationType(payload: DecryptedPayload): Promise<void> {
const notificationType = payload.type as string;
// Handle based on notification type
if (notificationType === 'violation_detected') {
// Violation notifications are critical - ensure they're displayed
console.log('[BackgroundTask] Processing violation notification');
} else if (
notificationType === 'stream_started' ||
notificationType === 'stream_stopped'
) {
// Session notifications are informational
console.log('[BackgroundTask] Processing session notification');
} else if (
notificationType === 'server_down' ||
notificationType === 'server_up'
) {
// Server status notifications are important
console.log('[BackgroundTask] Processing server status notification');
} else if (notificationType === 'data_sync') {
// Silent notification for background data refresh
const syncType = payload.syncType as string | undefined;
console.log('[BackgroundTask] Processing data sync request:', syncType);
// Import QueryClient dynamically to avoid circular dependencies
try {
// Invalidate relevant query caches based on sync type
// The actual data will be refetched when the app becomes active
// This is handled by React Query's cache invalidation
if (syncType === 'stats') {
console.log('[BackgroundTask] Marking stats cache for refresh');
// Stats will be refetched on next app focus
} else if (syncType === 'sessions') {
console.log('[BackgroundTask] Marking sessions cache for refresh');
// Sessions will be refetched on next app focus
}
} catch (error) {
console.error('[BackgroundTask] Data sync error:', error);
}
} else {
console.log('[BackgroundTask] Unknown notification type:', notificationType);
}
}
/**
* Define the background notification task
*
* This task is executed by the OS when a background notification arrives.
* It must complete within the OS-defined time limit (usually 30 seconds).
*/
TaskManager.defineTask(
BACKGROUND_NOTIFICATION_TASK,
async ({ data, error }: TaskManager.TaskManagerTaskBody) => {
if (error) {
console.error('[BackgroundTask] Error:', error);
return;
}
if (!data) {
console.log('[BackgroundTask] No data received');
return;
}
try {
// Extract notification from task data
const taskData = data as { notification?: Notifications.Notification };
const notificationData = taskData.notification?.request.content.data;
if (!notificationData) {
console.log('[BackgroundTask] No notification data');
return;
}
// Process the payload (decrypt if encrypted)
const payload = await processPayload(
notificationData as Record<string, unknown>
);
if (!payload) {
console.error('[BackgroundTask] Failed to process payload');
return;
}
// Handle the notification based on type
await handleNotificationType(payload);
} catch (err) {
console.error('[BackgroundTask] Processing error:', err);
}
}
);
/**
* Register the background notification task
* Call this during app initialization
*/
export async function registerBackgroundNotificationTask(): Promise<void> {
try {
const isRegistered = await TaskManager.isTaskRegisteredAsync(
BACKGROUND_NOTIFICATION_TASK
);
if (!isRegistered) {
await Notifications.registerTaskAsync(BACKGROUND_NOTIFICATION_TASK);
console.log('[BackgroundTask] Registered background notification task');
} else {
console.log('[BackgroundTask] Background task already registered');
}
} catch (error) {
console.error('[BackgroundTask] Failed to register task:', error);
}
}
/**
* Unregister the background notification task
* Call this on logout/cleanup
*/
export async function unregisterBackgroundNotificationTask(): Promise<void> {
try {
const isRegistered = await TaskManager.isTaskRegisteredAsync(
BACKGROUND_NOTIFICATION_TASK
);
if (isRegistered) {
await Notifications.unregisterTaskAsync(BACKGROUND_NOTIFICATION_TASK);
console.log('[BackgroundTask] Unregistered background notification task');
}
} catch (error) {
console.error('[BackgroundTask] Failed to unregister task:', error);
}
}
/**
* Check if background notifications are supported
*/
export function isBackgroundNotificationSupported(): boolean {
return TaskManager.isAvailableAsync !== undefined;
}

View File

@@ -0,0 +1,203 @@
/**
* Push Notification Payload Encryption/Decryption
*
* Uses AES-256-GCM (Authenticated Encryption with Associated Data)
* for secure push notification payloads.
*
* Security properties:
* - Confidentiality: Only the intended device can read the payload
* - Integrity: Tampered payloads are detected and rejected
* - Per-device keys: Each device has a unique derived key
*/
import crypto from 'react-native-quick-crypto';
import * as SecureStore from 'expo-secure-store';
import type { EncryptedPushPayload, NotificationEventType } from '@tracearr/shared';
// Storage key for the per-device encryption secret
const DEVICE_SECRET_KEY = 'tracearr_device_secret';
// AES-256-GCM parameters
const ALGORITHM = 'aes-256-gcm';
const KEY_LENGTH = 32; // 256 bits
const IV_LENGTH = 12; // 96 bits (recommended for GCM)
const SALT_LENGTH = 16; // 128 bits (NIST recommended minimum)
const AUTH_TAG_LENGTH = 16; // 128 bits
/**
* Decrypted push payload structure
*/
export interface DecryptedPayload {
type: NotificationEventType | 'data_sync';
title?: string;
body?: string;
data?: Record<string, unknown>;
[key: string]: unknown;
}
/**
* Generate or retrieve the device-specific encryption secret
* This secret is used along with the server's key to derive the encryption key
*/
export async function getDeviceSecret(): Promise<string> {
let secret = await SecureStore.getItemAsync(DEVICE_SECRET_KEY);
if (!secret) {
// Generate a new 32-byte random secret
const randomBytes = crypto.randomBytes(32);
secret = Buffer.from(randomBytes).toString('base64');
await SecureStore.setItemAsync(DEVICE_SECRET_KEY, secret);
console.log('[Crypto] Generated new device secret');
}
return secret;
}
/**
* Derive the encryption key using PBKDF2
*
* The key is derived from:
* - Device secret (stored locally)
* - Server key identifier (sent with encrypted payload)
*
* This ensures each device has a unique key.
*/
export async function deriveKey(
deviceSecret: string,
salt: Buffer
): Promise<Buffer> {
// Use PBKDF2 with 100,000 iterations for key derivation
// Note: react-native-quick-crypto uses uppercase hash names
const key = crypto.pbkdf2Sync(
deviceSecret,
salt,
100000,
KEY_LENGTH,
'SHA-256'
);
return Buffer.from(key);
}
/**
* Decrypt an encrypted push notification payload
*/
export async function decryptPushPayload(
encrypted: EncryptedPushPayload
): Promise<DecryptedPayload> {
// Validate version
if (encrypted.v !== 1) {
throw new Error(`Unsupported encryption version: ${encrypted.v}`);
}
try {
// Get device secret
const deviceSecret = await getDeviceSecret();
// Decode Base64 values
const iv = Buffer.from(encrypted.iv, 'base64');
const salt = Buffer.from(encrypted.salt, 'base64');
const ciphertext = Buffer.from(encrypted.ct, 'base64');
const authTag = Buffer.from(encrypted.tag, 'base64');
// Validate lengths
if (iv.length !== IV_LENGTH) {
throw new Error(`Invalid IV length: ${iv.length}`);
}
if (salt.length !== SALT_LENGTH) {
throw new Error(`Invalid salt length: ${salt.length}`);
}
if (authTag.length !== AUTH_TAG_LENGTH) {
throw new Error(`Invalid auth tag length: ${authTag.length}`);
}
// Derive key using the separate salt from payload
const key = await deriveKey(deviceSecret, salt);
// Create decipher
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv) as ReturnType<typeof crypto.createDecipheriv> & {
setAuthTag: (tag: Buffer) => void;
};
decipher.setAuthTag(authTag);
// Decrypt
const decrypted = Buffer.concat([
decipher.update(ciphertext),
decipher.final(),
]);
// Parse JSON
const payload = JSON.parse(decrypted.toString('utf8')) as DecryptedPayload;
return payload;
} catch (error) {
console.error('[Crypto] Decryption failed:', error);
throw new Error('Failed to decrypt push payload');
}
}
/**
* Encrypt data for testing purposes (client-side encryption)
* This is primarily used for development/testing.
* In production, encryption happens on the server.
*/
export async function encryptData(
data: Record<string, unknown>
): Promise<EncryptedPushPayload> {
try {
const deviceSecret = await getDeviceSecret();
// Generate random IV and salt separately (NIST: salt should be at least 128 bits)
const iv = crypto.randomBytes(IV_LENGTH);
const salt = crypto.randomBytes(SALT_LENGTH);
// Derive key using proper random salt (wrap in Buffer for type compatibility)
const key = await deriveKey(deviceSecret, Buffer.from(salt));
// Create cipher
const cipher = crypto.createCipheriv(ALGORITHM, key, iv) as ReturnType<typeof crypto.createCipheriv> & {
getAuthTag: () => Buffer;
};
// Encrypt
const plaintext = Buffer.from(JSON.stringify(data), 'utf8');
const encrypted = Buffer.concat([
cipher.update(plaintext),
cipher.final(),
]);
// Get auth tag
const authTag = cipher.getAuthTag();
return {
v: 1,
iv: Buffer.from(iv).toString('base64'),
salt: Buffer.from(salt).toString('base64'),
ct: encrypted.toString('base64'),
tag: authTag.toString('base64'),
};
} catch (error) {
console.error('[Crypto] Encryption failed:', error);
throw new Error('Failed to encrypt data');
}
}
/**
* Check if encryption is available on this device
*/
export function isEncryptionAvailable(): boolean {
try {
// Test if crypto functions are available
const testBytes = crypto.randomBytes(16);
return testBytes.length === 16;
} catch {
return false;
}
}
/**
* Clear the device secret (on logout/unpair)
*/
export async function clearDeviceSecret(): Promise<void> {
await SecureStore.deleteItemAsync(DEVICE_SECRET_KEY);
console.log('[Crypto] Cleared device secret');
}

View File

@@ -0,0 +1,366 @@
/**
* Secure storage utilities for mobile app credentials
* Supports multiple server connections with independent credentials
*
* Uses expo-secure-store for all storage (Expo Go compatible)
*/
import * as SecureStore from 'expo-secure-store';
// Keys for secure storage (per-server, uses serverId suffix)
const SECURE_KEYS = {
ACCESS_TOKEN: 'tracearr_access_token',
REFRESH_TOKEN: 'tracearr_refresh_token',
} as const;
// Keys for general storage (JSON-serializable data, stored in SecureStore)
const STORAGE_KEYS = {
SERVERS: 'tracearr_servers',
ACTIVE_SERVER: 'tracearr_active_server',
} as const;
/**
* Server connection info stored in SecureStore
*/
export interface ServerInfo {
id: string; // Unique identifier (from pairing response or generated)
url: string;
name: string;
type: 'plex' | 'jellyfin' | 'emby';
addedAt: string; // ISO date string
}
/**
* Credentials for a specific server (tokens stored in SecureStore)
*/
export interface ServerCredentials {
accessToken: string;
refreshToken: string;
}
/**
* Full server data including credentials
*/
export interface StoredServer extends ServerInfo {
credentials: ServerCredentials;
}
// Helper to get per-server secure key
function getSecureKey(baseKey: string, serverId: string): string {
return `${baseKey}_${serverId}`;
}
export const storage = {
// ============================================================================
// Server List Management
// ============================================================================
/**
* Get all connected servers
*/
async getServers(): Promise<ServerInfo[]> {
const data = await SecureStore.getItemAsync(STORAGE_KEYS.SERVERS);
if (!data) return [];
try {
return JSON.parse(data) as ServerInfo[];
} catch {
return [];
}
},
/**
* Add a new server to the list
*/
async addServer(server: ServerInfo, credentials: ServerCredentials): Promise<void> {
// Store credentials in SecureStore
await Promise.all([
SecureStore.setItemAsync(
getSecureKey(SECURE_KEYS.ACCESS_TOKEN, server.id),
credentials.accessToken
),
SecureStore.setItemAsync(
getSecureKey(SECURE_KEYS.REFRESH_TOKEN, server.id),
credentials.refreshToken
),
]);
// Add server to list
const servers = await this.getServers();
const existingIndex = servers.findIndex((s) => s.id === server.id);
if (existingIndex >= 0) {
servers[existingIndex] = server;
} else {
servers.push(server);
}
await SecureStore.setItemAsync(STORAGE_KEYS.SERVERS, JSON.stringify(servers));
},
/**
* Remove a server and its credentials
*/
async removeServer(serverId: string): Promise<void> {
// Read active server ID BEFORE making any changes to avoid race conditions
const activeId = await this.getActiveServerId();
const servers = await this.getServers();
const filtered = servers.filter((s) => s.id !== serverId);
// Remove credentials from SecureStore
await Promise.all([
SecureStore.deleteItemAsync(getSecureKey(SECURE_KEYS.ACCESS_TOKEN, serverId)),
SecureStore.deleteItemAsync(getSecureKey(SECURE_KEYS.REFRESH_TOKEN, serverId)),
]);
// Remove from server list
await SecureStore.setItemAsync(STORAGE_KEYS.SERVERS, JSON.stringify(filtered));
// Update active server if the removed one was active
if (activeId === serverId) {
// Select first remaining server or clear
if (filtered.length > 0) {
await this.setActiveServerId(filtered[0]!.id);
} else {
await SecureStore.deleteItemAsync(STORAGE_KEYS.ACTIVE_SERVER);
}
}
},
/**
* Get a specific server by ID
*/
async getServer(serverId: string): Promise<ServerInfo | null> {
const servers = await this.getServers();
return servers.find((s) => s.id === serverId) ?? null;
},
/**
* Update server info (e.g., name changed)
*/
async updateServer(serverId: string, updates: Partial<Omit<ServerInfo, 'id'>>): Promise<void> {
const servers = await this.getServers();
const index = servers.findIndex((s) => s.id === serverId);
if (index >= 0) {
servers[index] = { ...servers[index]!, ...updates };
await SecureStore.setItemAsync(STORAGE_KEYS.SERVERS, JSON.stringify(servers));
}
},
// ============================================================================
// Active Server Selection
// ============================================================================
/**
* Get the currently active server ID
*/
async getActiveServerId(): Promise<string | null> {
return SecureStore.getItemAsync(STORAGE_KEYS.ACTIVE_SERVER);
},
/**
* Set the active server
*/
async setActiveServerId(serverId: string): Promise<void> {
await SecureStore.setItemAsync(STORAGE_KEYS.ACTIVE_SERVER, serverId);
},
/**
* Get the active server info
*/
async getActiveServer(): Promise<ServerInfo | null> {
const activeId = await this.getActiveServerId();
if (!activeId) return null;
return this.getServer(activeId);
},
// ============================================================================
// Credentials Management (per-server)
// ============================================================================
/**
* Get credentials for a specific server
*/
async getServerCredentials(serverId: string): Promise<ServerCredentials | null> {
const [accessToken, refreshToken] = await Promise.all([
SecureStore.getItemAsync(getSecureKey(SECURE_KEYS.ACCESS_TOKEN, serverId)),
SecureStore.getItemAsync(getSecureKey(SECURE_KEYS.REFRESH_TOKEN, serverId)),
]);
if (!accessToken || !refreshToken) {
return null;
}
return { accessToken, refreshToken };
},
/**
* Update tokens for a specific server (after refresh)
*/
async updateServerTokens(
serverId: string,
accessToken: string,
refreshToken: string
): Promise<void> {
await Promise.all([
SecureStore.setItemAsync(getSecureKey(SECURE_KEYS.ACCESS_TOKEN, serverId), accessToken),
SecureStore.setItemAsync(getSecureKey(SECURE_KEYS.REFRESH_TOKEN, serverId), refreshToken),
]);
},
/**
* Get access token for active server
*/
async getAccessToken(): Promise<string | null> {
const activeId = await this.getActiveServerId();
if (!activeId) return null;
return SecureStore.getItemAsync(getSecureKey(SECURE_KEYS.ACCESS_TOKEN, activeId));
},
/**
* Get refresh token for active server
*/
async getRefreshToken(): Promise<string | null> {
const activeId = await this.getActiveServerId();
if (!activeId) return null;
return SecureStore.getItemAsync(getSecureKey(SECURE_KEYS.REFRESH_TOKEN, activeId));
},
/**
* Get server URL for active server
*/
async getServerUrl(): Promise<string | null> {
const server = await this.getActiveServer();
return server?.url ?? null;
},
/**
* Update tokens for active server
*/
async updateTokens(accessToken: string, refreshToken: string): Promise<void> {
const activeId = await this.getActiveServerId();
if (!activeId) throw new Error('No active server');
await this.updateServerTokens(activeId, accessToken, refreshToken);
},
// ============================================================================
// Migration & Compatibility
// ============================================================================
/**
* Check if using legacy single-server storage and migrate if needed
*/
async migrateFromLegacy(): Promise<boolean> {
// Check for legacy keys
const legacyUrl = await SecureStore.getItemAsync('tracearr_server_url');
const legacyAccess = await SecureStore.getItemAsync('tracearr_access_token');
const legacyRefresh = await SecureStore.getItemAsync('tracearr_refresh_token');
const legacyName = await SecureStore.getItemAsync('tracearr_server_name');
if (legacyUrl && legacyAccess && legacyRefresh) {
// Generate a server ID from the URL
const serverId = Buffer.from(legacyUrl).toString('base64').slice(0, 16);
const serverInfo: ServerInfo = {
id: serverId,
url: legacyUrl,
name: legacyName || 'Tracearr',
type: 'plex', // Assume plex for legacy, will update on next sync
addedAt: new Date().toISOString(),
};
// Add server with credentials
await this.addServer(serverInfo, {
accessToken: legacyAccess,
refreshToken: legacyRefresh,
});
// Set as active
await this.setActiveServerId(serverId);
// Clean up legacy keys
await Promise.all([
SecureStore.deleteItemAsync('tracearr_server_url'),
SecureStore.deleteItemAsync('tracearr_access_token'),
SecureStore.deleteItemAsync('tracearr_refresh_token'),
SecureStore.deleteItemAsync('tracearr_server_name'),
]);
return true;
}
return false;
},
// ============================================================================
// Legacy Compatibility (for existing code during transition)
// ============================================================================
/**
* @deprecated Use getServers() and getServerCredentials() instead
* Get stored credentials for active server (legacy compatibility)
*/
async getCredentials(): Promise<{
serverUrl: string;
accessToken: string;
refreshToken: string;
serverName: string;
} | null> {
const server = await this.getActiveServer();
if (!server) return null;
const credentials = await this.getServerCredentials(server.id);
if (!credentials) return null;
return {
serverUrl: server.url,
accessToken: credentials.accessToken,
refreshToken: credentials.refreshToken,
serverName: server.name,
};
},
/**
* @deprecated Use addServer() instead
* Store credentials (legacy compatibility - adds/updates single server)
*/
async storeCredentials(credentials: {
serverUrl: string;
accessToken: string;
refreshToken: string;
serverName: string;
}): Promise<void> {
// Generate ID from URL for consistency
const serverId = Buffer.from(credentials.serverUrl).toString('base64').slice(0, 16);
const serverInfo: ServerInfo = {
id: serverId,
url: credentials.serverUrl,
name: credentials.serverName,
type: 'plex', // Will be updated on server sync
addedAt: new Date().toISOString(),
};
await this.addServer(serverInfo, {
accessToken: credentials.accessToken,
refreshToken: credentials.refreshToken,
});
await this.setActiveServerId(serverId);
},
/**
* @deprecated Use removeServer() for specific server
* Clear all credentials (legacy compatibility - removes active server)
*/
async clearCredentials(): Promise<void> {
const activeId = await this.getActiveServerId();
if (activeId) {
await this.removeServer(activeId);
}
},
/**
* Check if user is authenticated (has at least one server)
*/
async isAuthenticated(): Promise<boolean> {
const servers = await this.getServers();
return servers.length > 0;
},
};

View File

@@ -0,0 +1,167 @@
/**
* Design system tokens matching Tracearr web app
* These values match the web's dark mode theme exactly
*/
export const colors = {
// Brand colors (from Tracearr web)
cyan: {
core: '#18D1E7',
deep: '#0EAFC8',
dark: '#0A7C96',
},
blue: {
core: '#0B1A2E',
steel: '#162840',
soft: '#1E3A5C',
},
// Background colors - matching web dark mode
background: {
dark: '#050A12',
light: '#F9FAFB',
},
card: {
dark: '#0B1A2E',
light: '#FFFFFF',
},
surface: {
dark: '#0F2338',
light: '#F3F4F6',
},
// Accent colors
orange: {
core: '#F97316',
},
purple: '#8B5CF6',
// Status colors
success: '#22C55E',
warning: '#F59E0B',
error: '#EF4444',
danger: '#EF4444',
info: '#3B82F6',
// Switch/toggle colors - matching web dark mode border
switch: {
trackOff: '#162840',
trackOn: '#0EAFC8',
thumbOn: '#18D1E7',
thumbOff: '#64748B',
},
// Text colors - matching web dark mode (CSS: --color-*)
text: {
// --color-foreground / --color-card-foreground
primary: {
dark: '#FFFFFF',
light: '#0B1A2E',
},
// --color-muted-foreground (used for secondary/muted text)
secondary: {
dark: '#94A3B8',
light: '#64748B',
},
// Alias for secondary - use this for muted text in inline styles
muted: {
dark: '#94A3B8',
light: '#64748B',
},
},
// Icon colors - matching web dark mode (CSS: --color-icon-*)
icon: {
default: '#8CA3B8',
active: '#18D1E7',
danger: '#FF4C4C',
},
// Border colors - matching web dark mode (blue-steel)
border: {
dark: '#162840',
light: '#E5E7EB',
},
// Chart colors
chart: ['#18D1E7', '#0EAFC8', '#1E3A5C', '#F59E0B', '#EF4444', '#22C55E'],
};
export const spacing = {
xs: 4,
sm: 8,
md: 16,
lg: 24,
xl: 32,
xxl: 48,
} as const;
export const borderRadius = {
sm: 4,
md: 8,
lg: 12,
xl: 16,
full: 9999,
} as const;
export const typography = {
fontFamily: {
regular: 'System',
medium: 'System',
bold: 'System',
},
fontSize: {
xs: 12,
sm: 14,
base: 16,
lg: 18,
xl: 20,
'2xl': 24,
'3xl': 30,
'4xl': 36,
},
lineHeight: {
tight: 1.25,
normal: 1.5,
relaxed: 1.75,
},
} as const;
export const shadows = {
sm: {
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 2,
elevation: 1,
},
md: {
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.1,
shadowRadius: 6,
elevation: 3,
},
lg: {
shadowColor: '#000',
shadowOffset: { width: 0, height: 10 },
shadowOpacity: 0.15,
shadowRadius: 15,
elevation: 5,
},
} as const;
// Helper function to get theme-aware colors
export function getThemeColor(
colorKey: 'background' | 'card' | 'surface' | 'border',
isDark: boolean
): string {
return colors[colorKey][isDark ? 'dark' : 'light'];
}
export function getTextColor(
variant: 'primary' | 'secondary' | 'muted',
isDark: boolean
): string {
return colors.text[variant][isDark ? 'dark' : 'light'];
}

View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

View File

@@ -0,0 +1,153 @@
/**
* Media Server selection provider
* Fetches available servers from Tracearr API and manages selection
* Similar to web's useServer hook
*/
import {
createContext,
useContext,
useState,
useCallback,
useMemo,
useEffect,
type ReactNode,
} from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import * as SecureStore from 'expo-secure-store';
import type { Server } from '@tracearr/shared';
import { api } from '../lib/api';
import { useAuthStore } from '../lib/authStore';
const SELECTED_SERVER_KEY = 'tracearr_selected_media_server';
interface MediaServerContextValue {
servers: Server[];
selectedServer: Server | null;
selectedServerId: string | null;
isLoading: boolean;
selectServer: (serverId: string | null) => void;
refetch: () => Promise<unknown>;
}
const MediaServerContext = createContext<MediaServerContextValue | null>(null);
export function MediaServerProvider({ children }: { children: ReactNode }) {
const { isAuthenticated, activeServerId: tracearrBackendId } = useAuthStore();
const queryClient = useQueryClient();
const [selectedServerId, setSelectedServerId] = useState<string | null>(null);
const [initialized, setInitialized] = useState(false);
// Load saved selection on mount
useEffect(() => {
void SecureStore.getItemAsync(SELECTED_SERVER_KEY).then((saved) => {
if (saved) {
setSelectedServerId(saved);
}
setInitialized(true);
});
}, []);
// Fetch available servers from API
const {
data: servers = [],
isLoading,
refetch,
} = useQuery({
queryKey: ['media-servers', tracearrBackendId],
queryFn: () => api.servers.list(),
enabled: isAuthenticated && !!tracearrBackendId,
staleTime: 1000 * 60 * 5, // 5 minutes
});
// Validate selection when servers load
useEffect(() => {
if (!initialized || isLoading) return;
if (servers.length === 0) {
if (selectedServerId) {
setSelectedServerId(null);
void SecureStore.deleteItemAsync(SELECTED_SERVER_KEY);
}
return;
}
// If selection is invalid (server no longer exists), select first
if (selectedServerId && !servers.some((s) => s.id === selectedServerId)) {
const firstServer = servers[0];
if (firstServer) {
setSelectedServerId(firstServer.id);
void SecureStore.setItemAsync(SELECTED_SERVER_KEY, firstServer.id);
}
}
// If no selection but servers exist, select first
if (!selectedServerId && servers.length > 0) {
const firstServer = servers[0];
if (firstServer) {
setSelectedServerId(firstServer.id);
void SecureStore.setItemAsync(SELECTED_SERVER_KEY, firstServer.id);
}
}
}, [servers, selectedServerId, initialized, isLoading]);
// Clear selection on logout
useEffect(() => {
if (!isAuthenticated) {
setSelectedServerId(null);
void SecureStore.deleteItemAsync(SELECTED_SERVER_KEY);
}
}, [isAuthenticated]);
const selectServer = useCallback(
(serverId: string | null) => {
setSelectedServerId(serverId);
if (serverId) {
void SecureStore.setItemAsync(SELECTED_SERVER_KEY, serverId);
} else {
void SecureStore.deleteItemAsync(SELECTED_SERVER_KEY);
}
// Invalidate all server-dependent queries
void queryClient.invalidateQueries({
predicate: (query) => {
const key = query.queryKey[0];
return key !== 'media-servers' && key !== 'servers';
},
});
},
[queryClient]
);
const selectedServer = useMemo(() => {
if (!selectedServerId) return null;
return servers.find((s) => s.id === selectedServerId) ?? null;
}, [servers, selectedServerId]);
const value = useMemo<MediaServerContextValue>(
() => ({
servers,
selectedServer,
selectedServerId,
isLoading,
selectServer,
refetch,
}),
[servers, selectedServer, selectedServerId, isLoading, selectServer, refetch]
);
return (
<MediaServerContext.Provider value={value}>{children}</MediaServerContext.Provider>
);
}
export function useMediaServer(): MediaServerContextValue {
const context = useContext(MediaServerContext);
if (!context) {
throw new Error('useMediaServer must be used within a MediaServerProvider');
}
return context;
}
export function useSelectedServerId(): string | null {
const { selectedServerId } = useMediaServer();
return selectedServerId;
}

View File

@@ -0,0 +1,32 @@
/**
* React Query provider for data fetching
*/
import React from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60, // 1 minute
retry: 2,
refetchOnWindowFocus: true,
},
mutations: {
retry: 1,
},
},
});
interface QueryProviderProps {
children: React.ReactNode;
}
export function QueryProvider({ children }: QueryProviderProps) {
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
}
export { queryClient };

View File

@@ -0,0 +1,160 @@
/**
* Socket.io provider for real-time updates
* Connects to Tracearr backend and invalidates queries on events
*/
import React, { createContext, useContext, useEffect, useRef, useState, useCallback } from 'react';
import { io } from 'socket.io-client';
import type { Socket } from 'socket.io-client';
import { AppState } from 'react-native';
import type { AppStateStatus } from 'react-native';
import { useQueryClient } from '@tanstack/react-query';
import { storage } from '../lib/storage';
import { useAuthStore } from '../lib/authStore';
import type {
ServerToClientEvents,
ClientToServerEvents,
ActiveSession,
ViolationWithDetails,
DashboardStats,
} from '@tracearr/shared';
interface SocketContextValue {
socket: Socket<ServerToClientEvents, ClientToServerEvents> | null;
isConnected: boolean;
}
const SocketContext = createContext<SocketContextValue>({
socket: null,
isConnected: false,
});
export function useSocket() {
const context = useContext(SocketContext);
if (!context) {
throw new Error('useSocket must be used within a SocketProvider');
}
return context;
}
export function SocketProvider({ children }: { children: React.ReactNode }) {
const { isAuthenticated, activeServerId, serverUrl } = useAuthStore();
const queryClient = useQueryClient();
const socketRef = useRef<Socket<ServerToClientEvents, ClientToServerEvents> | null>(null);
const [isConnected, setIsConnected] = useState(false);
// Track which Tracearr backend we're connected to
const connectedServerIdRef = useRef<string | null>(null);
const connectSocket = useCallback(async () => {
if (!isAuthenticated || !serverUrl || !activeServerId) return;
// If already connected to this backend, skip
if (connectedServerIdRef.current === activeServerId && socketRef.current?.connected) {
return;
}
// Disconnect existing socket if connected to different backend
if (socketRef.current) {
socketRef.current.disconnect();
socketRef.current = null;
}
const credentials = await storage.getServerCredentials(activeServerId);
if (!credentials) return;
connectedServerIdRef.current = activeServerId;
const newSocket: Socket<ServerToClientEvents, ClientToServerEvents> = io(serverUrl, {
auth: { token: credentials.accessToken },
transports: ['websocket'],
reconnection: true,
reconnectionAttempts: 5,
reconnectionDelay: 1000,
reconnectionDelayMax: 5000,
});
newSocket.on('connect', () => {
console.log('Socket connected');
setIsConnected(true);
// Subscribe to session updates
newSocket.emit('subscribe:sessions');
});
newSocket.on('disconnect', (reason) => {
console.log('Socket disconnected:', reason);
setIsConnected(false);
});
newSocket.on('connect_error', (error) => {
console.error('Socket connection error:', error.message);
setIsConnected(false);
});
// Handle real-time events
// Use partial query keys to invalidate ALL cached data regardless of selected media server
// This matches the web app pattern where socket events invalidate all server-filtered caches
newSocket.on('session:started', (_session: ActiveSession) => {
// Invalidate all active sessions caches (any server filter)
void queryClient.invalidateQueries({ queryKey: ['sessions', 'active'] });
void queryClient.invalidateQueries({ queryKey: ['dashboard', 'stats'] });
});
newSocket.on('session:stopped', (_sessionId: string) => {
void queryClient.invalidateQueries({ queryKey: ['sessions', 'active'] });
void queryClient.invalidateQueries({ queryKey: ['dashboard', 'stats'] });
});
newSocket.on('session:updated', (_session: ActiveSession) => {
void queryClient.invalidateQueries({ queryKey: ['sessions', 'active'] });
});
newSocket.on('violation:new', (_violation: ViolationWithDetails) => {
void queryClient.invalidateQueries({ queryKey: ['violations'] });
void queryClient.invalidateQueries({ queryKey: ['dashboard', 'stats'] });
});
newSocket.on('stats:updated', (_stats: DashboardStats) => {
void queryClient.invalidateQueries({ queryKey: ['dashboard', 'stats'] });
});
socketRef.current = newSocket;
}, [isAuthenticated, serverUrl, activeServerId, queryClient]);
// Connect/disconnect based on auth state
useEffect(() => {
if (isAuthenticated && serverUrl && activeServerId) {
void connectSocket();
} else if (socketRef.current) {
socketRef.current.disconnect();
socketRef.current = null;
connectedServerIdRef.current = null;
setIsConnected(false);
}
return () => {
if (socketRef.current) {
socketRef.current.disconnect();
socketRef.current = null;
connectedServerIdRef.current = null;
}
};
}, [isAuthenticated, serverUrl, activeServerId, connectSocket]);
// Handle app state changes (background/foreground)
useEffect(() => {
const handleAppStateChange = (nextState: AppStateStatus) => {
if (nextState === 'active' && isAuthenticated && !isConnected) {
// Reconnect when app comes to foreground
void connectSocket();
}
};
const subscription = AppState.addEventListener('change', handleAppStateChange);
return () => subscription.remove();
}, [isAuthenticated, isConnected, connectSocket]);
return (
<SocketContext.Provider value={{ socket: socketRef.current, isConnected }}>
{children}
</SocketContext.Provider>
);
}

32
apps/mobile/tsconfig.json Normal file
View File

@@ -0,0 +1,32 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"baseUrl": ".",
"paths": {
"@/*": [
"./src/*"
],
"@tracearr/shared": [
"../../packages/shared/src"
]
},
"types": [
"expo-router/types",
"node"
],
"skipLibCheck": true,
"esModuleInterop": true,
"resolveJsonModule": true
},
"include": [
"**/*.ts",
"**/*.tsx",
".expo/types/**/*.ts",
"expo-env.d.ts",
"nativewind-env.d.ts"
],
"exclude": [
"node_modules"
]
}

View File

@@ -0,0 +1,16 @@
import { defineConfig } from 'drizzle-kit';
if (!process.env.DATABASE_URL) {
throw new Error('DATABASE_URL environment variable is required');
}
export default defineConfig({
schema: './src/db/schema.ts',
out: './src/db/migrations',
dialect: 'postgresql',
dbCredentials: {
url: process.env.DATABASE_URL,
},
verbose: true,
strict: true,
});

73
apps/server/package.json Normal file
View File

@@ -0,0 +1,73 @@
{
"name": "@tracearr/server",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "./dist/index.js",
"scripts": {
"dev": "tsx watch --env-file=../../.env src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"typecheck": "tsc --noEmit",
"lint": "eslint src/",
"lint:fix": "eslint src/ --fix",
"test": "vitest run",
"test:all": "vitest run",
"test:watch": "vitest",
"test:unit": "vitest run --config vitest.unit.config.ts",
"test:services": "vitest run --config vitest.services.config.ts",
"test:routes": "vitest run --config vitest.routes.config.ts",
"test:security": "vitest run --config vitest.security.config.ts",
"test:integration": "vitest run --config vitest.integration.config.ts",
"test:coverage": "vitest run --coverage",
"test:unit:coverage": "vitest run --config vitest.unit.config.ts --coverage",
"test:services:coverage": "vitest run --config vitest.services.config.ts --coverage",
"test:routes:coverage": "vitest run --config vitest.routes.config.ts --coverage",
"clean": "rm -rf dist .turbo coverage",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio"
},
"dependencies": {
"@fastify/cookie": "^11.0.0",
"@fastify/cors": "^11.0.0",
"@fastify/helmet": "^13.0.0",
"@fastify/jwt": "^10.0.0",
"@fastify/rate-limit": "^10.0.0",
"@fastify/sensible": "^6.0.0",
"@fastify/static": "^8.0.0",
"@fastify/swagger": "^9.0.0",
"@fastify/swagger-ui": "^5.0.0",
"@fastify/websocket": "^11.0.0",
"@tracearr/shared": "workspace:*",
"bcrypt": "^6.0.0",
"bullmq": "^5.65.1",
"dotenv": "^16.4.7",
"drizzle-orm": "^0.44.0",
"eventsource": "^4.1.0",
"expo-server-sdk": "^4.0.0",
"fastify": "^5.0.0",
"fastify-plugin": "^5.0.0",
"ioredis": "^5.4.0",
"jsonwebtoken": "^9.0.3",
"maxmind": "^4.3.29",
"pg": "^8.13.0",
"sharp": "^0.34.0",
"socket.io": "^4.8.0",
"zod": "^4.0.0"
},
"devDependencies": {
"@tracearr/test-utils": "workspace:*",
"@types/bcrypt": "^5.0.2",
"@types/eventsource": "^3.0.0",
"@types/jsonwebtoken": "^9.0.9",
"@types/pg": "^8.11.10",
"@vitest/coverage-v8": "^4.0.0",
"drizzle-kit": "^0.31.0",
"pino-pretty": "^13.0.0",
"tsx": "^4.19.0",
"typescript": "^5.7.0",
"vitest": "^4.0.0"
}
}

View File

@@ -0,0 +1,50 @@
/**
* Database client and connection pool
*/
import { drizzle } from 'drizzle-orm/node-postgres';
import { migrate } from 'drizzle-orm/node-postgres/migrator';
import pg from 'pg';
import * as schema from './schema.js';
const { Pool } = pg;
if (!process.env.DATABASE_URL) {
throw new Error('DATABASE_URL environment variable is required');
}
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 20, // Maximum connections
idleTimeoutMillis: 20000, // Close idle connections after 20s
connectionTimeoutMillis: 10000, // Connection timeout (increased for complex queries)
maxUses: 7500, // Max queries per connection before refresh (prevents memory leaks)
allowExitOnIdle: false, // Keep pool alive during idle periods
});
// Log pool errors for debugging
pool.on('error', (err) => {
console.error('[DB Pool Error]', err.message);
});
export const db = drizzle(pool, { schema });
export async function closeDatabase(): Promise<void> {
await pool.end();
}
export async function checkDatabaseConnection(): Promise<boolean> {
try {
const client = await pool.connect();
await client.query('SELECT 1');
client.release();
return true;
} catch (error) {
console.error('Database connection check failed:', error);
return false;
}
}
export async function runMigrations(migrationsFolder: string): Promise<void> {
await migrate(db, { migrationsFolder });
}

View File

@@ -0,0 +1,154 @@
CREATE TABLE "rules" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" varchar(100) NOT NULL,
"type" varchar(50) NOT NULL,
"params" jsonb NOT NULL,
"server_user_id" uuid,
"is_active" boolean DEFAULT true NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "server_users" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"server_id" uuid NOT NULL,
"external_id" varchar(255) NOT NULL,
"username" varchar(255) NOT NULL,
"email" varchar(255),
"thumb_url" text,
"is_server_admin" boolean DEFAULT false NOT NULL,
"trust_score" integer DEFAULT 100 NOT NULL,
"session_count" integer DEFAULT 0 NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "servers" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" varchar(100) NOT NULL,
"type" varchar(20) NOT NULL,
"url" text NOT NULL,
"token" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "sessions" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"server_id" uuid NOT NULL,
"server_user_id" uuid NOT NULL,
"session_key" varchar(255) NOT NULL,
"state" varchar(20) NOT NULL,
"media_type" varchar(20) NOT NULL,
"media_title" text NOT NULL,
"grandparent_title" varchar(500),
"season_number" integer,
"episode_number" integer,
"year" integer,
"thumb_path" varchar(500),
"rating_key" varchar(255),
"external_session_id" varchar(255),
"started_at" timestamp with time zone DEFAULT now() NOT NULL,
"stopped_at" timestamp with time zone,
"duration_ms" integer,
"total_duration_ms" integer,
"progress_ms" integer,
"last_paused_at" timestamp with time zone,
"paused_duration_ms" integer DEFAULT 0 NOT NULL,
"reference_id" uuid,
"watched" boolean DEFAULT false NOT NULL,
"ip_address" varchar(45) NOT NULL,
"geo_city" varchar(255),
"geo_region" varchar(255),
"geo_country" varchar(100),
"geo_lat" real,
"geo_lon" real,
"player_name" varchar(255),
"device_id" varchar(255),
"product" varchar(255),
"device" varchar(255),
"platform" varchar(100),
"quality" varchar(100),
"is_transcode" boolean DEFAULT false NOT NULL,
"bitrate" integer
);
--> statement-breakpoint
CREATE TABLE "settings" (
"id" integer PRIMARY KEY DEFAULT 1 NOT NULL,
"allow_guest_access" boolean DEFAULT false NOT NULL,
"discord_webhook_url" text,
"custom_webhook_url" text,
"notify_on_violation" boolean DEFAULT true NOT NULL,
"notify_on_session_start" boolean DEFAULT false NOT NULL,
"notify_on_session_stop" boolean DEFAULT false NOT NULL,
"notify_on_server_down" boolean DEFAULT true NOT NULL,
"poller_enabled" boolean DEFAULT true NOT NULL,
"poller_interval_ms" integer DEFAULT 15000 NOT NULL,
"tautulli_url" text,
"tautulli_api_key" text,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "users" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"username" varchar(100) NOT NULL,
"name" varchar(255),
"thumbnail" text,
"email" varchar(255),
"password_hash" text,
"plex_account_id" varchar(255),
"role" varchar(20) DEFAULT 'member' NOT NULL,
"aggregate_trust_score" integer DEFAULT 100 NOT NULL,
"total_violations" integer DEFAULT 0 NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "violations" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"rule_id" uuid NOT NULL,
"server_user_id" uuid NOT NULL,
"session_id" uuid NOT NULL,
"severity" varchar(20) NOT NULL,
"data" jsonb NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"acknowledged_at" timestamp with time zone
);
--> statement-breakpoint
ALTER TABLE "rules" ADD CONSTRAINT "rules_server_user_id_server_users_id_fk" FOREIGN KEY ("server_user_id") REFERENCES "public"."server_users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "server_users" ADD CONSTRAINT "server_users_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "server_users" ADD CONSTRAINT "server_users_server_id_servers_id_fk" FOREIGN KEY ("server_id") REFERENCES "public"."servers"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sessions" ADD CONSTRAINT "sessions_server_id_servers_id_fk" FOREIGN KEY ("server_id") REFERENCES "public"."servers"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sessions" ADD CONSTRAINT "sessions_server_user_id_server_users_id_fk" FOREIGN KEY ("server_user_id") REFERENCES "public"."server_users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "violations" ADD CONSTRAINT "violations_rule_id_rules_id_fk" FOREIGN KEY ("rule_id") REFERENCES "public"."rules"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "violations" ADD CONSTRAINT "violations_server_user_id_server_users_id_fk" FOREIGN KEY ("server_user_id") REFERENCES "public"."server_users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "violations" ADD CONSTRAINT "violations_session_id_sessions_id_fk" FOREIGN KEY ("session_id") REFERENCES "public"."sessions"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "rules_active_idx" ON "rules" USING btree ("is_active");--> statement-breakpoint
CREATE INDEX "rules_server_user_id_idx" ON "rules" USING btree ("server_user_id");--> statement-breakpoint
CREATE UNIQUE INDEX "server_users_user_server_unique" ON "server_users" USING btree ("user_id","server_id");--> statement-breakpoint
CREATE UNIQUE INDEX "server_users_server_external_unique" ON "server_users" USING btree ("server_id","external_id");--> statement-breakpoint
CREATE INDEX "server_users_user_idx" ON "server_users" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "server_users_server_idx" ON "server_users" USING btree ("server_id");--> statement-breakpoint
CREATE INDEX "server_users_username_idx" ON "server_users" USING btree ("username");--> statement-breakpoint
CREATE INDEX "sessions_server_user_time_idx" ON "sessions" USING btree ("server_user_id","started_at");--> statement-breakpoint
CREATE INDEX "sessions_server_time_idx" ON "sessions" USING btree ("server_id","started_at");--> statement-breakpoint
CREATE INDEX "sessions_state_idx" ON "sessions" USING btree ("state");--> statement-breakpoint
CREATE INDEX "sessions_external_session_idx" ON "sessions" USING btree ("server_id","external_session_id");--> statement-breakpoint
CREATE INDEX "sessions_device_idx" ON "sessions" USING btree ("server_user_id","device_id");--> statement-breakpoint
CREATE INDEX "sessions_reference_idx" ON "sessions" USING btree ("reference_id");--> statement-breakpoint
CREATE INDEX "sessions_server_user_rating_idx" ON "sessions" USING btree ("server_user_id","rating_key");--> statement-breakpoint
CREATE INDEX "sessions_geo_idx" ON "sessions" USING btree ("geo_lat","geo_lon");--> statement-breakpoint
CREATE INDEX "sessions_geo_time_idx" ON "sessions" USING btree ("started_at","geo_lat","geo_lon");--> statement-breakpoint
CREATE INDEX "sessions_media_type_idx" ON "sessions" USING btree ("media_type");--> statement-breakpoint
CREATE INDEX "sessions_transcode_idx" ON "sessions" USING btree ("is_transcode");--> statement-breakpoint
CREATE INDEX "sessions_platform_idx" ON "sessions" USING btree ("platform");--> statement-breakpoint
CREATE INDEX "sessions_top_movies_idx" ON "sessions" USING btree ("media_type","media_title","year");--> statement-breakpoint
CREATE INDEX "sessions_top_shows_idx" ON "sessions" USING btree ("media_type","grandparent_title");--> statement-breakpoint
CREATE UNIQUE INDEX "users_username_unique" ON "users" USING btree ("username");--> statement-breakpoint
CREATE UNIQUE INDEX "users_email_unique" ON "users" USING btree ("email");--> statement-breakpoint
CREATE INDEX "users_plex_account_id_idx" ON "users" USING btree ("plex_account_id");--> statement-breakpoint
CREATE INDEX "users_role_idx" ON "users" USING btree ("role");--> statement-breakpoint
CREATE INDEX "violations_server_user_id_idx" ON "violations" USING btree ("server_user_id");--> statement-breakpoint
CREATE INDEX "violations_rule_id_idx" ON "violations" USING btree ("rule_id");--> statement-breakpoint
CREATE INDEX "violations_created_at_idx" ON "violations" USING btree ("created_at");

View File

@@ -0,0 +1,2 @@
DROP INDEX "users_username_unique";--> statement-breakpoint
CREATE INDEX "users_username_idx" ON "users" USING btree ("username");

View File

@@ -0,0 +1,26 @@
CREATE TABLE "mobile_sessions" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"refresh_token_hash" varchar(64) NOT NULL,
"device_name" varchar(100) NOT NULL,
"device_id" varchar(100) NOT NULL,
"platform" varchar(20) NOT NULL,
"expo_push_token" varchar(255),
"last_seen_at" timestamp with time zone DEFAULT now() NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "mobile_sessions_refresh_token_hash_unique" UNIQUE("refresh_token_hash")
);
--> statement-breakpoint
CREATE TABLE "mobile_tokens" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"token_hash" varchar(64) NOT NULL,
"is_enabled" boolean DEFAULT true NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"rotated_at" timestamp with time zone,
CONSTRAINT "mobile_tokens_token_hash_unique" UNIQUE("token_hash")
);
--> statement-breakpoint
ALTER TABLE "settings" ADD COLUMN "external_url" text;--> statement-breakpoint
ALTER TABLE "settings" ADD COLUMN "base_path" varchar(100) DEFAULT '' NOT NULL;--> statement-breakpoint
ALTER TABLE "settings" ADD COLUMN "trust_proxy" boolean DEFAULT false NOT NULL;--> statement-breakpoint
CREATE INDEX "mobile_sessions_device_id_idx" ON "mobile_sessions" USING btree ("device_id");--> statement-breakpoint
CREATE INDEX "mobile_sessions_refresh_token_idx" ON "mobile_sessions" USING btree ("refresh_token_hash");

View File

@@ -0,0 +1,28 @@
CREATE TABLE "notification_preferences" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"mobile_session_id" uuid NOT NULL,
"push_enabled" boolean DEFAULT true NOT NULL,
"on_violation_detected" boolean DEFAULT true NOT NULL,
"on_stream_started" boolean DEFAULT false NOT NULL,
"on_stream_stopped" boolean DEFAULT false NOT NULL,
"on_concurrent_streams" boolean DEFAULT true NOT NULL,
"on_new_device" boolean DEFAULT true NOT NULL,
"on_trust_score_changed" boolean DEFAULT false NOT NULL,
"on_server_down" boolean DEFAULT true NOT NULL,
"on_server_up" boolean DEFAULT true NOT NULL,
"violation_min_severity" integer DEFAULT 1 NOT NULL,
"violation_rule_types" text[] DEFAULT '{}',
"max_per_minute" integer DEFAULT 10 NOT NULL,
"max_per_hour" integer DEFAULT 60 NOT NULL,
"quiet_hours_enabled" boolean DEFAULT false NOT NULL,
"quiet_hours_start" varchar(5),
"quiet_hours_end" varchar(5),
"quiet_hours_timezone" varchar(50) DEFAULT 'UTC',
"quiet_hours_override_critical" boolean DEFAULT true NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "notification_preferences_mobile_session_id_unique" UNIQUE("mobile_session_id")
);
--> statement-breakpoint
ALTER TABLE "notification_preferences" ADD CONSTRAINT "notification_preferences_mobile_session_id_mobile_sessions_id_fk" FOREIGN KEY ("mobile_session_id") REFERENCES "public"."mobile_sessions"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "notification_prefs_mobile_session_idx" ON "notification_preferences" USING btree ("mobile_session_id");

View File

@@ -0,0 +1,25 @@
CREATE TABLE "notification_channel_routing" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"event_type" varchar(50) NOT NULL,
"discord_enabled" boolean DEFAULT true NOT NULL,
"webhook_enabled" boolean DEFAULT true NOT NULL,
"push_enabled" boolean DEFAULT true NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "notification_channel_routing_event_type_unique" UNIQUE("event_type")
);
--> statement-breakpoint
CREATE INDEX "notification_channel_routing_event_type_idx" ON "notification_channel_routing" USING btree ("event_type");
--> statement-breakpoint
-- Seed default routing configuration for all event types
INSERT INTO "notification_channel_routing" ("event_type", "discord_enabled", "webhook_enabled", "push_enabled")
VALUES
('violation_detected', true, true, true),
('stream_started', false, false, false),
('stream_stopped', false, false, false),
('concurrent_streams', true, true, true),
('new_device', true, true, true),
('trust_score_changed', false, false, false),
('server_down', true, true, true),
('server_up', true, true, true)
ON CONFLICT ("event_type") DO NOTHING;

View File

@@ -0,0 +1 @@
ALTER TABLE "mobile_sessions" ADD COLUMN "device_secret" varchar(64);

View File

@@ -0,0 +1,3 @@
CREATE INDEX "mobile_sessions_expo_push_token_idx" ON "mobile_sessions" USING btree ("expo_push_token");--> statement-breakpoint
ALTER TABLE "notification_preferences" ADD CONSTRAINT "quiet_hours_start_format" CHECK ("notification_preferences"."quiet_hours_start" IS NULL OR "notification_preferences"."quiet_hours_start" ~ '^([01][0-9]|2[0-3]):[0-5][0-9]$');--> statement-breakpoint
ALTER TABLE "notification_preferences" ADD CONSTRAINT "quiet_hours_end_format" CHECK ("notification_preferences"."quiet_hours_end" IS NULL OR "notification_preferences"."quiet_hours_end" ~ '^([01][0-9]|2[0-3]):[0-5][0-9]$');

View File

@@ -0,0 +1 @@
ALTER TABLE "settings" ADD COLUMN "mobile_enabled" boolean DEFAULT false NOT NULL;

View File

@@ -0,0 +1,41 @@
-- Custom SQL migration file, put your code below! --
-- Update mobile_tokens schema for one-time pairing tokens
-- Remove old columns (is_enabled, rotated_at) and add new columns (expires_at, created_by, used_at)
-- Step 1: Clear existing tokens (breaking change - old schema incompatible)
DELETE FROM "mobile_tokens";
-- Step 2: Drop old columns
ALTER TABLE "mobile_tokens" DROP COLUMN IF EXISTS "is_enabled";
ALTER TABLE "mobile_tokens" DROP COLUMN IF EXISTS "rotated_at";
-- Step 3: Add new required column with temporary default (IF NOT EXISTS for idempotency)
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'mobile_tokens' AND column_name = 'expires_at') THEN
ALTER TABLE "mobile_tokens" ADD COLUMN "expires_at" timestamp with time zone NOT NULL DEFAULT NOW() + INTERVAL '15 minutes';
END IF;
END $$;
-- Step 4: Add nullable columns (IF NOT EXISTS for idempotency)
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'mobile_tokens' AND column_name = 'created_by') THEN
ALTER TABLE "mobile_tokens" ADD COLUMN "created_by" uuid;
END IF;
END $$;
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'mobile_tokens' AND column_name = 'used_at') THEN
ALTER TABLE "mobile_tokens" ADD COLUMN "used_at" timestamp with time zone;
END IF;
END $$;
-- Step 5: Add foreign key constraint (IF NOT EXISTS for idempotency)
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'mobile_tokens_created_by_users_id_fk') THEN
ALTER TABLE "mobile_tokens" ADD CONSTRAINT "mobile_tokens_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION;
END IF;
END $$;
-- Step 6: Remove temporary default from expires_at
ALTER TABLE "mobile_tokens" ALTER COLUMN "expires_at" DROP DEFAULT;

View File

@@ -0,0 +1,20 @@
-- TimescaleDB hypertables with columnstore don't allow non-constant defaults like now()
-- So we add columns as nullable first, backfill, then set NOT NULL
-- Add last_seen_at as nullable first
ALTER TABLE "sessions" ADD COLUMN "last_seen_at" timestamp with time zone;--> statement-breakpoint
-- Backfill existing rows: use started_at as the initial last_seen_at value
UPDATE "sessions" SET "last_seen_at" = "started_at" WHERE "last_seen_at" IS NULL;--> statement-breakpoint
-- Now set NOT NULL constraint (no default needed - app always provides value)
ALTER TABLE "sessions" ALTER COLUMN "last_seen_at" SET NOT NULL;--> statement-breakpoint
-- Add force_stopped column
ALTER TABLE "sessions" ADD COLUMN "force_stopped" boolean DEFAULT false NOT NULL;--> statement-breakpoint
-- Add short_session column
ALTER TABLE "sessions" ADD COLUMN "short_session" boolean DEFAULT false NOT NULL;--> statement-breakpoint
-- Create index for stale session detection
CREATE INDEX "sessions_stale_detection_idx" ON "sessions" USING btree ("last_seen_at","stopped_at");

View File

@@ -0,0 +1,18 @@
-- Multi-server support: Add user_id to mobile_sessions
-- BREAKING CHANGE: Clears existing mobile sessions - users must re-pair devices
-- Clear existing data (notification_preferences has FK to mobile_sessions)
DELETE FROM "notification_preferences";--> statement-breakpoint
DELETE FROM "mobile_sessions";--> statement-breakpoint
-- Unrelated schema drift fix from drizzle-kit
ALTER TABLE "sessions" ALTER COLUMN "last_seen_at" DROP DEFAULT;--> statement-breakpoint
-- Add user_id column (required for multi-user mobile support)
ALTER TABLE "mobile_sessions" ADD COLUMN "user_id" uuid NOT NULL;--> statement-breakpoint
-- Add foreign key constraint
ALTER TABLE "mobile_sessions" ADD CONSTRAINT "mobile_sessions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
-- Add index for efficient user lookups
CREATE INDEX "mobile_sessions_user_idx" ON "mobile_sessions" USING btree ("user_id");

View File

@@ -0,0 +1,27 @@
-- Note: session_id has no FK constraint because sessions is a TimescaleDB hypertable
-- (hypertables don't support foreign key references to their primary key)
CREATE TABLE "termination_logs" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"session_id" uuid NOT NULL,
"server_id" uuid NOT NULL,
"server_user_id" uuid NOT NULL,
"trigger" varchar(20) NOT NULL,
"triggered_by_user_id" uuid,
"rule_id" uuid,
"violation_id" uuid,
"reason" text,
"success" boolean NOT NULL,
"error_message" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "termination_logs" ADD CONSTRAINT "termination_logs_server_id_servers_id_fk" FOREIGN KEY ("server_id") REFERENCES "public"."servers"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "termination_logs" ADD CONSTRAINT "termination_logs_server_user_id_server_users_id_fk" FOREIGN KEY ("server_user_id") REFERENCES "public"."server_users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "termination_logs" ADD CONSTRAINT "termination_logs_triggered_by_user_id_users_id_fk" FOREIGN KEY ("triggered_by_user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "termination_logs" ADD CONSTRAINT "termination_logs_rule_id_rules_id_fk" FOREIGN KEY ("rule_id") REFERENCES "public"."rules"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "termination_logs" ADD CONSTRAINT "termination_logs_violation_id_violations_id_fk" FOREIGN KEY ("violation_id") REFERENCES "public"."violations"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "termination_logs_session_idx" ON "termination_logs" USING btree ("session_id");--> statement-breakpoint
CREATE INDEX "termination_logs_server_user_idx" ON "termination_logs" USING btree ("server_user_id");--> statement-breakpoint
CREATE INDEX "termination_logs_triggered_by_idx" ON "termination_logs" USING btree ("triggered_by_user_id");--> statement-breakpoint
CREATE INDEX "termination_logs_rule_idx" ON "termination_logs" USING btree ("rule_id");--> statement-breakpoint
CREATE INDEX "termination_logs_created_at_idx" ON "termination_logs" USING btree ("created_at");

View File

@@ -0,0 +1 @@
ALTER TABLE "sessions" ADD COLUMN "plex_session_id" varchar(255);

View File

@@ -0,0 +1,3 @@
ALTER TABLE "termination_logs" DROP CONSTRAINT IF EXISTS "termination_logs_session_id_sessions_id_fk";
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "sessions_dedup_fallback_idx" ON "sessions" USING btree ("server_id","server_user_id","rating_key","started_at");

View File

@@ -0,0 +1,2 @@
ALTER TABLE "settings" ADD COLUMN "webhook_format" text;--> statement-breakpoint
ALTER TABLE "settings" ADD COLUMN "ntfy_topic" text;

View File

@@ -0,0 +1 @@
ALTER TABLE "servers" ADD COLUMN "machine_identifier" varchar(100);

View File

@@ -0,0 +1,4 @@
ALTER TABLE "settings" DROP COLUMN "notify_on_violation";--> statement-breakpoint
ALTER TABLE "settings" DROP COLUMN "notify_on_session_start";--> statement-breakpoint
ALTER TABLE "settings" DROP COLUMN "notify_on_session_stop";--> statement-breakpoint
ALTER TABLE "settings" DROP COLUMN "notify_on_server_down";

View File

@@ -0,0 +1 @@
ALTER TABLE "settings" ADD COLUMN "primary_auth_method" varchar(20) DEFAULT 'local' NOT NULL;

View File

@@ -0,0 +1,2 @@
ALTER TABLE "notification_channel_routing" ADD COLUMN "web_toast_enabled" boolean DEFAULT true NOT NULL;--> statement-breakpoint
ALTER TABLE "settings" ADD COLUMN "unit_system" varchar(20) DEFAULT 'metric' NOT NULL;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More