674 lines
19 KiB
TypeScript
674 lines
19 KiB
TypeScript
import { useFocusEffect } from '@react-navigation/native';
|
|
import { CameraView, useCameraPermissions } from 'expo-camera';
|
|
import { useKeepAwake } from 'expo-keep-awake';
|
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
|
import {
|
|
Alert,
|
|
Image,
|
|
Modal,
|
|
Pressable,
|
|
SafeAreaView,
|
|
ScrollView,
|
|
StyleSheet,
|
|
Text,
|
|
TextInput,
|
|
View,
|
|
} from 'react-native';
|
|
|
|
import { useApp } from '@/src/app-context';
|
|
|
|
function PromptModal({
|
|
visible,
|
|
title,
|
|
placeholder,
|
|
value,
|
|
onChange,
|
|
onCancel,
|
|
onConfirm,
|
|
}: {
|
|
visible: boolean;
|
|
title: string;
|
|
placeholder: string;
|
|
value: string;
|
|
onChange: (value: string) => void;
|
|
onCancel: () => void;
|
|
onConfirm: () => void;
|
|
}) {
|
|
return (
|
|
<Modal visible={visible} transparent animationType="fade" onRequestClose={onCancel}>
|
|
<View style={styles.modalBackdrop}>
|
|
<View style={styles.modalCard}>
|
|
<Text style={styles.modalTitle}>{title}</Text>
|
|
<TextInput
|
|
style={styles.modalInput}
|
|
placeholder={placeholder}
|
|
placeholderTextColor="#6b7280"
|
|
value={value}
|
|
onChangeText={onChange}
|
|
autoCapitalize="none"
|
|
/>
|
|
<View style={styles.modalActions}>
|
|
<Pressable style={[styles.modalButton, styles.modalCancel]} onPress={onCancel}>
|
|
<Text style={styles.modalCancelText}>Cancel</Text>
|
|
</Pressable>
|
|
<Pressable style={[styles.modalButton, styles.modalConfirm]} onPress={onConfirm}>
|
|
<Text style={styles.modalConfirmText}>Save</Text>
|
|
</Pressable>
|
|
</View>
|
|
</View>
|
|
</View>
|
|
</Modal>
|
|
);
|
|
}
|
|
|
|
function CameraDashboard() {
|
|
const { state, actions } = useApp();
|
|
const cameraViewRef = useRef<CameraView | null>(null);
|
|
const [permission, requestPermission] = useCameraPermissions();
|
|
|
|
// Prevent auto-lock while the camera dashboard is open in the foreground.
|
|
useKeepAwake('securecam-camera-dashboard');
|
|
|
|
useEffect(() => {
|
|
actions.setCameraPermissionGranted(Boolean(permission?.granted));
|
|
}, [actions, permission?.granted]);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
actions.setCameraRef(null);
|
|
actions.setCameraPreviewReady(false);
|
|
};
|
|
}, [actions]);
|
|
|
|
return (
|
|
<ScrollView contentContainerStyle={styles.scrollContent}>
|
|
<View style={styles.card}>
|
|
<Text style={styles.cardTitle}>Camera Preview</Text>
|
|
{!permission?.granted ? (
|
|
<View style={styles.permissionBox}>
|
|
<Text style={styles.permissionText}>Camera permission is required to publish live frames.</Text>
|
|
<Pressable style={styles.primaryButton} onPress={() => void requestPermission()}>
|
|
<Text style={styles.primaryButtonText}>Grant Camera Permission</Text>
|
|
</Pressable>
|
|
</View>
|
|
) : (
|
|
<View style={styles.previewBox}>
|
|
<CameraView
|
|
ref={(ref) => {
|
|
cameraViewRef.current = ref;
|
|
actions.setCameraRef(ref);
|
|
}}
|
|
style={styles.previewCamera}
|
|
facing="back"
|
|
onCameraReady={() => actions.setCameraPreviewReady(true)}
|
|
/>
|
|
</View>
|
|
)}
|
|
</View>
|
|
|
|
<View style={styles.card}>
|
|
<Text style={styles.cardTitle}>Camera Status</Text>
|
|
<View style={styles.rowBetween}>
|
|
<Text style={styles.label}>Realtime</Text>
|
|
<Text style={[styles.status, state.socketConnected ? styles.online : styles.offline]}>
|
|
{state.socketConnected ? 'ONLINE' : 'OFFLINE'}
|
|
</Text>
|
|
</View>
|
|
<View style={styles.rowBetween}>
|
|
<Text style={styles.label}>Motion</Text>
|
|
<Text style={[styles.status, state.isMotionActive ? styles.online : styles.offline]}>
|
|
{state.isMotionActive ? 'ACTIVE' : 'IDLE'}
|
|
</Text>
|
|
</View>
|
|
<View style={styles.rowBetween}>
|
|
<Text style={styles.label}>Preview</Text>
|
|
<Text style={[styles.status, state.cameraPreviewReady ? styles.online : styles.offline]}>
|
|
{state.cameraPreviewReady ? 'READY' : 'NOT READY'}
|
|
</Text>
|
|
</View>
|
|
<View style={styles.rowBetween}>
|
|
<Text style={styles.label}>Publishing</Text>
|
|
<Text style={[styles.status, state.cameraStatus === 'recording' ? styles.online : styles.offline]}>
|
|
{state.cameraStatus === 'recording' ? 'ACTIVE' : 'IDLE'}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
|
|
<View style={styles.card}>
|
|
<Text style={styles.cardTitle}>Actions</Text>
|
|
{!state.isMotionActive ? (
|
|
<Pressable style={styles.primaryButton} onPress={() => void actions.startMotion()}>
|
|
<Text style={styles.primaryButtonText}>Simulate Motion Event</Text>
|
|
</Pressable>
|
|
) : (
|
|
<Pressable style={styles.secondaryButton} onPress={() => void actions.endMotion()}>
|
|
<Text style={styles.secondaryButtonText}>Stop Motion Event</Text>
|
|
</Pressable>
|
|
)}
|
|
|
|
<Pressable style={styles.secondaryButton} onPress={() => void actions.goOnline()}>
|
|
<Text style={styles.secondaryButtonText}>Reconnect Realtime</Text>
|
|
</Pressable>
|
|
|
|
<Text style={styles.infoText}>
|
|
This mobile build remains on the legacy frame-relay path. Add a native WebRTC stack before enabling
|
|
SIMPLE_STREAMING by default.
|
|
</Text>
|
|
</View>
|
|
|
|
<View style={styles.card}>
|
|
<Text style={styles.cardTitle}>Logs</Text>
|
|
{state.activityLog.length === 0 ? (
|
|
<Text style={styles.emptyText}>Awaiting events...</Text>
|
|
) : (
|
|
state.activityLog.map((item) => (
|
|
<Text key={item.id} style={styles.logLine}>
|
|
[{new Date(item.createdAt).toLocaleTimeString()}] {item.type}: {item.message}
|
|
</Text>
|
|
))
|
|
)}
|
|
</View>
|
|
</ScrollView>
|
|
);
|
|
}
|
|
|
|
function ClientDashboard() {
|
|
const { state, actions } = useApp();
|
|
const [linkModalOpen, setLinkModalOpen] = useState(false);
|
|
const [cameraIdInput, setCameraIdInput] = useState('');
|
|
const [renameTargetId, setRenameTargetId] = useState<string | null>(null);
|
|
const [renameInput, setRenameInput] = useState('');
|
|
|
|
const activeCameraLabel = useMemo(() => {
|
|
const linked = state.linkedCameras.find((camera) => camera.cameraDeviceId === state.activeCameraDeviceId);
|
|
return linked?.cameraName || linked?.cameraDeviceId || 'Live Feed Viewer';
|
|
}, [state.activeCameraDeviceId, state.linkedCameras]);
|
|
|
|
const isCameraLive = (cameraDeviceId: string) => {
|
|
const sessionId = state.cameraSessions?.[cameraDeviceId];
|
|
if (!sessionId) return false;
|
|
return state.connectedStreamSessionIds.includes(sessionId);
|
|
};
|
|
|
|
const openRenameModal = (cameraDeviceId: string, currentName?: string | null) => {
|
|
setRenameTargetId(cameraDeviceId);
|
|
setRenameInput(currentName || '');
|
|
};
|
|
|
|
const submitRename = async () => {
|
|
if (!renameTargetId) return;
|
|
await actions.renameLinkedCamera(renameTargetId, renameInput);
|
|
setRenameTargetId(null);
|
|
setRenameInput('');
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<ScrollView contentContainerStyle={styles.scrollContent}>
|
|
<View style={styles.rowBetween}>
|
|
<Text style={styles.screenTitle}>Client Dashboard</Text>
|
|
<View style={styles.rowGap}>
|
|
<Pressable style={styles.outlineButton} onPress={() => setLinkModalOpen(true)}>
|
|
<Text style={styles.outlineButtonText}>Link Camera</Text>
|
|
</Pressable>
|
|
<Pressable style={styles.iconButton} onPress={() => void actions.refreshClientData()}>
|
|
<Text style={styles.iconButtonText}>R</Text>
|
|
</Pressable>
|
|
</View>
|
|
</View>
|
|
|
|
<View style={styles.card}>
|
|
<Text style={styles.cardTitle}>Your Cameras</Text>
|
|
<ScrollView horizontal contentContainerStyle={styles.cameraRow} showsHorizontalScrollIndicator={false}>
|
|
{state.linkedCameras.length === 0 ? (
|
|
<View style={styles.emptyCameraCard}>
|
|
<Text style={styles.emptyText}>No cameras linked yet</Text>
|
|
</View>
|
|
) : (
|
|
state.linkedCameras.map((link) => {
|
|
const selected = state.activeCameraDeviceId === link.cameraDeviceId;
|
|
const live = isCameraLive(link.cameraDeviceId);
|
|
return (
|
|
<View
|
|
key={link.id}
|
|
style={[styles.cameraCard, selected ? styles.cameraCardSelected : null]}>
|
|
<Text style={styles.cameraName}>{link.cameraName || link.cameraDeviceId}</Text>
|
|
<Text style={[styles.cameraStatus, link.cameraStatus === 'online' ? styles.online : styles.offline]}>
|
|
{(link.cameraStatus || 'offline').toUpperCase()}
|
|
</Text>
|
|
<Text style={styles.cameraSubtext}>{live ? 'Live stream active' : 'Tap view to request stream'}</Text>
|
|
|
|
<View style={styles.cameraActionsRow}>
|
|
<Pressable style={styles.smallButton} onPress={() => void actions.selectCamera(link.cameraDeviceId)}>
|
|
<Text style={styles.smallButtonText}>View</Text>
|
|
</Pressable>
|
|
<Pressable
|
|
style={styles.smallButton}
|
|
onPress={() => openRenameModal(link.cameraDeviceId, link.cameraName)}>
|
|
<Text style={styles.smallButtonText}>Rename</Text>
|
|
</Pressable>
|
|
<Pressable
|
|
style={[styles.smallButton, styles.smallDangerButton]}
|
|
onPress={() => {
|
|
Alert.alert(
|
|
'Remove Camera',
|
|
`Remove \"${link.cameraName || link.cameraDeviceId}\" from linked cameras?`,
|
|
[
|
|
{ text: 'Cancel', style: 'cancel' },
|
|
{
|
|
text: 'Delete',
|
|
style: 'destructive',
|
|
onPress: () => {
|
|
void actions.deleteLinkedCamera(link.id);
|
|
},
|
|
},
|
|
],
|
|
);
|
|
}}>
|
|
<Text style={[styles.smallButtonText, styles.smallDangerText]}>Delete</Text>
|
|
</Pressable>
|
|
</View>
|
|
</View>
|
|
);
|
|
})
|
|
)}
|
|
</ScrollView>
|
|
</View>
|
|
|
|
{state.activeCameraDeviceId ? (
|
|
<View style={styles.card}>
|
|
<View style={styles.rowBetween}>
|
|
<Text style={styles.cardTitle}>Live Feed: {activeCameraLabel}</Text>
|
|
<Pressable style={styles.iconButton} onPress={actions.closeStreamViewer}>
|
|
<Text style={styles.iconButtonText}>X</Text>
|
|
</Pressable>
|
|
</View>
|
|
|
|
<View style={styles.streamBox}>
|
|
{state.clientStreamMode === 'image' && state.clientFallbackFrame ? (
|
|
<Image source={{ uri: state.clientFallbackFrame }} style={styles.streamImage} resizeMode="contain" />
|
|
) : (
|
|
<Text style={styles.streamPlaceholder}>{state.clientPlaceholderText}</Text>
|
|
)}
|
|
</View>
|
|
</View>
|
|
) : null}
|
|
|
|
<View style={styles.card}>
|
|
<Text style={styles.cardTitle}>Recent Recordings</Text>
|
|
{state.recordings.length === 0 ? (
|
|
<Text style={styles.emptyText}>No recordings found</Text>
|
|
) : (
|
|
state.recordings.slice(0, 5).map((recording) => (
|
|
<View key={recording.id} style={styles.recordingRow}>
|
|
<View style={{ flex: 1 }}>
|
|
<Text style={styles.recordingTitle}>{new Date(recording.createdAt).toLocaleString()}</Text>
|
|
<Text style={styles.recordingMeta}>
|
|
{recording.durationSeconds != null ? `${recording.durationSeconds}s` : 'Duration pending'} -{' '}
|
|
{recording.status ?? 'unknown'}
|
|
</Text>
|
|
</View>
|
|
<Pressable
|
|
style={styles.smallButton}
|
|
disabled={recording.status !== 'ready'}
|
|
onPress={() => void actions.openRecording(recording.id)}>
|
|
<Text style={styles.smallButtonText}>Open</Text>
|
|
</Pressable>
|
|
</View>
|
|
))
|
|
)}
|
|
</View>
|
|
</ScrollView>
|
|
|
|
<PromptModal
|
|
visible={linkModalOpen}
|
|
title="Link Camera"
|
|
placeholder="Camera Device ID"
|
|
value={cameraIdInput}
|
|
onChange={setCameraIdInput}
|
|
onCancel={() => {
|
|
setLinkModalOpen(false);
|
|
setCameraIdInput('');
|
|
}}
|
|
onConfirm={() => {
|
|
void actions.linkCamera(cameraIdInput);
|
|
setLinkModalOpen(false);
|
|
setCameraIdInput('');
|
|
}}
|
|
/>
|
|
|
|
<PromptModal
|
|
visible={Boolean(renameTargetId)}
|
|
title="Rename Camera"
|
|
placeholder="Camera name"
|
|
value={renameInput}
|
|
onChange={setRenameInput}
|
|
onCancel={() => {
|
|
setRenameTargetId(null);
|
|
setRenameInput('');
|
|
}}
|
|
onConfirm={() => {
|
|
void submitRename();
|
|
}}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|
|
export default function DashboardScreen() {
|
|
const { state, actions } = useApp();
|
|
|
|
useFocusEffect(
|
|
React.useCallback(() => {
|
|
actions.setPage(state.device?.role === 'camera' ? 'camera' : 'client');
|
|
return undefined;
|
|
}, [actions, state.device?.role]),
|
|
);
|
|
|
|
return <SafeAreaView style={styles.safe}>{state.device?.role === 'camera' ? <CameraDashboard /> : <ClientDashboard />}</SafeAreaView>;
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
safe: {
|
|
flex: 1,
|
|
backgroundColor: '#0a0a0c',
|
|
},
|
|
scrollContent: {
|
|
padding: 14,
|
|
gap: 12,
|
|
},
|
|
screenTitle: {
|
|
color: '#f9fafb',
|
|
fontSize: 22,
|
|
fontWeight: '700',
|
|
},
|
|
rowBetween: {
|
|
flexDirection: 'row',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center',
|
|
gap: 10,
|
|
},
|
|
rowGap: {
|
|
flexDirection: 'row',
|
|
gap: 8,
|
|
alignItems: 'center',
|
|
},
|
|
card: {
|
|
borderRadius: 16,
|
|
padding: 14,
|
|
borderWidth: 1,
|
|
borderColor: 'rgba(255,255,255,0.08)',
|
|
backgroundColor: '#111218',
|
|
gap: 10,
|
|
},
|
|
cardTitle: {
|
|
color: '#f9fafb',
|
|
fontSize: 15,
|
|
fontWeight: '600',
|
|
},
|
|
previewBox: {
|
|
height: 220,
|
|
borderRadius: 12,
|
|
overflow: 'hidden',
|
|
borderWidth: 1,
|
|
borderColor: 'rgba(255,255,255,0.1)',
|
|
backgroundColor: '#08090d',
|
|
},
|
|
previewCamera: {
|
|
flex: 1,
|
|
},
|
|
permissionBox: {
|
|
borderRadius: 12,
|
|
borderWidth: 1,
|
|
borderColor: 'rgba(255,255,255,0.1)',
|
|
backgroundColor: '#08090d',
|
|
padding: 12,
|
|
gap: 10,
|
|
},
|
|
permissionText: {
|
|
color: '#9ca3af',
|
|
fontSize: 12,
|
|
lineHeight: 18,
|
|
},
|
|
label: {
|
|
color: '#9ca3af',
|
|
fontSize: 12,
|
|
},
|
|
status: {
|
|
fontSize: 12,
|
|
fontWeight: '700',
|
|
},
|
|
online: {
|
|
color: '#34d399',
|
|
},
|
|
offline: {
|
|
color: '#f87171',
|
|
},
|
|
primaryButton: {
|
|
height: 44,
|
|
borderRadius: 10,
|
|
backgroundColor: '#2563eb',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
},
|
|
primaryButtonText: {
|
|
color: '#f9fafb',
|
|
fontWeight: '700',
|
|
fontSize: 14,
|
|
},
|
|
secondaryButton: {
|
|
height: 44,
|
|
borderRadius: 10,
|
|
borderWidth: 1,
|
|
borderColor: 'rgba(255,255,255,0.12)',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
},
|
|
secondaryButtonText: {
|
|
color: '#d1d5db',
|
|
fontWeight: '600',
|
|
fontSize: 13,
|
|
},
|
|
outlineButton: {
|
|
height: 36,
|
|
borderRadius: 9,
|
|
paddingHorizontal: 12,
|
|
borderWidth: 1,
|
|
borderColor: 'rgba(255,255,255,0.18)',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
},
|
|
outlineButtonText: {
|
|
color: '#d1d5db',
|
|
fontSize: 12,
|
|
fontWeight: '600',
|
|
},
|
|
iconButton: {
|
|
height: 36,
|
|
width: 36,
|
|
borderRadius: 9,
|
|
borderWidth: 1,
|
|
borderColor: 'rgba(255,255,255,0.18)',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
},
|
|
iconButtonText: {
|
|
color: '#d1d5db',
|
|
fontSize: 12,
|
|
fontWeight: '700',
|
|
},
|
|
infoText: {
|
|
color: '#9ca3af',
|
|
fontSize: 12,
|
|
lineHeight: 18,
|
|
},
|
|
emptyText: {
|
|
color: '#6b7280',
|
|
fontSize: 12,
|
|
},
|
|
logLine: {
|
|
color: '#9ca3af',
|
|
fontSize: 12,
|
|
lineHeight: 18,
|
|
},
|
|
cameraRow: {
|
|
gap: 10,
|
|
},
|
|
emptyCameraCard: {
|
|
borderRadius: 12,
|
|
borderWidth: 1,
|
|
borderStyle: 'dashed',
|
|
borderColor: 'rgba(255,255,255,0.2)',
|
|
padding: 16,
|
|
minWidth: 240,
|
|
},
|
|
cameraCard: {
|
|
minWidth: 260,
|
|
borderRadius: 12,
|
|
borderWidth: 1,
|
|
borderColor: 'rgba(255,255,255,0.08)',
|
|
padding: 12,
|
|
backgroundColor: '#0d0f15',
|
|
gap: 6,
|
|
},
|
|
cameraCardSelected: {
|
|
borderColor: '#3b82f6',
|
|
backgroundColor: '#111b2f',
|
|
},
|
|
cameraName: {
|
|
color: '#f3f4f6',
|
|
fontWeight: '600',
|
|
fontSize: 13,
|
|
},
|
|
cameraStatus: {
|
|
fontSize: 11,
|
|
fontWeight: '700',
|
|
},
|
|
cameraSubtext: {
|
|
color: '#9ca3af',
|
|
fontSize: 11,
|
|
marginBottom: 4,
|
|
},
|
|
cameraActionsRow: {
|
|
flexDirection: 'row',
|
|
gap: 6,
|
|
},
|
|
smallButton: {
|
|
borderRadius: 8,
|
|
borderWidth: 1,
|
|
borderColor: 'rgba(255,255,255,0.16)',
|
|
paddingHorizontal: 10,
|
|
height: 30,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
},
|
|
smallButtonText: {
|
|
color: '#d1d5db',
|
|
fontSize: 11,
|
|
fontWeight: '600',
|
|
},
|
|
smallDangerButton: {
|
|
borderColor: 'rgba(248,113,113,0.4)',
|
|
},
|
|
smallDangerText: {
|
|
color: '#fca5a5',
|
|
},
|
|
streamBox: {
|
|
height: 220,
|
|
borderRadius: 12,
|
|
borderWidth: 1,
|
|
borderColor: 'rgba(255,255,255,0.1)',
|
|
backgroundColor: '#08090d',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
overflow: 'hidden',
|
|
},
|
|
streamImage: {
|
|
width: '100%',
|
|
height: '100%',
|
|
},
|
|
streamPlaceholder: {
|
|
color: '#6b7280',
|
|
fontWeight: '600',
|
|
},
|
|
recordingRow: {
|
|
borderRadius: 10,
|
|
borderWidth: 1,
|
|
borderColor: 'rgba(255,255,255,0.08)',
|
|
backgroundColor: '#0d0f15',
|
|
padding: 10,
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: 8,
|
|
},
|
|
recordingTitle: {
|
|
color: '#e5e7eb',
|
|
fontSize: 12,
|
|
fontWeight: '500',
|
|
},
|
|
recordingMeta: {
|
|
color: '#6b7280',
|
|
fontSize: 11,
|
|
marginTop: 2,
|
|
},
|
|
modalBackdrop: {
|
|
flex: 1,
|
|
backgroundColor: 'rgba(0,0,0,0.7)',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
padding: 16,
|
|
},
|
|
modalCard: {
|
|
width: '100%',
|
|
maxWidth: 360,
|
|
borderRadius: 14,
|
|
padding: 14,
|
|
backgroundColor: '#111218',
|
|
borderWidth: 1,
|
|
borderColor: 'rgba(255,255,255,0.1)',
|
|
gap: 10,
|
|
},
|
|
modalTitle: {
|
|
color: '#f3f4f6',
|
|
fontSize: 16,
|
|
fontWeight: '700',
|
|
},
|
|
modalInput: {
|
|
height: 46,
|
|
borderRadius: 10,
|
|
borderWidth: 1,
|
|
borderColor: 'rgba(255,255,255,0.14)',
|
|
backgroundColor: '#0a0a0e',
|
|
color: '#f3f4f6',
|
|
paddingHorizontal: 10,
|
|
},
|
|
modalActions: {
|
|
flexDirection: 'row',
|
|
justifyContent: 'flex-end',
|
|
gap: 8,
|
|
},
|
|
modalButton: {
|
|
height: 36,
|
|
borderRadius: 8,
|
|
minWidth: 76,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
paddingHorizontal: 10,
|
|
},
|
|
modalCancel: {
|
|
borderWidth: 1,
|
|
borderColor: 'rgba(255,255,255,0.16)',
|
|
},
|
|
modalConfirm: {
|
|
backgroundColor: '#2563eb',
|
|
},
|
|
modalCancelText: {
|
|
color: '#d1d5db',
|
|
fontWeight: '600',
|
|
},
|
|
modalConfirmText: {
|
|
color: '#f9fafb',
|
|
fontWeight: '700',
|
|
},
|
|
});
|