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

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

View File

@@ -0,0 +1,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>
);
}