aboutsummaryrefslogtreecommitdiffstats
path: root/apps
diff options
context:
space:
mode:
authorMohamed Bassem <me@mbassem.com>2025-12-22 16:36:23 +0200
committerGitHub <noreply@github.com>2025-12-22 14:36:23 +0000
commitece68ed078be3f6d66b5dcd7de8ba9853d48be27 (patch)
treed976591691a8819f5aae1cafee54a4b386910394 /apps
parentca4bfa4c88287aba23c104a93e4fb6be7b2776da (diff)
downloadkarakeep-ece68ed078be3f6d66b5dcd7de8ba9853d48be27.tar.zst
feat(mobile): Convert server address editing to modal in mobile app (#2290)
* feat: Convert server address editing to modal in mobile app Changed the server address editing experience from an inline button to a modal dialog. This improves UX by forcing users to explicitly save or cancel their changes rather than forgetting to click a save button. Changes: - Created ServerAddressModal component following the CustomHeadersModal pattern - Updated signin page to use the modal instead of inline editing - Enhanced settings page to allow changing server address (was previously read-only) - Added validation and error handling within the modal - Made the settings page server address clickable with visual feedback This resolves the issue where users forget to click the save button after editing the server address. * refactor: Convert server address to screen modal Changed from React Native Modal to Expo Router screen modal presentation. This provides a better native experience with proper navigation stack integration. Changes: - Created server-address.tsx as a screen route with modal presentation - Registered the route in root _layout.tsx - Updated signin.tsx to navigate to the screen modal instead of opening RN modal - Reverted settings page to original (no server address editing from settings) - Removed ServerAddressModal component (no longer needed) Benefits: - Native modal presentation with proper animations - Better integration with the navigation stack - Cleaner separation of concerns * merge the custom headers inside the server-add screen * fix the look of the address UI --------- Co-authored-by: Claude <noreply@anthropic.com>
Diffstat (limited to 'apps')
-rw-r--r--apps/mobile/app/_layout.tsx8
-rw-r--r--apps/mobile/app/server-address.tsx230
-rw-r--r--apps/mobile/app/signin.tsx103
3 files changed, 257 insertions, 84 deletions
diff --git a/apps/mobile/app/_layout.tsx b/apps/mobile/app/_layout.tsx
index 1e6128c7..3f9e5575 100644
--- a/apps/mobile/app/_layout.tsx
+++ b/apps/mobile/app/_layout.tsx
@@ -64,6 +64,14 @@ export default function RootLayout() {
/>
<Stack.Screen name="sharing" />
<Stack.Screen
+ name="server-address"
+ options={{
+ title: "Server Address",
+ headerShown: true,
+ presentation: "modal",
+ }}
+ />
+ <Stack.Screen
name="test-connection"
options={{
title: "Test Connection",
diff --git a/apps/mobile/app/server-address.tsx b/apps/mobile/app/server-address.tsx
new file mode 100644
index 00000000..b63aaf4c
--- /dev/null
+++ b/apps/mobile/app/server-address.tsx
@@ -0,0 +1,230 @@
+import { useState } from "react";
+import { Pressable, View } from "react-native";
+import { KeyboardAwareScrollView } from "react-native-keyboard-controller";
+import { Stack, useRouter } from "expo-router";
+import { Button } from "@/components/ui/Button";
+import CustomSafeAreaView from "@/components/ui/CustomSafeAreaView";
+import { Input } from "@/components/ui/Input";
+import PageTitle from "@/components/ui/PageTitle";
+import { Text } from "@/components/ui/Text";
+import useAppSettings from "@/lib/settings";
+import { Plus, Trash2 } from "lucide-react-native";
+import { useColorScheme } from "nativewind";
+
+export default function ServerAddress() {
+ const router = useRouter();
+ const { colorScheme } = useColorScheme();
+ const iconColor = colorScheme === "dark" ? "#d1d5db" : "#374151";
+ const { settings, setSettings } = useAppSettings();
+ const [address, setAddress] = useState(
+ settings.address ?? "https://cloud.karakeep.app",
+ );
+ const [error, setError] = useState<string | undefined>();
+
+ // Custom headers state
+ const [headers, setHeaders] = useState<{ key: string; value: string }[]>(
+ Object.entries(settings.customHeaders || {}).map(([key, value]) => ({
+ key,
+ value,
+ })),
+ );
+ const [newHeaderKey, setNewHeaderKey] = useState("");
+ const [newHeaderValue, setNewHeaderValue] = useState("");
+
+ const handleAddHeader = () => {
+ if (!newHeaderKey.trim() || !newHeaderValue.trim()) {
+ return;
+ }
+
+ // Check if header already exists
+ const existingIndex = headers.findIndex((h) => h.key === newHeaderKey);
+ if (existingIndex >= 0) {
+ // Update existing header
+ const updatedHeaders = [...headers];
+ updatedHeaders[existingIndex].value = newHeaderValue;
+ setHeaders(updatedHeaders);
+ } else {
+ // Add new header
+ setHeaders([...headers, { key: newHeaderKey, value: newHeaderValue }]);
+ }
+
+ setNewHeaderKey("");
+ setNewHeaderValue("");
+ };
+
+ const handleRemoveHeader = (index: number) => {
+ setHeaders(headers.filter((_, i) => i !== index));
+ };
+
+ const handleSave = () => {
+ // Validate the address
+ if (!address.trim()) {
+ setError("Server address is required");
+ return;
+ }
+
+ if (!address.startsWith("http://") && !address.startsWith("https://")) {
+ setError("Server address must start with http:// or https://");
+ return;
+ }
+
+ // Convert headers array to object
+ const headersObject = headers.reduce(
+ (acc, { key, value }) => {
+ if (key.trim() && value.trim()) {
+ acc[key] = value;
+ }
+ return acc;
+ },
+ {} as Record<string, string>,
+ );
+
+ // Remove trailing slash and save
+ const cleanedAddress = address.trim().replace(/\/$/, "");
+ setSettings({
+ ...settings,
+ address: cleanedAddress,
+ customHeaders: headersObject,
+ });
+ router.back();
+ };
+
+ return (
+ <CustomSafeAreaView>
+ <Stack.Screen
+ options={{
+ title: "Server Address",
+ headerRight: () => (
+ <Pressable onPress={handleSave}>
+ <Text className="text-base font-semibold text-blue-500">
+ Save
+ </Text>
+ </Pressable>
+ ),
+ }}
+ />
+ <PageTitle title="Server Address" />
+ <KeyboardAwareScrollView
+ className="w-full"
+ contentContainerClassName="items-center gap-4 px-4 py-4"
+ bottomOffset={20}
+ keyboardShouldPersistTaps="handled"
+ >
+ {/* Error Message */}
+ {error && (
+ <View className="w-full rounded-lg bg-red-50 p-3 dark:bg-red-950">
+ <Text className="text-center text-sm text-red-600 dark:text-red-400">
+ {error}
+ </Text>
+ </View>
+ )}
+
+ {/* Server Address Section */}
+ <View className="w-full">
+ <Text className="mb-2 px-1 text-sm font-medium text-muted-foreground">
+ Server URL
+ </Text>
+ <View className="w-full gap-3 rounded-lg bg-card px-4 py-4">
+ <Text className="text-sm text-muted-foreground">
+ Enter the URL of your Karakeep server
+ </Text>
+ <Input
+ placeholder="https://cloud.karakeep.app"
+ value={address}
+ onChangeText={(text) => {
+ setAddress(text);
+ setError(undefined);
+ }}
+ autoCapitalize="none"
+ keyboardType="url"
+ autoFocus
+ inputClasses="bg-background"
+ />
+ <Text className="text-xs text-muted-foreground">
+ Must start with http:// or https://
+ </Text>
+ </View>
+ </View>
+
+ {/* Custom Headers Section */}
+ <View className="w-full">
+ <Text className="mb-2 px-1 text-sm font-medium text-muted-foreground">
+ Custom Headers
+ {headers.length > 0 && (
+ <Text className="text-muted-foreground"> ({headers.length})</Text>
+ )}
+ </Text>
+ <View className="w-full gap-3 rounded-lg bg-card px-4 py-4">
+ <Text className="text-sm text-muted-foreground">
+ Add custom HTTP headers for API requests
+ </Text>
+
+ {/* Existing Headers List */}
+ {headers.length === 0 ? (
+ <View className="py-4">
+ <Text className="text-center text-sm text-muted-foreground">
+ No custom headers configured
+ </Text>
+ </View>
+ ) : (
+ <View className="gap-2">
+ {headers.map((header, index) => (
+ <View
+ key={index}
+ className="flex-row items-center gap-3 rounded-lg border border-border bg-background p-3"
+ >
+ <View className="flex-1 gap-1">
+ <Text className="text-sm font-semibold">
+ {header.key}
+ </Text>
+ <Text
+ className="text-xs text-muted-foreground"
+ numberOfLines={1}
+ >
+ {header.value}
+ </Text>
+ </View>
+ <Pressable
+ onPress={() => handleRemoveHeader(index)}
+ className="rounded-md p-2"
+ hitSlop={8}
+ >
+ <Trash2 size={18} color="#ef4444" />
+ </Pressable>
+ </View>
+ ))}
+ </View>
+ )}
+
+ {/* Add New Header Form */}
+ <View className="gap-2 border-t border-border pt-4">
+ <Text className="text-sm font-medium">Add New Header</Text>
+ <Input
+ placeholder="Header Name (e.g., X-Custom-Header)"
+ value={newHeaderKey}
+ onChangeText={setNewHeaderKey}
+ autoCapitalize="none"
+ inputClasses="bg-background"
+ />
+ <Input
+ placeholder="Header Value"
+ value={newHeaderValue}
+ onChangeText={setNewHeaderValue}
+ autoCapitalize="none"
+ inputClasses="bg-background"
+ />
+ <Button
+ variant="secondary"
+ onPress={handleAddHeader}
+ disabled={!newHeaderKey.trim() || !newHeaderValue.trim()}
+ >
+ <Plus size={16} color={iconColor} />
+ <Text className="text-sm">Add Header</Text>
+ </Button>
+ </View>
+ </View>
+ </View>
+ </KeyboardAwareScrollView>
+ </CustomSafeAreaView>
+ );
+}
diff --git a/apps/mobile/app/signin.tsx b/apps/mobile/app/signin.tsx
index 6a554f89..03cbba5a 100644
--- a/apps/mobile/app/signin.tsx
+++ b/apps/mobile/app/signin.tsx
@@ -7,7 +7,6 @@ import {
View,
} from "react-native";
import { Redirect, useRouter } from "expo-router";
-import { CustomHeadersModal } from "@/components/CustomHeadersModal";
import Logo from "@/components/Logo";
import { TailwindResolver } from "@/components/TailwindResolver";
import { Button } from "@/components/ui/Button";
@@ -15,7 +14,7 @@ import { Input } from "@/components/ui/Input";
import { Text } from "@/components/ui/Text";
import useAppSettings from "@/lib/settings";
import { api } from "@/lib/trpc";
-import { Bug, Check, Edit3 } from "lucide-react-native";
+import { Bug, Edit3 } from "lucide-react-native";
enum LoginType {
Password,
@@ -28,12 +27,6 @@ export default function Signin() {
const [error, setError] = useState<string | undefined>();
const [loginType, setLoginType] = useState<LoginType>(LoginType.Password);
- const [isEditingServerAddress, setIsEditingServerAddress] = useState(false);
- const [tempServerAddress, setTempServerAddress] = useState(
- settings.address ?? "https://cloud.karakeep.app",
- );
- const [isCustomHeadersModalVisible, setIsCustomHeadersModalVisible] =
- useState(false);
const emailRef = useRef<string>("");
const passwordRef = useRef<string>("");
@@ -82,19 +75,15 @@ export default function Signin() {
return <Redirect href="dashboard" />;
}
- const handleSaveCustomHeaders = (headers: Record<string, string>) => {
- setSettings({ ...settings, customHeaders: headers });
- };
-
const onSignin = () => {
- if (!tempServerAddress) {
+ if (!settings.address) {
setError("Server address is required");
return;
}
if (
- !tempServerAddress.startsWith("http://") &&
- !tempServerAddress.startsWith("https://")
+ !settings.address.startsWith("http://") &&
+ !settings.address.startsWith("https://")
) {
setError("Server address must start with http:// or https://");
return;
@@ -137,71 +126,23 @@ export default function Signin() {
)}
<View className="gap-2">
<Text className="font-bold">Server Address</Text>
- {!isEditingServerAddress ? (
- <View className="flex-row items-center gap-2">
- <View className="flex-1 rounded-md border border-border bg-card px-3 py-2">
- <Text>{tempServerAddress}</Text>
- </View>
- <Button
- size="icon"
- variant="secondary"
- onPress={() => {
- setIsEditingServerAddress(true);
- }}
- >
- <TailwindResolver
- comp={(styles) => (
- <Edit3 size={16} color={styles?.color?.toString()} />
- )}
- className="color-foreground"
- />
- </Button>
+ <View className="flex-row items-center gap-2">
+ <View className="flex-1 rounded-md border border-border bg-card px-3 py-2">
+ <Text>{settings.address ?? "https://cloud.karakeep.app"}</Text>
</View>
- ) : (
- <View className="flex-row items-center gap-2">
- <Input
- className="flex-1"
- inputClasses="bg-card"
- placeholder="Server Address"
- value={tempServerAddress}
- autoCapitalize="none"
- keyboardType="url"
- onChangeText={setTempServerAddress}
- autoFocus
+ <Button
+ size="icon"
+ variant="secondary"
+ onPress={() => router.push("/server-address")}
+ >
+ <TailwindResolver
+ comp={(styles) => (
+ <Edit3 size={16} color={styles?.color?.toString()} />
+ )}
+ className="color-foreground"
/>
- <Button
- size="icon"
- variant="primary"
- onPress={() => {
- if (tempServerAddress.trim()) {
- setSettings({
- ...settings,
- address: tempServerAddress.trim().replace(/\/$/, ""),
- });
- }
- setIsEditingServerAddress(false);
- }}
- >
- <TailwindResolver
- comp={(styles) => (
- <Check size={16} color={styles?.color?.toString()} />
- )}
- className="text-white"
- />
- </Button>
- </View>
- )}
- <Pressable
- onPress={() => setIsCustomHeadersModalVisible(true)}
- className="mt-1"
- >
- <Text className="text-xs text-gray-500 underline">
- Configure Custom Headers{" "}
- {settings.customHeaders &&
- Object.keys(settings.customHeaders).length > 0 &&
- `(${Object.keys(settings.customHeaders).length})`}
- </Text>
- </Pressable>
+ </Button>
+ </View>
</View>
{loginType === LoginType.Password && (
<>
@@ -282,12 +223,6 @@ export default function Signin() {
</Pressable>
</View>
</TouchableWithoutFeedback>
- <CustomHeadersModal
- visible={isCustomHeadersModalVisible}
- customHeaders={settings.customHeaders || {}}
- onClose={() => setIsCustomHeadersModalVisible(false)}
- onSave={handleSaveCustomHeaders}
- />
</KeyboardAvoidingView>
);
}