diff --git a/README.md b/README.md
index 48dd63f..76b1807 100644
--- a/README.md
+++ b/README.md
@@ -1,50 +1,200 @@
-# Welcome to your Expo app 👋
+# UpSnap Mobile
-This is an [Expo](https://expo.dev) project created with [`create-expo-app`](https://www.npmjs.com/package/create-expo-app).
+A React Native Expo app that connects to an UpSnap server and provides mobile access to all Wake-on-LAN features.
-## Get started
+## Features
-1. Install dependencies
+- 🔌 **Device Management**: View, add, edit, and delete devices
+- 💤 **Power Control**: Wake, sleep, reboot, and shutdown devices remotely
+- 🔍 **Network Scanning**: Discover devices on your local network
+- 👤 **Authentication**: Secure login with username/email and password
+- 📱 **Mobile-First UI**: Touch-optimized interface designed for phones
+- 🎨 **Clean Design**: Modern, intuitive interface with visual status indicators
- ```bash
- npm install
- ```
+## Setup
-2. Start the app
+### Prerequisites
- ```bash
- npx expo start
- ```
+- Node.js installed
+- Expo CLI installed (`npm install -g expo-cli`)
+- An UpSnap server instance running (e.g., https://wol.f6knight.duckdns.org/)
-In the output, you'll find options to open the app in a
-
-- [development build](https://docs.expo.dev/develop/development-builds/introduction/)
-- [Android emulator](https://docs.expo.dev/workflow/android-studio-emulator/)
-- [iOS simulator](https://docs.expo.dev/workflow/ios-simulator/)
-- [Expo Go](https://expo.dev/go), a limited sandbox for trying out app development with Expo
-
-You can start developing by editing the files inside the **app** directory. This project uses [file-based routing](https://docs.expo.dev/router/introduction).
-
-## Get a fresh project
-
-When you're ready, run:
+### Installation
+1. Install dependencies:
```bash
-npm run reset-project
+npm install
```
-This command will move the starter code to the **app-example** directory and create a blank **app** directory where you can start developing.
+2. Start the development server:
+```bash
+npm start
+```
-## Learn more
+3. Run on your preferred platform:
+ - iOS: Press `i` in the terminal or run `npm run ios`
+ - Android: Press `a` in the terminal or run `npm run android`
+ - Web: Press `w` in the terminal or run `npm run web`
-To learn more about developing your project with Expo, look at the following resources:
+## Usage
-- [Expo documentation](https://docs.expo.dev/): Learn fundamentals, or go into advanced topics with our [guides](https://docs.expo.dev/guides).
-- [Learn Expo tutorial](https://docs.expo.dev/tutorial/introduction/): Follow a step-by-step tutorial where you'll create a project that runs on Android, iOS, and the web.
+### First Time Setup
-## Join the community
+1. Open the app
+2. Enter your UpSnap server credentials:
+ - Username or Email
+ - Password
+ - Check "Login as Admin" if you're an admin user
+3. Tap "Login"
-Join our community of developers creating universal apps.
+### Device Management
-- [Expo on GitHub](https://github.com/expo/expo): View our open source platform and contribute.
-- [Discord community](https://chat.expo.dev): Chat with Expo users and ask questions.
+#### Adding a Device
+
+1. Tap the "+ Add" button in the Devices screen
+2. Fill in the required fields:
+ - Device Name (e.g., "My PC")
+ - MAC Address (format: XX:XX:XX:XX:XX:XX)
+ - IP Address
+ - Optional: Netmask, Broadcast Address, SecureOn Password, Port
+3. Tap "Add Device"
+
+#### Scanning for Devices
+
+1. Tap the "Scan" button in the Devices screen
+2. Wait for the network scan to complete
+3. Tap on a discovered device to add it to your list
+
+#### Managing Devices
+
+- **View Devices**: Scroll through the list on the main screen
+- **Device Details**: Tap on a device card to view and edit details
+- **Wake Device**: Tap the green "Wake" button on a device card
+- **Sleep Device**: Tap the orange "Sleep" button
+- **Reboot Device**: Tap the blue "Reboot" button
+- **Shutdown Device**: Tap the red "Shutdown" button
+
+### Status Indicators
+
+- 🟢 Green dot: Device is online
+- 🔴 Red dot: Device is offline
+- 🟠 Orange dot: Status unknown
+
+## Screens
+
+### Login Screen
+- Username/email and password fields
+- Admin login option
+- Persistent authentication using AsyncStorage
+
+### Devices Screen
+- List of all devices with status indicators
+- Quick action buttons for each device
+- Pull-to-refresh for updating status
+- Add device and scan network buttons
+
+### Device Details Screen
+- View all device information
+- Edit device details
+- Delete device option
+
+### Add Device Screen
+- Manual device entry form
+- All required and optional fields
+- Input validation
+
+### Scan Devices Screen
+- Network scanning functionality
+- List of discovered devices
+- Quick add to device list
+
+### Settings Screen
+- User information display
+- Server configuration
+- Logout option
+- Clear data option
+
+## API Integration
+
+The app connects to an UpSnap server via its REST API:
+
+- **Authentication**: `/api/collections/users/auth-with-password` or `/api/collections/_superusers/auth-with-password`
+- **Devices**: `/api/collections/devices/records`
+- **Wake**: `/api/upsnap/wake/:id`
+- **Sleep**: `/api/collections/upsnap/sleep/:id`
+- **Reboot**: `/api/upsnap/reboot/:id`
+- **Shutdown**: `/api/upsnap/shutdown/:id`
+- **Scan**: `/api/upsnap/scan`
+
+## Project Structure
+
+```
+src/
+├── components/ # Reusable components
+├── context/ # React context providers (AuthContext)
+├── navigation/ # Navigation setup
+├── screens/ # All app screens
+│ ├── LoginScreen.tsx
+│ ├── DeviceListScreen.tsx
+│ ├── DeviceDetailsScreen.tsx
+│ ├── AddDeviceScreen.tsx
+│ ├── ScanDevicesScreen.tsx
+│ └── SettingsScreen.tsx
+├── services/ # API service
+│ └── api.ts
+└── types/ # TypeScript type definitions
+ └── index.ts
+```
+
+## Tech Stack
+
+- **React Native**: Cross-platform mobile framework
+- **Expo**: Development platform
+- **TypeScript**: Type safety
+- **React Navigation**: Navigation library
+- **AsyncStorage**: Local data persistence
+
+## Server Configuration
+
+The app is pre-configured to connect to:
+- Server URL: `https://wol.f6knight.duckdns.org`
+- API Base: `/api`
+
+To change the server URL, modify the `API_BASE_URL` constant in `src/services/api.ts`.
+
+## Security Notes
+
+- Authentication tokens are stored securely in AsyncStorage
+- All API calls require authentication
+- Destructive actions (sleep, reboot, shutdown) require confirmation
+- Never expose your UpSnap server to the open web without proper security measures
+
+## Troubleshooting
+
+### Login Issues
+
+- Verify your username/email and password are correct
+- Check if you need to log in as admin
+- Ensure your UpSnap server is accessible
+
+### Network Scan Issues
+
+- Network scanning requires the server to have `nmap` installed
+- Scanning may take several minutes to complete
+- Make sure your device and server are on the same network
+
+### Device Control Issues
+
+- Verify the device's MAC address is correct
+- Ensure Wake-on-LAN is enabled in the device's BIOS/UEFI
+- Check that the device is on the same network as the server
+- For sleep/shutdown/reboot, ensure the device has the required agent installed
+
+## License
+
+This project is a mobile companion to UpSnap, which is licensed under the MIT License.
+
+## Credits
+
+- UpSnap: https://github.com/seriousm4x/UpSnap
+- Built with React Native and Expo
diff --git a/app.json b/app.json
index e1c9193..fca5712 100644
--- a/app.json
+++ b/app.json
@@ -9,7 +9,8 @@
"userInterfaceStyle": "automatic",
"newArchEnabled": true,
"ios": {
- "supportsTablet": true
+ "supportsTablet": true,
+ "bundleIdentifier": "com.anonymous.remote-wol"
},
"android": {
"adaptiveIcon": {
diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx
index 54e11d0..4ee0119 100644
--- a/app/(tabs)/_layout.tsx
+++ b/app/(tabs)/_layout.tsx
@@ -1,35 +1,45 @@
-import { Tabs } from 'expo-router';
-import React from 'react';
+import React from "react";
+import { Ionicons } from "@expo/vector-icons";
+import { useRouter } from "expo-router";
+import { View, TouchableOpacity, StyleSheet } from "react-native";
+import { NativeTabs, Icon, Label } from "expo-router/unstable-native-tabs";
-import { HapticTab } from '@/components/haptic-tab';
-import { IconSymbol } from '@/components/ui/icon-symbol';
-import { Colors } from '@/constants/theme';
-import { useColorScheme } from '@/hooks/use-color-scheme';
+export function DevicesHeader() {
+ const router = useRouter();
-export default function TabLayout() {
- const colorScheme = useColorScheme();
-
- return (
-
- ,
- }}
- />
- ,
- }}
- />
-
- );
+ return (
+
+ router.push("/scan-devices")}
+ style={styles.headerButton}
+ >
+
+
+
+ );
}
+
+export default function TabsLayout() {
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ headerRight: {
+ flexDirection: "row",
+ gap: 16,
+ },
+ headerButton: {
+ paddingHorizontal: 8,
+ },
+});
diff --git a/app/(tabs)/explore.tsx b/app/(tabs)/explore.tsx
deleted file mode 100644
index 71518f9..0000000
--- a/app/(tabs)/explore.tsx
+++ /dev/null
@@ -1,112 +0,0 @@
-import { Image } from 'expo-image';
-import { Platform, StyleSheet } from 'react-native';
-
-import { Collapsible } from '@/components/ui/collapsible';
-import { ExternalLink } from '@/components/external-link';
-import ParallaxScrollView from '@/components/parallax-scroll-view';
-import { ThemedText } from '@/components/themed-text';
-import { ThemedView } from '@/components/themed-view';
-import { IconSymbol } from '@/components/ui/icon-symbol';
-import { Fonts } from '@/constants/theme';
-
-export default function TabTwoScreen() {
- return (
-
- }>
-
-
- Explore
-
-
- This app includes example code to help you get started.
-
-
- This app has two screens:{' '}
- app/(tabs)/index.tsx and{' '}
- app/(tabs)/explore.tsx
-
-
- The layout file in app/(tabs)/_layout.tsx{' '}
- sets up the tab navigator.
-
-
- Learn more
-
-
-
-
- You can open this project on Android, iOS, and the web. To open the web version, press{' '}
- w in the terminal running this project.
-
-
-
-
- For static images, you can use the @2x and{' '}
- @3x suffixes to provide files for
- different screen densities
-
-
-
- Learn more
-
-
-
-
- This template has light and dark mode support. The{' '}
- useColorScheme() hook lets you inspect
- what the user's current color scheme is, and so you can adjust UI colors accordingly.
-
-
- Learn more
-
-
-
-
- This template includes an example of an animated component. The{' '}
- components/HelloWave.tsx component uses
- the powerful{' '}
-
- react-native-reanimated
- {' '}
- library to create a waving hand animation.
-
- {Platform.select({
- ios: (
-
- The components/ParallaxScrollView.tsx{' '}
- component provides a parallax effect for the header image.
-
- ),
- })}
-
-
- );
-}
-
-const styles = StyleSheet.create({
- headerImage: {
- color: '#808080',
- bottom: -90,
- left: -35,
- position: 'absolute',
- },
- titleContainer: {
- flexDirection: 'row',
- gap: 8,
- },
-});
diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx
index 786b736..222a990 100644
--- a/app/(tabs)/index.tsx
+++ b/app/(tabs)/index.tsx
@@ -1,98 +1,346 @@
-import { Image } from 'expo-image';
-import { Platform, StyleSheet } from 'react-native';
+import React, { useState, useEffect } from "react";
+import {
+ View,
+ Text,
+ FlatList,
+ TouchableOpacity,
+ StyleSheet,
+ RefreshControl,
+ Alert,
+ ActivityIndicator,
+} from "react-native";
+import { ContextMenu, Button as SwiftUIButton, Host } from "@expo/ui/swift-ui";
+import { useRouter, useLocalSearchParams, Stack } from "expo-router";
+import { Ionicons } from "@expo/vector-icons";
+import api from "../../src/services/api";
+import { Device } from "../../src/types";
-import { HelloWave } from '@/components/hello-wave';
-import ParallaxScrollView from '@/components/parallax-scroll-view';
-import { ThemedText } from '@/components/themed-text';
-import { ThemedView } from '@/components/themed-view';
-import { Link } from 'expo-router';
+function DevicesHeader() {
+ const router = useRouter();
-export default function HomeScreen() {
- return (
-
- }>
-
- Welcome!
-
-
-
- Step 1: Try it
-
- Edit app/(tabs)/index.tsx to see changes.
- Press{' '}
-
- {Platform.select({
- ios: 'cmd + d',
- android: 'cmd + m',
- web: 'F12',
- })}
- {' '}
- to open developer tools.
-
-
-
-
-
- Step 2: Explore
-
-
-
- alert('Action pressed')} />
- alert('Share pressed')}
- />
-
- alert('Delete pressed')}
- />
-
-
-
+ return (
+
+ router.push("/scan-devices")}>
+
+
+
+ );
+}
-
- {`Tap the Explore tab to learn more about what's included in this starter app.`}
-
-
-
- Step 3: Get a fresh start
-
- {`When you're ready, run `}
- npm run reset-project to get a fresh{' '}
- app directory. This will move the current{' '}
- app to{' '}
- app-example.
-
-
-
- );
+export default function DeviceListScreen() {
+ const router = useRouter();
+ const [devices, setDevices] = useState([]);
+ const [isLoading, setIsLoading] = useState(true);
+ const [refreshing, setRefreshing] = useState(false);
+
+ useEffect(() => {
+ loadDevices();
+ }, []);
+
+ const loadDevices = async () => {
+ try {
+ setIsLoading(true);
+ const data = await api.getDevices();
+ setDevices(data);
+ } catch (error: any) {
+ Alert.alert("Error", error.message || "Failed to load devices");
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ const onRefresh = async () => {
+ setRefreshing(true);
+ await loadDevices();
+ setRefreshing(false);
+ };
+
+ const handleWake = async (device: Device) => {
+ try {
+ await api.wakeDevice(device.id);
+ Alert.alert("Success", `Wake signal sent to ${device.name}`);
+ } catch (error: any) {
+ Alert.alert("Error", error.message || "Failed to wake device");
+ }
+ };
+
+ const handleSleep = async (device: Device) => {
+ Alert.alert("Confirm", `Send ${device.name} to sleep?`, [
+ { text: "Cancel", style: "cancel" },
+ {
+ text: "Sleep",
+ style: "destructive",
+ onPress: async () => {
+ try {
+ await api.sleepDevice(device.id);
+ Alert.alert("Success", `Sleep signal sent to ${device.name}`);
+ } catch (error: any) {
+ Alert.alert("Error", error.message || "Failed to sleep device");
+ }
+ },
+ },
+ ]);
+ };
+
+ const handleReboot = async (device: Device) => {
+ Alert.alert("Confirm", `Reboot ${device.name}?`, [
+ { text: "Cancel", style: "cancel" },
+ {
+ text: "Reboot",
+ style: "destructive",
+ onPress: async () => {
+ try {
+ await api.rebootDevice(device.id);
+ Alert.alert("Success", `Reboot signal sent to ${device.name}`);
+ } catch (error: any) {
+ Alert.alert("Error", error.message || "Failed to reboot device");
+ }
+ },
+ },
+ ]);
+ };
+
+ const handleShutdown = async (device: Device) => {
+ Alert.alert(
+ "Confirm Shutdown",
+ `Shutdown ${device.name}? This cannot be undone.`,
+ [
+ { text: "Cancel", style: "cancel" },
+ {
+ text: "Shutdown",
+ style: "destructive",
+ onPress: async () => {
+ try {
+ await api.shutdownDevice(device.id);
+ Alert.alert("Success", `Shutdown signal sent to ${device.name}`);
+ } catch (error: any) {
+ Alert.alert(
+ "Error",
+ error.message || "Failed to shutdown device"
+ );
+ }
+ },
+ },
+ ]
+ );
+ };
+
+ const handleDelete = (device: Device) => {
+ Alert.alert("Delete Device", `Delete "${device.name}"?`, [
+ {
+ text: "Delete",
+ style: "destructive",
+ onPress: async () => {
+ try {
+ await api.deleteDevice(device.id);
+ Alert.alert("Success", "Device deleted successfully");
+ loadDevices();
+ } catch (error: any) {
+ Alert.alert("Error", error.message || "Failed to delete device");
+ }
+ },
+ },
+ ]);
+ };
+
+ const getStatusColor = (status: string) => {
+ switch (status?.toLowerCase()) {
+ case "online":
+ return "#4CAF50";
+ case "offline":
+ return "#f44336";
+ default:
+ return "#ff9800";
+ }
+ };
+
+ const renderDevice = ({ item }: { item: Device }) => (
+
+
+
+ handleDelete(item)}
+ >
+ Delete Device
+
+
+
+
+ router.push(`/devices/${item.id}`)}
+ activeOpacity={1}
+ >
+
+
+ {item.name}
+ {item.ip}
+
+
+
+
+
+ handleWake(item)}
+ >
+ Wake
+
+ handleSleep(item)}
+ >
+ Sleep
+
+ handleReboot(item)}
+ >
+ Reboot
+
+ handleShutdown(item)}
+ >
+ Shutdown
+
+
+
+
+
+
+
+ );
+
+ if (isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+ ,
+ }}
+ />
+ item.id}
+ contentContainerStyle={styles.list}
+ refreshControl={
+
+ }
+ ListEmptyComponent={
+
+ No devices found
+
+ }
+ />
+
+ );
}
const styles = StyleSheet.create({
- titleContainer: {
- flexDirection: 'row',
- alignItems: 'center',
- gap: 8,
- },
- stepContainer: {
- gap: 8,
- marginBottom: 8,
- },
- reactLogo: {
- height: 178,
- width: 290,
- bottom: 0,
- left: 0,
- position: 'absolute',
- },
+ container: {
+ flex: 1,
+ backgroundColor: "#f5f5f5",
+ },
+ headerRight: {
+ flexDirection: "row",
+ gap: 16,
+ },
+ headerButton: {
+ paddingHorizontal: 8,
+ },
+ loadingContainer: {
+ flex: 1,
+ justifyContent: "center",
+ alignItems: "center",
+ },
+ list: {
+ padding: 15,
+ },
+ deviceCard: {
+ backgroundColor: "#fff",
+ borderRadius: 12,
+ padding: 15,
+ marginBottom: 15,
+ shadowColor: "#000",
+ shadowOffset: { width: 0, height: 2 },
+ shadowOpacity: 0.1,
+ shadowRadius: 4,
+ elevation: 3,
+ },
+ deviceHeader: {
+ flexDirection: "row",
+ justifyContent: "space-between",
+ alignItems: "center",
+ marginBottom: 12,
+ },
+ deviceInfo: {
+ flex: 1,
+ },
+ deviceName: {
+ fontSize: 18,
+ fontWeight: "bold",
+ color: "#333",
+ marginBottom: 4,
+ },
+ deviceIP: {
+ fontSize: 14,
+ color: "#666",
+ },
+ statusDot: {
+ width: 12,
+ height: 12,
+ borderRadius: 6,
+ },
+ deviceActions: {
+ flexDirection: "row",
+ justifyContent: "space-between",
+ gap: 8,
+ },
+ actionButton: {
+ flex: 1,
+ paddingVertical: 10,
+ borderRadius: 8,
+ alignItems: "center",
+ },
+ wakeButton: {
+ backgroundColor: "#4CAF50",
+ },
+ sleepButton: {
+ backgroundColor: "#FF9800",
+ },
+ rebootButton: {
+ backgroundColor: "#2196F3",
+ },
+ shutdownButton: {
+ backgroundColor: "#f44336",
+ },
+ actionButtonText: {
+ color: "#fff",
+ fontWeight: "600",
+ fontSize: 12,
+ },
+ emptyContainer: {
+ alignItems: "center",
+ paddingVertical: 50,
+ },
+ emptyText: {
+ fontSize: 16,
+ color: "#999",
+ },
});
diff --git a/app/(tabs)/settings.tsx b/app/(tabs)/settings.tsx
new file mode 100644
index 0000000..d163619
--- /dev/null
+++ b/app/(tabs)/settings.tsx
@@ -0,0 +1,231 @@
+import React from 'react';
+import {
+ View,
+ Text,
+ StyleSheet,
+ ScrollView,
+ TouchableOpacity,
+ Alert,
+} from 'react-native';
+import { useAuth } from '../../src/context/AuthContext';
+import AsyncStorage from '@react-native-async-storage/async-storage';
+
+export default function SettingsScreen() {
+ const { user, logout } = useAuth();
+
+ const handleLogout = () => {
+ Alert.alert(
+ 'Logout',
+ 'Are you sure you want to logout?',
+ [
+ { text: 'Cancel', style: 'cancel' },
+ {
+ text: 'Logout',
+ style: 'destructive',
+ onPress: async () => {
+ await logout();
+ },
+ },
+ ]
+ );
+ };
+
+ const handleClearData = () => {
+ Alert.alert(
+ 'Clear Data',
+ 'This will clear all stored data. Are you sure?',
+ [
+ { text: 'Cancel', style: 'cancel' },
+ {
+ text: 'Clear',
+ style: 'destructive',
+ onPress: async () => {
+ try {
+ await AsyncStorage.clear();
+ Alert.alert('Success', 'All data cleared');
+ } catch (error) {
+ Alert.alert('Error', 'Failed to clear data');
+ }
+ },
+ },
+ ]
+ );
+ };
+
+ const SettingItem = ({
+ label,
+ value,
+ onPress
+ }: {
+ label: string;
+ value?: string;
+ onPress?: () => void;
+ }) => (
+
+ {label}
+
+
+ {value || 'N/A'}
+
+
+
+ );
+
+ const ActionButton = ({
+ title,
+ onPress,
+ destructive = false,
+ }: {
+ title: string;
+ onPress: () => void;
+ destructive?: boolean;
+ }) => (
+
+
+ {title}
+
+
+ );
+
+ return (
+
+
+
+ User Information
+
+
+
+
+
+
+ Server
+
+
+
+
+
+ App Info
+
+
+
+
+
+ Actions
+
+
+
+
+
+
+ UpSnap Mobile App
+
+
+ Connect to your UpSnap server
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ backgroundColor: '#f5f5f5',
+ },
+ content: {
+ padding: 20,
+ },
+ section: {
+ backgroundColor: '#fff',
+ borderRadius: 12,
+ marginBottom: 20,
+ overflow: 'hidden',
+ shadowColor: '#000',
+ shadowOffset: { width: 0, height: 2 },
+ shadowOpacity: 0.1,
+ shadowRadius: 4,
+ elevation: 3,
+ },
+ sectionTitle: {
+ fontSize: 14,
+ fontWeight: 'bold',
+ color: '#666',
+ paddingHorizontal: 15,
+ paddingTop: 15,
+ paddingBottom: 10,
+ backgroundColor: '#f9f9f9',
+ },
+ settingItem: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ paddingHorizontal: 15,
+ paddingVertical: 15,
+ borderBottomWidth: 1,
+ borderBottomColor: '#f0f0f0',
+ },
+ settingLabel: {
+ fontSize: 16,
+ color: '#333',
+ flex: 1,
+ },
+ settingValueContainer: {
+ flex: 1,
+ alignItems: 'flex-end',
+ },
+ settingValue: {
+ fontSize: 14,
+ color: '#666',
+ maxWidth: 200,
+ },
+ actionButton: {
+ backgroundColor: '#007AFF',
+ margin: 15,
+ padding: 15,
+ borderRadius: 8,
+ alignItems: 'center',
+ },
+ actionButtonDestructive: {
+ backgroundColor: '#f44336',
+ },
+ actionButtonText: {
+ color: '#fff',
+ fontSize: 16,
+ fontWeight: 'bold',
+ },
+ actionButtonTextDestructive: {
+ color: '#fff',
+ },
+ footer: {
+ alignItems: 'center',
+ paddingVertical: 30,
+ },
+ footerText: {
+ fontSize: 14,
+ color: '#999',
+ marginBottom: 5,
+ },
+});
diff --git a/app/_layout.tsx b/app/_layout.tsx
index f518c9b..09b27ff 100644
--- a/app/_layout.tsx
+++ b/app/_layout.tsx
@@ -1,24 +1,41 @@
-import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native';
-import { Stack } from 'expo-router';
-import { StatusBar } from 'expo-status-bar';
-import 'react-native-reanimated';
+import { StatusBar } from "expo-status-bar";
+import { AuthProvider, useAuth } from "../src/context/AuthContext";
+import { useEffect } from "react";
+import { Stack, useRouter } from "expo-router";
-import { useColorScheme } from '@/hooks/use-color-scheme';
+function RootStack() {
+ const router = useRouter();
+ const { isAuthenticated, isLoading } = useAuth();
-export const unstable_settings = {
- anchor: '(tabs)',
-};
+ useEffect(() => {
+ if (!isLoading) {
+ if (isAuthenticated) {
+ router.replace('/(tabs)');
+ } else {
+ router.replace('/login');
+ }
+ }
+ }, [isAuthenticated, isLoading, router]);
+
+ return (
+
+ {isAuthenticated ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ );
+}
export default function RootLayout() {
- const colorScheme = useColorScheme();
-
- return (
-
-
-
-
-
-
-
- );
+ return (
+
+
+
+
+ );
}
diff --git a/app/add-device.tsx b/app/add-device.tsx
new file mode 100644
index 0000000..21c3d75
--- /dev/null
+++ b/app/add-device.tsx
@@ -0,0 +1,236 @@
+import React, { useState } from 'react';
+import {
+ View,
+ Text,
+ ScrollView,
+ StyleSheet,
+ TextInput,
+ TouchableOpacity,
+ Alert,
+ ActivityIndicator,
+} from 'react-native';
+import { useRouter } from 'expo-router';
+import api from '../src/services/api';
+
+export default function AddDeviceScreen() {
+ const router = useRouter();
+ const [isLoading, setIsLoading] = useState(false);
+
+ const [formData, setFormData] = useState({
+ name: '',
+ mac: '',
+ ip: '',
+ netmask: '255.255.255.0',
+ broadcast: '',
+ secureOnPassword: '',
+ port: '9',
+ });
+
+ const handleSave = async () => {
+ if (!formData.name || !formData.mac || !formData.ip) {
+ Alert.alert('Error', 'Please fill in all required fields');
+ return;
+ }
+
+ setIsLoading(true);
+ try {
+ await api.createDevice({
+ name: formData.name,
+ mac: formData.mac,
+ ip: formData.ip,
+ netmask: formData.netmask,
+ broadcast: formData.broadcast,
+ secureOnPassword: formData.secureOnPassword,
+ port: parseInt(formData.port) || 9,
+ groups: [],
+ status: 'offline',
+ });
+ Alert.alert('Success', 'Device added successfully');
+ router.back();
+ } catch (error: any) {
+ Alert.alert('Error', error.message || 'Failed to add device');
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ return (
+
+
+ Add New Device
+
+
+
+ Device Name *
+ setFormData({ ...formData, name: text })}
+ placeholder="My PC"
+ autoCapitalize="words"
+ />
+
+
+
+ MAC Address *
+ setFormData({ ...formData, mac: text })}
+ placeholder="00:11:22:33:44:55"
+ autoCapitalize="characters"
+ />
+
+ Format: XX:XX:XX:XX:XX:XX
+
+
+
+
+ IP Address *
+ setFormData({ ...formData, ip: text })}
+ placeholder="192.168.1.100"
+ keyboardType="numeric"
+ />
+
+
+
+ Netmask
+ setFormData({ ...formData, netmask: text })}
+ placeholder="255.255.255.0"
+ keyboardType="numeric"
+ />
+
+
+
+ Broadcast Address
+ setFormData({ ...formData, broadcast: text })}
+ placeholder="192.168.1.255"
+ keyboardType="numeric"
+ />
+
+ Optional: Auto-calculated if left blank
+
+
+
+
+ SecureOn Password
+ setFormData({ ...formData, secureOnPassword: text })}
+ placeholder="Optional password"
+ secureTextEntry
+ />
+
+ Optional: For SecureOn enabled NICs
+
+
+
+
+ Port
+ setFormData({ ...formData, port: text })}
+ placeholder="9"
+ keyboardType="numeric"
+ />
+
+ Default: 9 (Standard Wake-on-LAN port)
+
+
+
+
+ router.back()}
+ disabled={isLoading}
+ >
+ Cancel
+
+
+
+ {isLoading ? 'Adding...' : 'Add Device'}
+
+
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ backgroundColor: '#f5f5f5',
+ },
+ content: {
+ padding: 20,
+ },
+ header: {
+ fontSize: 24,
+ fontWeight: 'bold',
+ color: '#333',
+ marginBottom: 20,
+ textAlign: 'center',
+ },
+ form: {
+ gap: 20,
+ },
+ inputGroup: {
+ gap: 8,
+ },
+ label: {
+ fontSize: 14,
+ fontWeight: '600',
+ color: '#333',
+ },
+ input: {
+ backgroundColor: '#fff',
+ borderWidth: 1,
+ borderColor: '#ddd',
+ borderRadius: 8,
+ padding: 12,
+ fontSize: 16,
+ },
+ hint: {
+ fontSize: 12,
+ color: '#666',
+ },
+ buttonGroup: {
+ flexDirection: 'row',
+ gap: 10,
+ marginTop: 10,
+ },
+ button: {
+ flex: 1,
+ paddingVertical: 15,
+ borderRadius: 8,
+ alignItems: 'center',
+ },
+ cancelButton: {
+ backgroundColor: '#999',
+ },
+ saveButton: {
+ backgroundColor: '#4CAF50',
+ },
+ buttonText: {
+ color: '#fff',
+ fontSize: 16,
+ fontWeight: 'bold',
+ },
+});
diff --git a/app/devices/[id].tsx b/app/devices/[id].tsx
new file mode 100644
index 0000000..4e11a41
--- /dev/null
+++ b/app/devices/[id].tsx
@@ -0,0 +1,350 @@
+import React, { useState, useEffect } from 'react';
+import {
+ View,
+ Text,
+ ScrollView,
+ StyleSheet,
+ TextInput,
+ TouchableOpacity,
+ Alert,
+ ActivityIndicator,
+} from 'react-native';
+import { useLocalSearchParams, useRouter } from 'expo-router';
+import api from '../../src/services/api';
+import { Device } from '../../src/types';
+
+export default function DeviceDetailsScreen() {
+ const router = useRouter();
+ const { id: deviceId } = useLocalSearchParams<{ id: string }>();
+
+ const [device, setDevice] = useState(null);
+ const [isEditing, setIsEditing] = useState(false);
+ const [isLoading, setIsLoading] = useState(true);
+ const [isSaving, setIsSaving] = useState(false);
+
+ const [formData, setFormData] = useState({
+ name: '',
+ mac: '',
+ ip: '',
+ netmask: '',
+ broadcast: '',
+ secureOnPassword: '',
+ port: '',
+ });
+
+ useEffect(() => {
+ loadDevice();
+ }, [deviceId]);
+
+ const loadDevice = async () => {
+ try {
+ setIsLoading(true);
+ const data = await api.getDevice(deviceId);
+ setDevice(data);
+ setFormData({
+ name: data.name,
+ mac: data.mac,
+ ip: data.ip,
+ netmask: data.netmask || '',
+ broadcast: data.broadcast || '',
+ secureOnPassword: data.secureOnPassword || '',
+ port: String(data.port),
+ });
+ } catch (error: any) {
+ Alert.alert('Error', error.message || 'Failed to load device');
+ router.back();
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ const handleSave = async () => {
+ if (!formData.name || !formData.mac || !formData.ip) {
+ Alert.alert('Error', 'Please fill in all required fields');
+ return;
+ }
+
+ setIsSaving(true);
+ try {
+ await api.updateDevice(deviceId, {
+ name: formData.name,
+ mac: formData.mac,
+ ip: formData.ip,
+ netmask: formData.netmask,
+ broadcast: formData.broadcast,
+ secureOnPassword: formData.secureOnPassword,
+ port: parseInt(formData.port) || 9,
+ });
+ setIsEditing(false);
+ await loadDevice();
+ Alert.alert('Success', 'Device updated successfully');
+ } catch (error: any) {
+ Alert.alert('Error', error.message || 'Failed to update device');
+ } finally {
+ setIsSaving(false);
+ }
+ };
+
+ const handleDelete = () => {
+ Alert.alert(
+ 'Delete Device',
+ 'Are you sure you want to delete this device?',
+ [
+ { text: 'Cancel', style: 'cancel' },
+ {
+ text: 'Delete',
+ style: 'destructive',
+ onPress: async () => {
+ try {
+ await api.deleteDevice(deviceId);
+ Alert.alert('Success', 'Device deleted successfully');
+ router.back();
+ } catch (error: any) {
+ Alert.alert('Error', error.message || 'Failed to delete device');
+ }
+ },
+ },
+ ]
+ );
+ };
+
+ if (isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+ {isEditing ? (
+
+ Device Name *
+ setFormData({ ...formData, name: text })}
+ placeholder="Device name"
+ />
+
+ MAC Address *
+ setFormData({ ...formData, mac: text })}
+ placeholder="00:11:22:33:44:55"
+ autoCapitalize="characters"
+ />
+
+ IP Address *
+ setFormData({ ...formData, ip: text })}
+ placeholder="192.168.1.100"
+ keyboardType="numeric"
+ />
+
+ Netmask
+ setFormData({ ...formData, netmask: text })}
+ placeholder="255.255.255.0"
+ keyboardType="numeric"
+ />
+
+ Broadcast Address
+ setFormData({ ...formData, broadcast: text })}
+ placeholder="192.168.1.255"
+ keyboardType="numeric"
+ />
+
+ SecureOn Password
+ setFormData({ ...formData, secureOnPassword: text })}
+ placeholder="Optional password"
+ />
+
+ Port
+ setFormData({ ...formData, port: text })}
+ placeholder="9"
+ keyboardType="numeric"
+ />
+
+
+
+
+ {isSaving ? 'Saving...' : 'Save'}
+
+
+ {
+ setIsEditing(false);
+ setFormData({
+ name: device!.name,
+ mac: device!.mac,
+ ip: device!.ip,
+ netmask: device!.netmask || '',
+ broadcast: device!.broadcast || '',
+ secureOnPassword: device!.secureOnPassword || '',
+ port: String(device!.port),
+ });
+ }}
+ >
+ Cancel
+
+
+
+ ) : (
+
+
+ Name
+ {device?.name}
+
+
+ MAC Address
+ {device?.mac}
+
+
+ IP Address
+ {device?.ip}
+
+
+ Netmask
+ {device?.netmask || 'N/A'}
+
+
+ Broadcast
+ {device?.broadcast || 'N/A'}
+
+
+ Port
+ {device?.port}
+
+
+ Status
+ {device?.status}
+
+
+ Groups
+
+ {device?.groups?.join(', ') || 'None'}
+
+
+
+
+ setIsEditing(true)}
+ >
+ Edit
+
+
+ Delete
+
+
+
+ )}
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ backgroundColor: '#f5f5f5',
+ },
+ loadingContainer: {
+ flex: 1,
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+ content: {
+ padding: 20,
+ },
+ form: {
+ gap: 15,
+ },
+ details: {
+ gap: 20,
+ },
+ label: {
+ fontSize: 14,
+ fontWeight: '600',
+ color: '#333',
+ marginBottom: 5,
+ },
+ input: {
+ backgroundColor: '#fff',
+ borderWidth: 1,
+ borderColor: '#ddd',
+ borderRadius: 8,
+ padding: 12,
+ fontSize: 16,
+ },
+ detailRow: {
+ backgroundColor: '#fff',
+ borderRadius: 8,
+ padding: 15,
+ borderWidth: 1,
+ borderColor: '#e0e0e0',
+ },
+ detailLabel: {
+ fontSize: 14,
+ color: '#666',
+ marginBottom: 4,
+ },
+ detailValue: {
+ fontSize: 16,
+ color: '#333',
+ fontWeight: '500',
+ },
+ buttonGroup: {
+ flexDirection: 'row',
+ gap: 10,
+ marginTop: 20,
+ },
+ button: {
+ flex: 1,
+ paddingVertical: 15,
+ borderRadius: 8,
+ alignItems: 'center',
+ },
+ saveButton: {
+ backgroundColor: '#4CAF50',
+ },
+ cancelButton: {
+ backgroundColor: '#999',
+ },
+ editButton: {
+ backgroundColor: '#007AFF',
+ },
+ deleteButton: {
+ backgroundColor: '#f44336',
+ },
+ buttonText: {
+ color: '#fff',
+ fontSize: 16,
+ fontWeight: 'bold',
+ },
+});
diff --git a/app/login.tsx b/app/login.tsx
new file mode 100644
index 0000000..ffc1f70
--- /dev/null
+++ b/app/login.tsx
@@ -0,0 +1,174 @@
+import React, { useState } from 'react';
+import {
+ View,
+ Text,
+ TextInput,
+ TouchableOpacity,
+ StyleSheet,
+ Alert,
+ KeyboardAvoidingView,
+ Platform,
+ ScrollView,
+} from 'react-native';
+import { useAuth } from '../src/context/AuthContext';
+import { useRouter } from 'expo-router';
+
+export default function LoginScreen() {
+ const router = useRouter();
+ const [identity, setIdentity] = useState('');
+ const [password, setPassword] = useState('');
+ const [isSuperuser, setIsSuperuser] = useState(false);
+ const [isLoading, setIsLoading] = useState(false);
+ const { login } = useAuth();
+
+ const handleLogin = async () => {
+ if (!identity || !password) {
+ Alert.alert('Error', 'Please enter both identity and password');
+ return;
+ }
+
+ setIsLoading(true);
+ try {
+ await login(identity, password, isSuperuser);
+ router.replace('/');
+ } catch (error: any) {
+ Alert.alert('Login Failed', error.message || 'An error occurred');
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ return (
+
+
+
+ UpSnap
+ Wake on LAN Mobile
+
+
+
+
+
+
+
+ setIsSuperuser(!isSuperuser)}
+ activeOpacity={0.7}
+ >
+
+ {isSuperuser && ✓}
+
+ Login as Admin
+
+
+
+
+ {isLoading ? 'Logging in...' : 'Login'}
+
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ backgroundColor: '#f5f5f5',
+ },
+ scrollContent: {
+ flexGrow: 1,
+ justifyContent: 'center',
+ padding: 20,
+ },
+ header: {
+ alignItems: 'center',
+ marginBottom: 40,
+ },
+ title: {
+ fontSize: 32,
+ fontWeight: 'bold',
+ color: '#333',
+ marginBottom: 8,
+ },
+ subtitle: {
+ fontSize: 16,
+ color: '#666',
+ },
+ form: {
+ width: '100%',
+ },
+ input: {
+ backgroundColor: '#fff',
+ borderWidth: 1,
+ borderColor: '#ddd',
+ borderRadius: 8,
+ padding: 15,
+ fontSize: 16,
+ marginBottom: 15,
+ },
+ checkboxContainer: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ marginBottom: 20,
+ },
+ checkbox: {
+ width: 24,
+ height: 24,
+ borderWidth: 2,
+ borderColor: '#007AFF',
+ borderRadius: 4,
+ marginRight: 10,
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+ checkboxChecked: {
+ backgroundColor: '#007AFF',
+ },
+ checkmark: {
+ color: '#fff',
+ fontSize: 16,
+ fontWeight: 'bold',
+ },
+ checkboxLabel: {
+ fontSize: 16,
+ color: '#333',
+ },
+ button: {
+ backgroundColor: '#007AFF',
+ borderRadius: 8,
+ padding: 15,
+ alignItems: 'center',
+ },
+ buttonDisabled: {
+ backgroundColor: '#ccc',
+ },
+ buttonText: {
+ color: '#fff',
+ fontSize: 16,
+ fontWeight: 'bold',
+ },
+});
diff --git a/app/modal.tsx b/app/modal.tsx
deleted file mode 100644
index 6dfbc1a..0000000
--- a/app/modal.tsx
+++ /dev/null
@@ -1,29 +0,0 @@
-import { Link } from 'expo-router';
-import { StyleSheet } from 'react-native';
-
-import { ThemedText } from '@/components/themed-text';
-import { ThemedView } from '@/components/themed-view';
-
-export default function ModalScreen() {
- return (
-
- This is a modal
-
- Go to home screen
-
-
- );
-}
-
-const styles = StyleSheet.create({
- container: {
- flex: 1,
- alignItems: 'center',
- justifyContent: 'center',
- padding: 20,
- },
- link: {
- marginTop: 15,
- paddingVertical: 15,
- },
-});
diff --git a/app/scan-devices.tsx b/app/scan-devices.tsx
new file mode 100644
index 0000000..c0215e5
--- /dev/null
+++ b/app/scan-devices.tsx
@@ -0,0 +1,433 @@
+import React, { useState } from 'react';
+import {
+ View,
+ Text,
+ FlatList,
+ TouchableOpacity,
+ StyleSheet,
+ Alert,
+ ActivityIndicator,
+ Modal,
+ TextInput,
+ ScrollView,
+} from 'react-native';
+import { useRouter } from 'expo-router';
+import api from '../src/services/api';
+import { NetworkScanResult } from '../src/types';
+
+export default function ScanDevicesScreen() {
+ const router = useRouter();
+ const [scanning, setScanning] = useState(false);
+ const [devices, setDevices] = useState([]);
+ const [selectedDevice, setSelectedDevice] = useState(null);
+ const [showAddModal, setShowAddModal] = useState(false);
+
+ const [formData, setFormData] = useState({
+ name: '',
+ mac: '',
+ ip: '',
+ netmask: '255.255.255.0',
+ broadcast: '',
+ secureOnPassword: '',
+ port: '9',
+ });
+
+ const handleScan = async () => {
+ setScanning(true);
+ try {
+ const results = await api.scanNetwork();
+ setDevices(results);
+ } catch (error: any) {
+ Alert.alert('Error', error.message || 'Failed to scan network');
+ } finally {
+ setScanning(false);
+ }
+ };
+
+ const handleAddFromScan = (device: NetworkScanResult) => {
+ const deviceName = device.name || device.hostname || `Device ${device.ip || device.ip_address}`;
+ const deviceIP = device.ip || device.ip_address || '';
+ const deviceMAC = device.mac || device.mac_address || '';
+ const deviceVendor = device.mac_vendor || '';
+
+ setFormData({
+ name: deviceName,
+ mac: deviceMAC,
+ ip: deviceIP,
+ netmask: '255.255.255.0',
+ broadcast: '',
+ secureOnPassword: '',
+ port: '9',
+ });
+ setSelectedDevice(device);
+ setShowAddModal(true);
+ };
+
+ const handleSaveDevice = async () => {
+ if (!formData.name || !formData.mac || !formData.ip) {
+ Alert.alert('Error', 'Please fill in all required fields');
+ return;
+ }
+
+ try {
+ await api.createDevice({
+ name: formData.name,
+ mac: formData.mac,
+ ip: formData.ip,
+ netmask: formData.netmask,
+ broadcast: formData.broadcast,
+ secureOnPassword: formData.secureOnPassword,
+ port: parseInt(formData.port) || 9,
+ groups: [],
+ status: 'offline',
+ });
+ Alert.alert('Success', 'Device added successfully');
+ setShowAddModal(false);
+ router.back();
+ } catch (error: any) {
+ Alert.alert('Error', error.message || 'Failed to add device');
+ }
+ };
+
+ const renderDevice = ({ item }: { item: NetworkScanResult }) => {
+ const displayName = item.name || item.hostname || 'Unknown Device';
+ const displayIP = item.ip || item.ip_address || '';
+ const displayMAC = item.mac || item.mac_address || '';
+ const displayVendor = item.mac_vendor || '';
+
+ return (
+ handleAddFromScan(item)}
+ >
+
+ {displayName}
+ {displayIP}
+ {displayMAC}
+ {displayVendor && displayVendor !== 'Unknown' && (
+ {displayVendor}
+ )}
+
+
+ +
+
+
+ );
+ };
+
+ return (
+
+
+ Network Scan
+
+ Discover devices on your local network
+
+
+
+
+ {scanning ? (
+
+ ) : (
+ Scan Network
+ )}
+
+
+
+ Note: This requires the server to have nmap installed and may take several minutes.
+
+
+ {devices.length > 0 && (
+
+ Discovered Devices ({devices.length})
+ `${item.ip || item.ip_address || index}-${index}`}
+ contentContainerStyle={styles.list}
+ />
+
+ )}
+
+ {devices.length === 0 && !scanning && (
+
+ Tap "Scan Network" to discover devices
+
+ )}
+
+ setShowAddModal(false)}
+ >
+
+
+
+ Add Device
+ setShowAddModal(false)}>
+ ✕
+
+
+
+
+
+ Device Name *
+ setFormData({ ...formData, name: text })}
+ placeholder="Device name"
+ />
+ {selectedDevice?.mac_vendor && (
+
+ Vendor: {selectedDevice.mac_vendor}
+
+ )}
+
+
+
+ MAC Address *
+ setFormData({ ...formData, mac: text })}
+ placeholder="00:11:22:33:44:55"
+ autoCapitalize="characters"
+ />
+
+
+
+ IP Address *
+ setFormData({ ...formData, ip: text })}
+ placeholder="192.168.1.100"
+ keyboardType="numeric"
+ />
+
+
+
+ Netmask
+ setFormData({ ...formData, netmask: text })}
+ placeholder="255.255.255.0"
+ keyboardType="numeric"
+ />
+
+
+
+ Broadcast Address
+ setFormData({ ...formData, broadcast: text })}
+ placeholder="192.168.1.255"
+ keyboardType="numeric"
+ />
+
+
+
+ Port
+ setFormData({ ...formData, port: text })}
+ placeholder="9"
+ keyboardType="numeric"
+ />
+
+
+
+ Add Device
+
+
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ backgroundColor: '#f5f5f5',
+ padding: 20,
+ },
+ header: {
+ marginBottom: 20,
+ },
+ headerText: {
+ fontSize: 24,
+ fontWeight: 'bold',
+ color: '#333',
+ marginBottom: 5,
+ },
+ headerSubtext: {
+ fontSize: 14,
+ color: '#666',
+ },
+ scanButton: {
+ backgroundColor: '#007AFF',
+ borderRadius: 12,
+ padding: 16,
+ alignItems: 'center',
+ marginBottom: 15,
+ },
+ scanButtonDisabled: {
+ backgroundColor: '#ccc',
+ },
+ scanButtonText: {
+ color: '#fff',
+ fontSize: 16,
+ fontWeight: 'bold',
+ },
+ infoText: {
+ fontSize: 12,
+ color: '#999',
+ textAlign: 'center',
+ marginBottom: 20,
+ },
+ resultsContainer: {
+ flex: 1,
+ },
+ resultsHeader: {
+ fontSize: 18,
+ fontWeight: 'bold',
+ color: '#333',
+ marginBottom: 15,
+ },
+ list: {
+ gap: 10,
+ },
+ deviceCard: {
+ backgroundColor: 'rgba(255, 255, 255, 0.8)',
+ borderRadius: 16,
+ padding: 15,
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ shadowColor: '#000',
+ shadowOffset: { width: 0, height: 2 },
+ shadowOpacity: 0.1,
+ shadowRadius: 8,
+ elevation: 3,
+ },
+ deviceInfo: {
+ flex: 1,
+ },
+ deviceName: {
+ fontSize: 16,
+ fontWeight: 'bold',
+ color: '#333',
+ marginBottom: 4,
+ },
+ deviceDetail: {
+ fontSize: 14,
+ color: '#666',
+ marginBottom: 2,
+ },
+ vendorText: {
+ fontSize: 12,
+ color: '#999',
+ fontStyle: 'italic',
+ },
+ addButton: {
+ width: 44,
+ height: 44,
+ backgroundColor: 'rgba(76, 175, 80, 0.9)',
+ borderRadius: 22,
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+ addButtonText: {
+ color: '#fff',
+ fontSize: 24,
+ fontWeight: 'bold',
+ },
+ emptyContainer: {
+ flex: 1,
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+ emptyText: {
+ fontSize: 16,
+ color: '#999',
+ textAlign: 'center',
+ },
+ modalBlur: {
+ flex: 1,
+ justifyContent: 'flex-end',
+ },
+ modalContent: {
+ backgroundColor: 'rgba(255, 255, 255, 0.95)',
+ borderTopLeftRadius: 24,
+ borderTopRightRadius: 24,
+ maxHeight: '90%',
+ },
+ modalHeader: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ padding: 20,
+ borderBottomWidth: 1,
+ borderBottomColor: 'rgba(0, 0, 0, 0.1)',
+ },
+ modalTitle: {
+ fontSize: 20,
+ fontWeight: 'bold',
+ color: '#333',
+ },
+ closeButton: {
+ fontSize: 24,
+ color: '#999',
+ padding: 8,
+ },
+ modalBody: {
+ padding: 20,
+ },
+ formGroup: {
+ marginBottom: 16,
+ },
+ label: {
+ fontSize: 14,
+ fontWeight: '600',
+ color: '#333',
+ marginBottom: 8,
+ },
+ input: {
+ backgroundColor: 'rgba(255, 255, 255, 0.8)',
+ borderWidth: 1,
+ borderColor: 'rgba(0, 0, 0, 0.1)',
+ borderRadius: 12,
+ padding: 14,
+ fontSize: 16,
+ },
+ hint: {
+ fontSize: 12,
+ color: '#666',
+ marginTop: 4,
+ },
+ saveButton: {
+ backgroundColor: '#4CAF50',
+ borderRadius: 12,
+ padding: 16,
+ alignItems: 'center',
+ marginTop: 10,
+ },
+ saveButtonText: {
+ color: '#fff',
+ fontSize: 16,
+ fontWeight: 'bold',
+ },
+});
diff --git a/components/external-link.tsx b/components/external-link.tsx
deleted file mode 100644
index 883e515..0000000
--- a/components/external-link.tsx
+++ /dev/null
@@ -1,25 +0,0 @@
-import { Href, Link } from 'expo-router';
-import { openBrowserAsync, WebBrowserPresentationStyle } from 'expo-web-browser';
-import { type ComponentProps } from 'react';
-
-type Props = Omit, 'href'> & { href: Href & string };
-
-export function ExternalLink({ href, ...rest }: Props) {
- return (
- {
- if (process.env.EXPO_OS !== 'web') {
- // Prevent the default behavior of linking to the default browser on native.
- event.preventDefault();
- // Open the link in an in-app browser.
- await openBrowserAsync(href, {
- presentationStyle: WebBrowserPresentationStyle.AUTOMATIC,
- });
- }
- }}
- />
- );
-}
diff --git a/components/haptic-tab.tsx b/components/haptic-tab.tsx
deleted file mode 100644
index 7f3981c..0000000
--- a/components/haptic-tab.tsx
+++ /dev/null
@@ -1,18 +0,0 @@
-import { BottomTabBarButtonProps } from '@react-navigation/bottom-tabs';
-import { PlatformPressable } from '@react-navigation/elements';
-import * as Haptics from 'expo-haptics';
-
-export function HapticTab(props: BottomTabBarButtonProps) {
- return (
- {
- if (process.env.EXPO_OS === 'ios') {
- // Add a soft haptic feedback when pressing down on the tabs.
- Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
- }
- props.onPressIn?.(ev);
- }}
- />
- );
-}
diff --git a/components/hello-wave.tsx b/components/hello-wave.tsx
deleted file mode 100644
index 5def547..0000000
--- a/components/hello-wave.tsx
+++ /dev/null
@@ -1,19 +0,0 @@
-import Animated from 'react-native-reanimated';
-
-export function HelloWave() {
- return (
-
- 👋
-
- );
-}
diff --git a/components/parallax-scroll-view.tsx b/components/parallax-scroll-view.tsx
deleted file mode 100644
index 6f674a7..0000000
--- a/components/parallax-scroll-view.tsx
+++ /dev/null
@@ -1,79 +0,0 @@
-import type { PropsWithChildren, ReactElement } from 'react';
-import { StyleSheet } from 'react-native';
-import Animated, {
- interpolate,
- useAnimatedRef,
- useAnimatedStyle,
- useScrollOffset,
-} from 'react-native-reanimated';
-
-import { ThemedView } from '@/components/themed-view';
-import { useColorScheme } from '@/hooks/use-color-scheme';
-import { useThemeColor } from '@/hooks/use-theme-color';
-
-const HEADER_HEIGHT = 250;
-
-type Props = PropsWithChildren<{
- headerImage: ReactElement;
- headerBackgroundColor: { dark: string; light: string };
-}>;
-
-export default function ParallaxScrollView({
- children,
- headerImage,
- headerBackgroundColor,
-}: Props) {
- const backgroundColor = useThemeColor({}, 'background');
- const colorScheme = useColorScheme() ?? 'light';
- const scrollRef = useAnimatedRef();
- const scrollOffset = useScrollOffset(scrollRef);
- const headerAnimatedStyle = useAnimatedStyle(() => {
- return {
- transform: [
- {
- translateY: interpolate(
- scrollOffset.value,
- [-HEADER_HEIGHT, 0, HEADER_HEIGHT],
- [-HEADER_HEIGHT / 2, 0, HEADER_HEIGHT * 0.75]
- ),
- },
- {
- scale: interpolate(scrollOffset.value, [-HEADER_HEIGHT, 0, HEADER_HEIGHT], [2, 1, 1]),
- },
- ],
- };
- });
-
- return (
-
-
- {headerImage}
-
- {children}
-
- );
-}
-
-const styles = StyleSheet.create({
- container: {
- flex: 1,
- },
- header: {
- height: HEADER_HEIGHT,
- overflow: 'hidden',
- },
- content: {
- flex: 1,
- padding: 32,
- gap: 16,
- overflow: 'hidden',
- },
-});
diff --git a/components/themed-text.tsx b/components/themed-text.tsx
deleted file mode 100644
index d79d0a1..0000000
--- a/components/themed-text.tsx
+++ /dev/null
@@ -1,60 +0,0 @@
-import { StyleSheet, Text, type TextProps } from 'react-native';
-
-import { useThemeColor } from '@/hooks/use-theme-color';
-
-export type ThemedTextProps = TextProps & {
- lightColor?: string;
- darkColor?: string;
- type?: 'default' | 'title' | 'defaultSemiBold' | 'subtitle' | 'link';
-};
-
-export function ThemedText({
- style,
- lightColor,
- darkColor,
- type = 'default',
- ...rest
-}: ThemedTextProps) {
- const color = useThemeColor({ light: lightColor, dark: darkColor }, 'text');
-
- return (
-
- );
-}
-
-const styles = StyleSheet.create({
- default: {
- fontSize: 16,
- lineHeight: 24,
- },
- defaultSemiBold: {
- fontSize: 16,
- lineHeight: 24,
- fontWeight: '600',
- },
- title: {
- fontSize: 32,
- fontWeight: 'bold',
- lineHeight: 32,
- },
- subtitle: {
- fontSize: 20,
- fontWeight: 'bold',
- },
- link: {
- lineHeight: 30,
- fontSize: 16,
- color: '#0a7ea4',
- },
-});
diff --git a/components/themed-view.tsx b/components/themed-view.tsx
deleted file mode 100644
index 6f181d8..0000000
--- a/components/themed-view.tsx
+++ /dev/null
@@ -1,14 +0,0 @@
-import { View, type ViewProps } from 'react-native';
-
-import { useThemeColor } from '@/hooks/use-theme-color';
-
-export type ThemedViewProps = ViewProps & {
- lightColor?: string;
- darkColor?: string;
-};
-
-export function ThemedView({ style, lightColor, darkColor, ...otherProps }: ThemedViewProps) {
- const backgroundColor = useThemeColor({ light: lightColor, dark: darkColor }, 'background');
-
- return ;
-}
diff --git a/components/ui/collapsible.tsx b/components/ui/collapsible.tsx
deleted file mode 100644
index 6345fde..0000000
--- a/components/ui/collapsible.tsx
+++ /dev/null
@@ -1,45 +0,0 @@
-import { PropsWithChildren, useState } from 'react';
-import { StyleSheet, TouchableOpacity } from 'react-native';
-
-import { ThemedText } from '@/components/themed-text';
-import { ThemedView } from '@/components/themed-view';
-import { IconSymbol } from '@/components/ui/icon-symbol';
-import { Colors } from '@/constants/theme';
-import { useColorScheme } from '@/hooks/use-color-scheme';
-
-export function Collapsible({ children, title }: PropsWithChildren & { title: string }) {
- const [isOpen, setIsOpen] = useState(false);
- const theme = useColorScheme() ?? 'light';
-
- return (
-
- setIsOpen((value) => !value)}
- activeOpacity={0.8}>
-
-
- {title}
-
- {isOpen && {children}}
-
- );
-}
-
-const styles = StyleSheet.create({
- heading: {
- flexDirection: 'row',
- alignItems: 'center',
- gap: 6,
- },
- content: {
- marginTop: 6,
- marginLeft: 24,
- },
-});
diff --git a/components/ui/icon-symbol.ios.tsx b/components/ui/icon-symbol.ios.tsx
deleted file mode 100644
index 9177f4d..0000000
--- a/components/ui/icon-symbol.ios.tsx
+++ /dev/null
@@ -1,32 +0,0 @@
-import { SymbolView, SymbolViewProps, SymbolWeight } from 'expo-symbols';
-import { StyleProp, ViewStyle } from 'react-native';
-
-export function IconSymbol({
- name,
- size = 24,
- color,
- style,
- weight = 'regular',
-}: {
- name: SymbolViewProps['name'];
- size?: number;
- color: string;
- style?: StyleProp;
- weight?: SymbolWeight;
-}) {
- return (
-
- );
-}
diff --git a/components/ui/icon-symbol.tsx b/components/ui/icon-symbol.tsx
deleted file mode 100644
index b7ece6b..0000000
--- a/components/ui/icon-symbol.tsx
+++ /dev/null
@@ -1,41 +0,0 @@
-// Fallback for using MaterialIcons on Android and web.
-
-import MaterialIcons from '@expo/vector-icons/MaterialIcons';
-import { SymbolWeight, SymbolViewProps } from 'expo-symbols';
-import { ComponentProps } from 'react';
-import { OpaqueColorValue, type StyleProp, type TextStyle } from 'react-native';
-
-type IconMapping = Record['name']>;
-type IconSymbolName = keyof typeof MAPPING;
-
-/**
- * Add your SF Symbols to Material Icons mappings here.
- * - see Material Icons in the [Icons Directory](https://icons.expo.fyi).
- * - see SF Symbols in the [SF Symbols](https://developer.apple.com/sf-symbols/) app.
- */
-const MAPPING = {
- 'house.fill': 'home',
- 'paperplane.fill': 'send',
- 'chevron.left.forwardslash.chevron.right': 'code',
- 'chevron.right': 'chevron-right',
-} as IconMapping;
-
-/**
- * An icon component that uses native SF Symbols on iOS, and Material Icons on Android and web.
- * This ensures a consistent look across platforms, and optimal resource usage.
- * Icon `name`s are based on SF Symbols and require manual mapping to Material Icons.
- */
-export function IconSymbol({
- name,
- size = 24,
- color,
- style,
-}: {
- name: IconSymbolName;
- size?: number;
- color: string | OpaqueColorValue;
- style?: StyleProp;
- weight?: SymbolWeight;
-}) {
- return ;
-}
diff --git a/constants/theme.ts b/constants/theme.ts
deleted file mode 100644
index f06facd..0000000
--- a/constants/theme.ts
+++ /dev/null
@@ -1,53 +0,0 @@
-/**
- * Below are the colors that are used in the app. The colors are defined in the light and dark mode.
- * There are many other ways to style your app. For example, [Nativewind](https://www.nativewind.dev/), [Tamagui](https://tamagui.dev/), [unistyles](https://reactnativeunistyles.vercel.app), etc.
- */
-
-import { Platform } from 'react-native';
-
-const tintColorLight = '#0a7ea4';
-const tintColorDark = '#fff';
-
-export const Colors = {
- light: {
- text: '#11181C',
- background: '#fff',
- tint: tintColorLight,
- icon: '#687076',
- tabIconDefault: '#687076',
- tabIconSelected: tintColorLight,
- },
- dark: {
- text: '#ECEDEE',
- background: '#151718',
- tint: tintColorDark,
- icon: '#9BA1A6',
- tabIconDefault: '#9BA1A6',
- tabIconSelected: tintColorDark,
- },
-};
-
-export const Fonts = Platform.select({
- ios: {
- /** iOS `UIFontDescriptorSystemDesignDefault` */
- sans: 'system-ui',
- /** iOS `UIFontDescriptorSystemDesignSerif` */
- serif: 'ui-serif',
- /** iOS `UIFontDescriptorSystemDesignRounded` */
- rounded: 'ui-rounded',
- /** iOS `UIFontDescriptorSystemDesignMonospaced` */
- mono: 'ui-monospace',
- },
- default: {
- sans: 'normal',
- serif: 'serif',
- rounded: 'normal',
- mono: 'monospace',
- },
- web: {
- sans: "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif",
- serif: "Georgia, 'Times New Roman', serif",
- rounded: "'SF Pro Rounded', 'Hiragino Maru Gothic ProN', Meiryo, 'MS PGothic', sans-serif",
- mono: "SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace",
- },
-});
diff --git a/package-lock.json b/package-lock.json
index 8a45b51..3b1db2c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,7 +8,9 @@
"name": "remote-wol",
"version": "1.0.0",
"dependencies": {
+ "@expo/ui": "~0.2.0-beta.9",
"@expo/vector-icons": "^15.0.3",
+ "@react-native-async-storage/async-storage": "^2.2.0",
"@react-navigation/bottom-tabs": "^7.4.0",
"@react-navigation/elements": "^2.6.3",
"@react-navigation/native": "^7.1.8",
@@ -2181,6 +2183,20 @@
"integrity": "sha512-HHQigo3rQWKMDzYDLkubN5WQOYXJJE2eNqIQC2axC2iO3mHdwnIR7FgZVvHWtBwAdzBgAP0ECp8KqS8TiMKvgw==",
"license": "MIT"
},
+ "node_modules/@expo/ui": {
+ "version": "0.2.0-beta.9",
+ "resolved": "https://registry.npmjs.org/@expo/ui/-/ui-0.2.0-beta.9.tgz",
+ "integrity": "sha512-RaBcp0cMe5GykQogJwRZGy4o4JHDLtrr+HaurDPhwPKqVATsV0rR11ysmFe4QX8XWLP/L3od7NOkXUi5ailvaw==",
+ "license": "MIT",
+ "dependencies": {
+ "sf-symbols-typescript": "^2.1.0"
+ },
+ "peerDependencies": {
+ "expo": "*",
+ "react": "*",
+ "react-native": "*"
+ }
+ },
"node_modules/@expo/vector-icons": {
"version": "15.0.3",
"resolved": "https://registry.npmjs.org/@expo/vector-icons/-/vector-icons-15.0.3.tgz",
@@ -2788,6 +2804,18 @@
}
}
},
+ "node_modules/@react-native-async-storage/async-storage": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-2.2.0.tgz",
+ "integrity": "sha512-gvRvjR5JAaUZF8tv2Kcq/Gbt3JHwbKFYfmb445rhOj6NUMx3qPLixmDx5pZAyb9at1bYvJ4/eTUipU5aki45xw==",
+ "license": "MIT",
+ "dependencies": {
+ "merge-options": "^3.0.4"
+ },
+ "peerDependencies": {
+ "react-native": "^0.0.0-0 || >=0.65 <1.0"
+ }
+ },
"node_modules/@react-native/assets-registry": {
"version": "0.81.5",
"resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.81.5.tgz",
@@ -7820,6 +7848,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/is-plain-obj": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz",
+ "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/is-regex": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
@@ -8797,6 +8834,18 @@
"integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==",
"license": "MIT"
},
+ "node_modules/merge-options": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/merge-options/-/merge-options-3.0.4.tgz",
+ "integrity": "sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ==",
+ "license": "MIT",
+ "dependencies": {
+ "is-plain-obj": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/merge-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
diff --git a/package.json b/package.json
index 3cd1a86..cda4961 100644
--- a/package.json
+++ b/package.json
@@ -5,13 +5,15 @@
"scripts": {
"start": "expo start",
"reset-project": "node ./scripts/reset-project.js",
- "android": "expo start --android",
- "ios": "expo start --ios",
+ "android": "expo run:android",
+ "ios": "expo run:ios",
"web": "expo start --web",
"lint": "expo lint"
},
"dependencies": {
+ "@expo/ui": "~0.2.0-beta.9",
"@expo/vector-icons": "^15.0.3",
+ "@react-native-async-storage/async-storage": "^2.2.0",
"@react-navigation/bottom-tabs": "^7.4.0",
"@react-navigation/elements": "^2.6.3",
"@react-navigation/native": "^7.1.8",
@@ -31,17 +33,17 @@
"react-dom": "19.1.0",
"react-native": "0.81.5",
"react-native-gesture-handler": "~2.28.0",
- "react-native-worklets": "0.5.1",
"react-native-reanimated": "~4.1.1",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
- "react-native-web": "~0.21.0"
+ "react-native-web": "~0.21.0",
+ "react-native-worklets": "0.5.1"
},
"devDependencies": {
"@types/react": "~19.1.0",
- "typescript": "~5.9.2",
"eslint": "^9.25.0",
- "eslint-config-expo": "~10.0.0"
+ "eslint-config-expo": "~10.0.0",
+ "typescript": "~5.9.2"
},
"private": true
}
diff --git a/src/context/AuthContext.tsx b/src/context/AuthContext.tsx
new file mode 100644
index 0000000..1f008d5
--- /dev/null
+++ b/src/context/AuthContext.tsx
@@ -0,0 +1,92 @@
+import React, { createContext, useContext, useState, useEffect } from 'react';
+import AsyncStorage from '@react-native-async-storage/async-storage';
+import api from '../services/api';
+import { AuthResponse, User } from '../types';
+
+interface AuthContextType {
+ user: User | null;
+ token: string | null;
+ isAuthenticated: boolean;
+ isLoading: boolean;
+ login: (identity: string, password: string, isSuperuser?: boolean) => Promise;
+ logout: () => Promise;
+}
+
+const AuthContext = createContext(undefined);
+
+export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
+ const [user, setUser] = useState(null);
+ const [token, setToken] = useState(null);
+ const [isLoading, setIsLoading] = useState(true);
+
+ useEffect(() => {
+ loadAuth();
+ }, []);
+
+ const loadAuth = async () => {
+ try {
+ const storedToken = await AsyncStorage.getItem('auth_token');
+ const storedUser = await AsyncStorage.getItem('auth_user');
+
+ if (storedToken && storedUser) {
+ setToken(storedToken);
+ api.setToken(storedToken);
+ setUser(JSON.parse(storedUser));
+ }
+ } catch (error) {
+ console.error('Failed to load auth', error);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ const login = async (identity: string, password: string, isSuperuser = false) => {
+ try {
+ const response: AuthResponse = await api.authenticate(identity, password, isSuperuser);
+
+ await AsyncStorage.setItem('auth_token', response.token);
+ await AsyncStorage.setItem('auth_user', JSON.stringify(response.record));
+
+ setToken(response.token);
+ setUser(response.record);
+ } catch (error) {
+ throw error;
+ }
+ };
+
+ const logout = async () => {
+ try {
+ await AsyncStorage.removeItem('auth_token');
+ await AsyncStorage.removeItem('auth_user');
+
+ api.clearToken();
+ setToken(null);
+ setUser(null);
+ } catch (error) {
+ console.error('Failed to logout', error);
+ }
+ };
+
+ return (
+
+ {children}
+
+ );
+};
+
+export const useAuth = () => {
+ const context = useContext(AuthContext);
+ if (!context) {
+ throw new Error('useAuth must be used within an AuthProvider');
+ }
+ return context;
+};
diff --git a/src/services/api.ts b/src/services/api.ts
new file mode 100644
index 0000000..dcd6776
--- /dev/null
+++ b/src/services/api.ts
@@ -0,0 +1,245 @@
+import { Device, AuthResponse, NetworkScanResult } from '../types';
+
+const API_BASE_URL = 'https://wol.f6knight.duckdns.org/api';
+
+class UpSnapAPI {
+ private token: string | null = null;
+
+ setToken(token: string) {
+ this.token = token;
+ }
+
+ getToken(): string | null {
+ return this.token;
+ }
+
+ clearToken() {
+ this.token = null;
+ }
+
+ private getHeaders(): HeadersInit {
+ const headers: HeadersInit = {
+ 'Content-Type': 'application/json',
+ };
+
+ if (this.token) {
+ headers['Authorization'] = `Bearer ${this.token}`;
+ }
+
+ return headers;
+ }
+
+ async authenticate(identity: string, password: string, isSuperuser = false): Promise {
+ const endpoint = isSuperuser
+ ? `${API_BASE_URL}/collections/_superusers/auth-with-password`
+ : `${API_BASE_URL}/collections/users/auth-with-password`;
+
+ const response = await fetch(endpoint, {
+ method: 'POST',
+ headers: this.getHeaders(),
+ body: JSON.stringify({ identity, password }),
+ });
+
+ if (!response.ok) {
+ const error = await response.json();
+ throw new Error(error.message || 'Authentication failed');
+ }
+
+ const data: AuthResponse = await response.json();
+ this.token = data.token;
+ return data;
+ }
+
+ async getDevices(page = 1, perPage = 30): Promise {
+ const response = await fetch(
+ `${API_BASE_URL}/collections/devices/records?page=${page}&perPage=${perPage}`,
+ {
+ headers: this.getHeaders(),
+ }
+ );
+
+ if (!response.ok) {
+ throw new Error('Failed to fetch devices');
+ }
+
+ const data = await response.json();
+ return data.items;
+ }
+
+ async getDevice(id: string): Promise {
+ const response = await fetch(
+ `${API_BASE_URL}/collections/devices/records/${id}`,
+ {
+ headers: this.getHeaders(),
+ }
+ );
+
+ if (!response.ok) {
+ throw new Error('Failed to fetch device');
+ }
+
+ return response.json();
+ }
+
+ async createDevice(device: Partial): Promise {
+ const response = await fetch(
+ `${API_BASE_URL}/collections/devices/records`,
+ {
+ method: 'POST',
+ headers: this.getHeaders(),
+ body: JSON.stringify(device),
+ }
+ );
+
+ if (!response.ok) {
+ const error = await response.json();
+ throw new Error(error.message || 'Failed to create device');
+ }
+
+ return response.json();
+ }
+
+ async updateDevice(id: string, device: Partial): Promise {
+ const response = await fetch(
+ `${API_BASE_URL}/collections/devices/records/${id}`,
+ {
+ method: 'PATCH',
+ headers: this.getHeaders(),
+ body: JSON.stringify(device),
+ }
+ );
+
+ if (!response.ok) {
+ const error = await response.json();
+ throw new Error(error.message || 'Failed to update device');
+ }
+
+ return response.json();
+ }
+
+ async deleteDevice(id: string): Promise {
+ const response = await fetch(
+ `${API_BASE_URL}/collections/devices/records/${id}`,
+ {
+ method: 'DELETE',
+ headers: this.getHeaders(),
+ }
+ );
+
+ if (!response.ok) {
+ throw new Error('Failed to delete device');
+ }
+ }
+
+ async wakeDevice(id: string): Promise {
+ const response = await fetch(
+ `${API_BASE_URL}/upsnap/wake/${id}`,
+ {
+ headers: this.getHeaders(),
+ }
+ );
+
+ if (!response.ok) {
+ throw new Error('Failed to wake device');
+ }
+ }
+
+ async wakeGroup(id: string): Promise {
+ const response = await fetch(
+ `${API_BASE_URL}/upsnap/wakegroup/${id}`,
+ {
+ headers: this.getHeaders(),
+ }
+ );
+
+ if (!response.ok) {
+ throw new Error('Failed to wake group');
+ }
+ }
+
+ async sleepDevice(id: string): Promise {
+ const response = await fetch(
+ `${API_BASE_URL}/upsnap/sleep/${id}`,
+ {
+ headers: this.getHeaders(),
+ }
+ );
+
+ if (!response.ok) {
+ throw new Error('Failed to sleep device');
+ }
+ }
+
+ async rebootDevice(id: string): Promise {
+ const response = await fetch(
+ `${API_BASE_URL}/upsnap/reboot/${id}`,
+ {
+ headers: this.getHeaders(),
+ }
+ );
+
+ if (!response.ok) {
+ throw new Error('Failed to reboot device');
+ }
+ }
+
+ async shutdownDevice(id: string): Promise {
+ const response = await fetch(
+ `${API_BASE_URL}/upsnap/shutdown/${id}`,
+ {
+ headers: this.getHeaders(),
+ }
+ );
+
+ if (!response.ok) {
+ throw new Error('Failed to shutdown device');
+ }
+ }
+
+ async scanNetwork(): Promise {
+ const response = await fetch(
+ `${API_BASE_URL}/upsnap/scan`,
+ {
+ headers: this.getHeaders(),
+ }
+ );
+
+ if (!response.ok) {
+ throw new Error('Failed to scan network');
+ }
+
+ const data = await response.json();
+ console.log('Raw scan data:', data);
+
+ if (data.devices && Array.isArray(data.devices)) {
+ return data.devices.map((item: any) => ({
+ name: item.name || item.hostname || 'Unknown',
+ ip: item.ip || item.ip_address || '',
+ mac: item.mac || item.mac_address || '',
+ mac_vendor: item.mac_vendor || 'Unknown',
+ }));
+ }
+
+ if (Array.isArray(data)) {
+ return data.map((item: any) => ({
+ name: item.name || item.hostname || 'Unknown',
+ ip: item.ip || item.ip_address || '',
+ mac: item.mac || item.mac_address || '',
+ mac_vendor: item.mac_vendor || 'Unknown',
+ }));
+ }
+
+ if (data.items && Array.isArray(data.items)) {
+ return data.items.map((item: any) => ({
+ name: item.name || item.hostname || 'Unknown',
+ ip: item.ip || item.ip_address || '',
+ mac: item.mac || item.mac_address || '',
+ mac_vendor: item.mac_vendor || 'Unknown',
+ }));
+ }
+
+ return [];
+ }
+}
+
+export default new UpSnapAPI();
diff --git a/src/types/index.ts b/src/types/index.ts
new file mode 100644
index 0000000..9877c34
--- /dev/null
+++ b/src/types/index.ts
@@ -0,0 +1,50 @@
+export interface Device {
+ id: string;
+ collectionId: string;
+ collectionName: string;
+ name: string;
+ mac: string;
+ ip: string;
+ netmask: string;
+ broadcast: string;
+ secureOnPassword: string;
+ port: number;
+ groups: string[];
+ status: string;
+ created: string;
+ updated: string;
+}
+
+export interface AuthResponse {
+ token: string;
+ record: User;
+}
+
+export interface User {
+ id: string;
+ collectionId: string;
+ collectionName: string;
+ username: string;
+ verified: boolean;
+ emailVisibility: boolean;
+ email: string;
+ created: string;
+ updated: string;
+ name: string;
+ avatar: number;
+}
+
+export interface DeviceGroup {
+ id: string;
+ name: string;
+}
+
+export interface NetworkScanResult {
+ name?: string;
+ hostname?: string;
+ ip?: string;
+ ip_address?: string;
+ mac?: string;
+ mac_address?: string;
+ mac_vendor?: string;
+}