This commit is contained in:
Ryan Chen
2025-06-26 20:19:21 -04:00
parent 948f36ffc1
commit ff29bccb95
23 changed files with 11614 additions and 11507 deletions

35
App.tsx
View File

@@ -1,28 +1,31 @@
/**
* Sample React Native App
* https://github.com/facebook/react-native
*
* @format
* PocketDog - React Native App
* A modern article archiving app
*/
import { NewAppScreen } from '@react-native/new-app-screen';
import { StatusBar, StyleSheet, useColorScheme, View } from 'react-native';
import React from 'react';
import { StatusBar, useColorScheme, Platform } from 'react-native';
import AppNavigator from './src/navigation/AppNavigator';
function App() {
const isDarkMode = useColorScheme() === 'dark';
return (
<View style={styles.container}>
<StatusBar barStyle={isDarkMode ? 'light-content' : 'dark-content'} />
<NewAppScreen templateFileName="App.tsx" />
</View>
<>
<StatusBar
barStyle={
Platform.OS === 'ios'
? 'dark-content'
: isDarkMode
? 'light-content'
: 'dark-content'
}
backgroundColor={Platform.OS === 'android' ? '#ffffff' : 'transparent'}
translucent={Platform.OS === 'android'}
/>
<AppNavigator />
</>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
});
export default App;

View File

@@ -4,6 +4,14 @@
<style name="AppTheme" parent="Theme.AppCompat.DayNight.NoActionBar">
<!-- Customize your theme here. -->
<item name="android:editTextBackground">@drawable/rn_edit_text_material</item>
<!-- Enable edge-to-edge display -->
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
<!-- Make status bar transparent -->
<item name="android:statusBarColor">@android:color/transparent</item>
<!-- Make navigation bar transparent -->
<item name="android:navigationBarColor">@android:color/transparent</item>
<!-- Enable light status bar content -->
<item name="android:windowLightStatusBar">true</item>
</style>
</resources>

View File

@@ -7,24 +7,14 @@
objects = {
/* Begin PBXBuildFile section */
00929FE363A96A6E1098BB99 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB81A68108700A75B9A /* PrivacyInfo.xcprivacy */; };
0C80B921A6F3F58F76C31292 /* libPods-PocketDog.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5DCACB8F33CDC322A6C60F78 /* libPods-PocketDog.a */; };
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; };
761780ED2CA45674006654EE /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 761780EC2CA45674006654EE /* AppDelegate.swift */; };
81AB9BB82411601600AC10FF /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
00E356F41AD99517003FC87E /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 83CBB9F71A601CBA00E9B192 /* Project object */;
proxyType = 1;
remoteGlobalIDString = 13B07F861A680F5B00A75B9A;
remoteInfo = PocketDog;
};
/* End PBXContainerItemProxy section */
/* Begin PBXFileReference section */
00E356F11AD99517003FC87E /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
13B07F961A680F5B00A75B9A /* PocketDog.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = PocketDog.app; sourceTree = BUILT_PRODUCTS_DIR; };
13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = PocketDog/Images.xcassets; sourceTree = "<group>"; };
13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = PocketDog/Info.plist; sourceTree = "<group>"; };
@@ -49,14 +39,6 @@
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
00E356F01AD99517003FC87E /* Supporting Files */ = {
isa = PBXGroup;
children = (
00E356F11AD99517003FC87E /* Info.plist */,
);
name = "Supporting Files";
sourceTree = "<group>";
};
13B07FAE1A68108700A75B9A /* PocketDog */ = {
isa = PBXGroup;
children = (
@@ -172,19 +154,13 @@
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
00E356EC1AD99517003FC87E /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
13B07F8E1A680F5B00A75B9A /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
81AB9BB82411601600AC10FF /* LaunchScreen.storyboard in Resources */,
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */,
00929FE363A96A6E1098BB99 /* PrivacyInfo.xcprivacy in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -276,14 +252,6 @@
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
00E356F51AD99517003FC87E /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 13B07F861A680F5B00A75B9A /* PocketDog */;
targetProxy = 00E356F41AD99517003FC87E /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */
13B07F941A680F5B00A75B9A /* Debug */ = {
isa = XCBuildConfiguration;
@@ -292,6 +260,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 8VJS8U8Z8Q;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = PocketDog/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
@@ -320,6 +289,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 8VJS8U8Z8Q;
INFOPLIST_FILE = PocketDog/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
LD_RUNPATH_SEARCH_PATHS = (
@@ -408,7 +378,14 @@
"-DFOLLY_CFG_NO_COROUTINES=1",
"-DFOLLY_HAVE_CLOCK_GETTIME=1",
);
OTHER_LDFLAGS = (
"$(inherited)",
" ",
);
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG";
USE_HERMES = true;
};
name = Debug;
};
@@ -473,7 +450,13 @@
"-DFOLLY_CFG_NO_COROUTINES=1",
"-DFOLLY_HAVE_CLOCK_GETTIME=1",
);
OTHER_LDFLAGS = (
"$(inherited)",
" ",
);
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
SDKROOT = iphoneos;
USE_HERMES = true;
VALIDATE_PRODUCT = YES;
};
name = Release;

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "group:PocketDog.xcodeproj">
</FileRef>
<FileRef
location = "group:Pods/Pods.xcodeproj">
</FileRef>
</Workspace>

View File

@@ -29,6 +29,11 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
launchOptions: launchOptions
)
// Configure status bar appearance
if #available(iOS 13.0, *) {
window?.overrideUserInterfaceStyle = .light
}
return true
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 594 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -26,7 +26,6 @@
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<!-- Do not change NSAllowsArbitraryLoads to true, or you will risk app rejection! -->
<key>NSAllowsArbitraryLoads</key>
<false/>
<key>NSAllowsLocalNetworking</key>
@@ -34,6 +33,8 @@
</dict>
<key>NSLocationWhenInUseUsageDescription</key>
<string></string>
<key>RCTNewArchEnabled</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIRequiredDeviceCapabilities</key>
@@ -48,5 +49,7 @@
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<false/>
<key>UIStatusBarStyle</key>
<string>UIStatusBarStyleDarkContent</string>
</dict>
</plist>

2657
ios/Podfile.lock Normal file

File diff suppressed because it is too large Load Diff

11454
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -10,9 +10,16 @@
"test": "jest"
},
"dependencies": {
"@react-native-async-storage/async-storage": "^2.2.0",
"@react-native/new-app-screen": "0.80.0",
"@react-navigation/bottom-tabs": "^7.4.2",
"@react-navigation/native": "^7.1.14",
"@react-navigation/native-stack": "^7.3.21",
"react": "19.1.0",
"react-native": "0.80.0",
"@react-native/new-app-screen": "0.80.0"
"react-native-safe-area-context": "^5.5.0",
"react-native-screens": "^4.11.1",
"react-native-webview": "^13.15.0"
},
"devDependencies": {
"@babel/core": "^7.25.2",

View File

@@ -0,0 +1,87 @@
import React from 'react';
import { Text, Platform } from 'react-native';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import { NavigationContainer } from '@react-navigation/native';
import {
SafeAreaProvider,
useSafeAreaInsets,
} from 'react-native-safe-area-context';
import ArticlesScreen from '../screens/ArticlesScreen';
import ArticleViewScreen from '../screens/ArticleViewScreen';
import SettingsScreen from '../screens/SettingsScreen';
const Tab = createBottomTabNavigator();
const Stack = createNativeStackNavigator();
const ArticlesStack = () => {
return (
<Stack.Navigator screenOptions={{ headerShown: false }}>
<Stack.Screen name="ArticlesList" component={ArticlesScreen} />
<Stack.Screen name="ArticleView" component={ArticleViewScreen} />
</Stack.Navigator>
);
};
const TabNavigator: React.FC = () => {
const insets = useSafeAreaInsets();
return (
<Tab.Navigator
screenOptions={{
tabBarActiveTintColor: '#007bff',
tabBarInactiveTintColor: '#6c757d',
tabBarStyle: {
backgroundColor: '#ffffff',
borderTopWidth: 1,
borderTopColor: '#e9ecef',
paddingBottom: Platform.OS === 'ios' ? insets.bottom : 8,
paddingTop: Platform.OS === 'ios' ? 8 : 8,
height: Platform.OS === 'ios' ? 60 + insets.bottom : 70,
shadowColor: '#000',
shadowOffset: {
width: 0,
height: -2,
},
shadowOpacity: 0.1,
shadowRadius: 3,
elevation: Platform.OS === 'android' ? 8 : 0,
},
headerShown: false,
}}
>
<Tab.Screen
name="Articles"
component={ArticlesStack}
options={{
tabBarLabel: 'Articles',
tabBarIcon: ({ color, size }: { color: string; size: number }) => (
<Text style={{ color, fontSize: size }}>📚</Text>
),
}}
/>
<Tab.Screen
name="Settings"
component={SettingsScreen}
options={{
tabBarLabel: 'Settings',
tabBarIcon: ({ color, size }: { color: string; size: number }) => (
<Text style={{ color, fontSize: size }}></Text>
),
}}
/>
</Tab.Navigator>
);
};
const AppNavigator: React.FC = () => {
return (
<SafeAreaProvider>
<NavigationContainer>
<TabNavigator />
</NavigationContainer>
</SafeAreaProvider>
);
};
export default AppNavigator;

View File

@@ -0,0 +1,318 @@
import React, { useState } from 'react';
import {
View,
Text,
TextInput,
TouchableOpacity,
StyleSheet,
Alert,
KeyboardAvoidingView,
Platform,
ScrollView,
Switch,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import {
saveHtmlArticle,
saveLinkArticle,
validateHtmlUrl,
} from '../utils/articleUtils';
const ArchiveScreen: React.FC = () => {
const [url, setUrl] = useState('');
const [title, setTitle] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [archiveAsHtml, setArchiveAsHtml] = useState(true);
const validateUrl = (urlString: string): boolean => {
try {
new URL(urlString);
return true;
} catch {
return false;
}
};
const handleSubmit = async () => {
if (!url.trim()) {
Alert.alert('Error', 'Please enter a URL');
return;
}
if (!validateUrl(url.trim())) {
Alert.alert('Error', 'Please enter a valid URL');
return;
}
setIsSubmitting(true);
try {
let article;
if (archiveAsHtml) {
// Validate that the URL is accessible
const isAccessible = await validateHtmlUrl(url.trim());
if (!isAccessible) {
Alert.alert(
'Warning',
'The URL might not be accessible. Would you like to archive it as a link instead?',
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Archive as Link',
onPress: () => {
setArchiveAsHtml(false);
handleSubmit();
},
},
],
);
setIsSubmitting(false);
return;
}
// Archive as HTML
article = await saveHtmlArticle(url.trim(), title.trim() || undefined);
Alert.alert('Success', 'HTML article archived successfully!', [
{ text: 'OK' },
]);
} else {
// Archive as link
article = await saveLinkArticle(url.trim(), title.trim() || undefined);
Alert.alert('Success', 'Link archived successfully!', [{ text: 'OK' }]);
}
// Clear form
setUrl('');
setTitle('');
} catch (error) {
console.error('Error archiving article:', error);
Alert.alert('Error', `Failed to archive article: ${error}`);
} finally {
setIsSubmitting(false);
}
};
const isFormValid = url.trim().length > 0 && validateUrl(url.trim());
return (
<SafeAreaView style={styles.safeArea} edges={['top']}>
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
>
<ScrollView contentContainerStyle={styles.scrollContainer}>
<View style={styles.header}>
<Text style={styles.headerTitle}>Archive Link</Text>
<Text style={styles.headerSubtitle}>
Save articles and links for later reading
</Text>
</View>
<View style={styles.formContainer}>
<View style={styles.archiveTypeContainer}>
<Text style={styles.archiveTypeLabel}>Archive Type</Text>
<View style={styles.switchContainer}>
<Text style={styles.switchLabel}>
{archiveAsHtml ? '📄 HTML Content' : '🔗 Link Only'}
</Text>
<Switch
value={archiveAsHtml}
onValueChange={setArchiveAsHtml}
trackColor={{ false: '#767577', true: '#81b0ff' }}
thumbColor={archiveAsHtml ? '#007bff' : '#f4f3f4'}
/>
</View>
<Text style={styles.archiveTypeHelp}>
{archiveAsHtml
? 'Download and store the full HTML content for offline reading'
: 'Store only the link (faster, uses less storage)'}
</Text>
</View>
<View style={styles.inputGroup}>
<Text style={styles.label}>URL *</Text>
<TextInput
style={[
styles.input,
url.trim() && !validateUrl(url.trim()) && styles.inputError,
]}
placeholder="https://example.com/article"
value={url}
onChangeText={setUrl}
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
returnKeyType="next"
/>
{url.trim() && !validateUrl(url.trim()) && (
<Text style={styles.errorText}>Please enter a valid URL</Text>
)}
</View>
<View style={styles.inputGroup}>
<Text style={styles.label}>Title (Optional)</Text>
<TextInput
style={styles.input}
placeholder="Article title"
value={title}
onChangeText={setTitle}
returnKeyType="done"
/>
<Text style={styles.helpText}>
{archiveAsHtml
? 'Leave empty to auto-extract from the webpage'
: 'Leave empty to use "Untitled Article"'}
</Text>
</View>
<TouchableOpacity
style={[
styles.submitButton,
(!isFormValid || isSubmitting) && styles.submitButtonDisabled,
]}
onPress={handleSubmit}
disabled={!isFormValid || isSubmitting}
>
<Text style={styles.submitButtonText}>
{isSubmitting
? archiveAsHtml
? 'Downloading...'
: 'Archiving...'
: archiveAsHtml
? 'Archive as HTML'
: 'Archive as Link'}
</Text>
</TouchableOpacity>
</View>
</ScrollView>
</KeyboardAvoidingView>
</SafeAreaView>
);
};
const styles = StyleSheet.create({
safeArea: {
flex: 1,
backgroundColor: '#f8f9fa',
},
container: {
flex: 1,
backgroundColor: '#f8f9fa',
},
scrollContainer: {
flexGrow: 1,
paddingBottom: Platform.OS === 'ios' ? 20 : 24, // Extra padding for Android
},
header: {
padding: 20,
paddingTop: Platform.OS === 'ios' ? 16 : 20, // More padding for Android status bar
backgroundColor: '#ffffff',
borderBottomWidth: 1,
borderBottomColor: '#e9ecef',
},
headerTitle: {
fontSize: 28,
fontWeight: 'bold',
color: '#212529',
marginBottom: 4,
},
headerSubtitle: {
fontSize: 16,
color: '#6c757d',
},
formContainer: {
padding: 20,
},
archiveTypeContainer: {
marginBottom: 24,
padding: 16,
backgroundColor: '#ffffff',
borderRadius: 8,
borderWidth: 1,
borderColor: '#e9ecef',
},
archiveTypeLabel: {
fontSize: 16,
fontWeight: '600',
color: '#495057',
marginBottom: 12,
},
switchContainer: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 8,
},
switchLabel: {
fontSize: 16,
color: '#212529',
fontWeight: '500',
},
archiveTypeHelp: {
fontSize: 14,
color: '#6c757d',
lineHeight: 20,
},
inputGroup: {
marginBottom: 20,
},
label: {
fontSize: 16,
fontWeight: '600',
color: '#495057',
marginBottom: 8,
},
input: {
backgroundColor: '#ffffff',
borderWidth: 1,
borderColor: '#dee2e6',
borderRadius: 8,
padding: 16,
fontSize: 16,
color: '#212529',
// Android-specific input styling
...(Platform.OS === 'android' && {
paddingVertical: 12,
paddingHorizontal: 16,
}),
},
inputError: {
borderColor: '#dc3545',
},
errorText: {
color: '#dc3545',
fontSize: 14,
marginTop: 4,
},
helpText: {
color: '#6c757d',
fontSize: 14,
marginTop: 4,
},
submitButton: {
backgroundColor: '#007bff',
borderRadius: 8,
padding: 16,
alignItems: 'center',
marginTop: 20,
// Android-specific button styling
...(Platform.OS === 'android' && {
elevation: 2,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.2,
shadowRadius: 2,
}),
},
submitButtonDisabled: {
backgroundColor: '#6c757d',
},
submitButtonText: {
color: '#ffffff',
fontSize: 16,
fontWeight: '600',
},
});
export default ArchiveScreen;

View File

@@ -0,0 +1,354 @@
import React from 'react';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
Alert,
Platform,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { WebView } from 'react-native-webview';
import { RouteProp, useRoute, useNavigation } from '@react-navigation/native';
import { Article } from '../utils/articleUtils';
type RootStackParamList = {
ArticleView: { article: Article };
};
type ArticleViewRouteProp = RouteProp<RootStackParamList, 'ArticleView'>;
const ArticleViewScreen: React.FC = () => {
const route = useRoute<ArticleViewRouteProp>();
const navigation = useNavigation();
const { article } = route.params;
const handleOpenOriginal = () => {
Alert.alert(
'Open Original',
'Would you like to open the original URL in your browser?',
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Open',
onPress: () => {
// In a real app, you'd use Linking.openURL here
Alert.alert(
'Info',
'This would open the original URL in your browser',
);
},
},
],
);
};
const handleDelete = () => {
Alert.alert(
'Delete Article',
'Are you sure you want to delete this article?',
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Delete',
style: 'destructive',
onPress: () => {
// In a real app, you'd call deleteArticle here
navigation.goBack();
},
},
],
);
};
const renderHtmlContent = () => {
if (article.type === 'html' && article.htmlContent) {
// Create a complete HTML document with our custom CSS
const htmlWithCSS = `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<style>
* {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica Neue', Arial, sans-serif !important;
line-height: 1.6 !important;
}
body {
margin: 0 !important;
padding: 16px !important;
background-color: #ffffff !important;
color: #333333 !important;
font-size: 16px !important;
-webkit-text-size-adjust: 100% !important;
}
h1, h2, h3, h4, h5, h6 {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica Neue', Arial, sans-serif !important;
font-weight: 600 !important;
line-height: 1.3 !important;
margin-top: 24px !important;
margin-bottom: 12px !important;
color: #212529 !important;
}
h1 { font-size: 24px !important; }
h2 { font-size: 20px !important; }
h3 { font-size: 18px !important; }
h4 { font-size: 16px !important; }
h5 { font-size: 14px !important; }
h6 { font-size: 12px !important; }
p {
margin-bottom: 16px !important;
text-align: justify !important;
}
img {
max-width: 100% !important;
height: auto !important;
display: block !important;
margin: 16px auto !important;
border-radius: 8px !important;
box-shadow: 0 2px 8px rgba(0,0,0,0.1) !important;
}
figure {
margin: 16px 0 !important;
text-align: center !important;
}
figure img {
margin: 0 auto !important;
}
figcaption {
font-size: 14px !important;
color: #6c757d !important;
margin-top: 8px !important;
font-style: italic !important;
}
a {
color: #007bff !important;
text-decoration: none !important;
}
a:hover {
text-decoration: underline !important;
}
blockquote {
border-left: 4px solid #007bff !important;
margin: 16px 0 !important;
padding: 12px 16px !important;
background-color: #f8f9fa !important;
font-style: italic !important;
}
code {
background-color: #f8f9fa !important;
padding: 2px 4px !important;
border-radius: 4px !important;
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace !important;
font-size: 14px !important;
}
pre {
background-color: #f8f9fa !important;
padding: 16px !important;
border-radius: 8px !important;
overflow-x: auto !important;
margin: 16px 0 !important;
}
pre code {
background-color: transparent !important;
padding: 0 !important;
}
ul, ol {
margin-bottom: 16px !important;
padding-left: 24px !important;
}
li {
margin-bottom: 4px !important;
}
table {
width: 100% !important;
border-collapse: collapse !important;
margin: 16px 0 !important;
font-size: 14px !important;
}
th, td {
border: 1px solid #dee2e6 !important;
padding: 8px 12px !important;
text-align: left !important;
}
th {
background-color: #f8f9fa !important;
font-weight: 600 !important;
}
/* Hide any fixed positioned elements that might interfere */
.fixed, .sticky, [style*="position: fixed"], [style*="position: sticky"] {
position: static !important;
}
/* Remove any overlays or popups */
.modal, .overlay, .popup, .lightbox {
display: none !important;
}
</style>
</head>
<body>
${article.htmlContent}
</body>
</html>
`;
return (
<WebView
source={{ html: htmlWithCSS }}
style={styles.webview}
javaScriptEnabled={true}
domStorageEnabled={true}
startInLoadingState={true}
scalesPageToFit={false}
onError={(syntheticEvent: any) => {
const { nativeEvent } = syntheticEvent;
console.warn('WebView error: ', nativeEvent);
}}
/>
);
} else {
return (
<View style={styles.fallbackContainer}>
<Text style={styles.fallbackText}>
This article doesn't have HTML content available.
</Text>
<TouchableOpacity
style={styles.openButton}
onPress={handleOpenOriginal}
>
<Text style={styles.openButtonText}>Open Original URL</Text>
</TouchableOpacity>
</View>
);
}
};
return (
<SafeAreaView style={styles.safeArea} edges={['top']}>
<View style={styles.container}>
<View style={styles.header}>
<TouchableOpacity
style={styles.backButton}
onPress={() => navigation.goBack()}
>
<Text style={styles.backButtonText}>← Back</Text>
</TouchableOpacity>
<View style={styles.headerContent}>
<Text style={styles.headerTitle} numberOfLines={2}>
{article.title}
</Text>
<Text style={styles.headerSubtitle}>
{new Date(article.archivedAt).toLocaleDateString()}
</Text>
</View>
<TouchableOpacity style={styles.menuButton} onPress={handleDelete}>
<Text style={styles.menuButtonText}>⋮</Text>
</TouchableOpacity>
</View>
{renderHtmlContent()}
</View>
</SafeAreaView>
);
};
const styles = StyleSheet.create({
safeArea: {
flex: 1,
backgroundColor: '#ffffff',
},
container: {
flex: 1,
backgroundColor: '#ffffff',
},
header: {
flexDirection: 'row',
alignItems: 'center',
padding: 16,
paddingTop: Platform.OS === 'ios' ? 8 : 16,
borderBottomWidth: 1,
borderBottomColor: '#e9ecef',
backgroundColor: '#ffffff',
},
backButton: {
padding: 8,
marginRight: 8,
},
backButtonText: {
fontSize: 16,
color: '#007bff',
fontWeight: '600',
},
headerContent: {
flex: 1,
},
headerTitle: {
fontSize: 18,
fontWeight: 'bold',
color: '#212529',
marginBottom: 2,
},
headerSubtitle: {
fontSize: 14,
color: '#6c757d',
},
menuButton: {
padding: 8,
marginLeft: 8,
},
menuButtonText: {
fontSize: 20,
color: '#6c757d',
},
webview: {
flex: 1,
},
fallbackContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 20,
},
fallbackText: {
fontSize: 16,
color: '#6c757d',
textAlign: 'center',
marginBottom: 20,
},
openButton: {
backgroundColor: '#007bff',
paddingHorizontal: 20,
paddingVertical: 12,
borderRadius: 8,
},
openButtonText: {
color: '#ffffff',
fontSize: 16,
fontWeight: '600',
},
});
export default ArticleViewScreen;

View File

@@ -0,0 +1,445 @@
import React, { useState, useEffect } from 'react';
import {
View,
Text,
FlatList,
StyleSheet,
TouchableOpacity,
RefreshControl,
Alert,
Platform,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useNavigation, useFocusEffect } from '@react-navigation/native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import {
Article,
loadArticles,
deleteArticle,
markArticleAsRead,
getReadStats,
} from '../utils/articleUtils';
const ArticlesScreen: React.FC = () => {
const [articles, setArticles] = useState<Article[]>([]);
const [refreshing, setRefreshing] = useState(false);
const [readStats, setReadStats] = useState({ total: 0, read: 0, unread: 0 });
const navigation = useNavigation();
const loadArticlesData = async () => {
try {
const articlesData = await loadArticles();
setArticles(articlesData);
// Load read statistics
const stats = await getReadStats();
setReadStats(stats);
} catch (error) {
console.error('Error loading articles:', error);
Alert.alert('Error', 'Failed to load articles');
}
};
const onRefresh = async () => {
setRefreshing(true);
await loadArticlesData();
setRefreshing(false);
};
// Refresh articles when screen comes into focus (e.g., after returning from Settings)
useFocusEffect(
React.useCallback(() => {
loadArticlesData();
}, []),
);
useEffect(() => {
loadArticlesData();
}, []);
const handleArticlePress = async (article: Article) => {
// Mark article as read when opened
if (!article.isRead) {
try {
await markArticleAsRead(article.id);
// Update the local state to reflect the read status
setArticles(prevArticles =>
prevArticles.map(a =>
a.id === article.id
? { ...a, isRead: true, readAt: new Date().toISOString() }
: a,
),
);
// Update read stats
const stats = await getReadStats();
setReadStats(stats);
} catch (error) {
console.error('Error marking article as read:', error);
}
}
if (article.type === 'html' && article.htmlContent) {
// Navigate to HTML article view
(navigation as any).navigate('ArticleView', { article });
} else {
// Show alert for regular links
Alert.alert('Article', `Opening: ${article.title}`, [
{ text: 'Cancel', style: 'cancel' },
{
text: 'Open',
onPress: () => {
// In a real app, you'd use Linking.openURL here
Alert.alert('Info', 'This would open the URL in your browser');
},
},
]);
}
};
const handleDeleteArticle = async (articleId: string) => {
Alert.alert(
'Delete Article',
'Are you sure you want to delete this article? This action cannot be undone.',
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Delete',
style: 'destructive',
onPress: async () => {
try {
await deleteArticle(articleId);
await loadArticlesData(); // Refresh the list
Alert.alert('Success', 'Article deleted successfully');
} catch (error) {
console.error('Error deleting article:', error);
Alert.alert('Error', 'Failed to delete article');
}
},
},
],
);
};
const handleDeleteAllArticles = async () => {
if (articles.length === 0) {
Alert.alert('No Articles', 'There are no articles to delete.');
return;
}
Alert.alert(
'Delete All Articles',
`Are you sure you want to delete all ${articles.length} articles? This action cannot be undone.`,
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Delete All',
style: 'destructive',
onPress: async () => {
try {
// Clear all articles from storage
await AsyncStorage.removeItem('articles');
setArticles([]);
Alert.alert('Success', 'All articles deleted successfully');
} catch (error) {
console.error('Error deleting all articles:', error);
Alert.alert('Error', 'Failed to delete all articles');
}
},
},
],
);
};
const renderArticle = ({ item }: { item: Article }) => {
// Clean the title by removing newlines and excessive whitespace
const cleanTitle = item.title.replace(/\s+/g, ' ').trim();
return (
<View style={[styles.articleCard, item.isRead && styles.articleCardRead]}>
<TouchableOpacity
style={styles.articleContent}
onPress={() => handleArticlePress(item)}
>
<View style={styles.articleHeader}>
<Text
style={[
styles.articleTitle,
item.isRead && styles.articleTitleRead,
]}
numberOfLines={0}
>
{cleanTitle}
</Text>
</View>
<Text style={styles.articleUrl} numberOfLines={1}>
{item.url}
</Text>
<Text style={styles.articleDate}>
Archived: {new Date(item.archivedAt).toLocaleDateString()}
{item.timestamp && ` (${item.timestamp})`}
{item.isRead && item.readAt && (
<Text style={styles.readDate}>
{' • Read: '}
{new Date(item.readAt).toLocaleDateString()}
</Text>
)}
</Text>
{item.type === 'html' && item.htmlContent && (
<Text style={styles.articleInfo}>Offline</Text>
)}
</TouchableOpacity>
<TouchableOpacity
style={styles.menuButton}
onPress={() => {
Alert.alert(
'Article Options',
'What would you like to do with this article?',
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Delete',
style: 'destructive',
onPress: () => handleDeleteArticle(item.id),
},
],
);
}}
>
<Text style={styles.menuButtonText}></Text>
</TouchableOpacity>
</View>
);
};
return (
<SafeAreaView style={styles.safeArea} edges={['top']}>
<View style={styles.container}>
<View style={styles.header}>
<View style={styles.headerTop}>
<Text style={styles.headerTitle}>PocketDog</Text>
<TouchableOpacity
style={styles.headerMenuButton}
onPress={() => {
Alert.alert('Menu', 'What would you like to do?', [
{ text: 'Cancel', style: 'cancel' },
{
text: 'Delete All Articles',
style: 'destructive',
onPress: handleDeleteAllArticles,
},
]);
}}
>
<Text style={styles.headerMenuButtonText}></Text>
</TouchableOpacity>
</View>
<Text style={styles.headerSubtitle}>
{articles.length} article{articles.length !== 1 ? 's' : ''} archived
{readStats.total > 0 && (
<Text style={styles.readStats}>
{' • '}
{readStats.read} read, {readStats.unread} unread
</Text>
)}
</Text>
</View>
<FlatList
data={articles}
renderItem={renderArticle}
keyExtractor={item => item.id}
contentContainerStyle={styles.listContainer}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
}
ListEmptyComponent={
<View style={styles.emptyContainer}>
<Text style={styles.emptyText}>No articles yet</Text>
<Text style={styles.emptySubtext}>
Use the Archive Manager in Settings to fetch articles
</Text>
</View>
}
/>
</View>
</SafeAreaView>
);
};
const styles = StyleSheet.create({
safeArea: {
flex: 1,
backgroundColor: '#f8f9fa',
},
container: {
flex: 1,
backgroundColor: '#f8f9fa',
},
header: {
padding: 20,
paddingTop: Platform.OS === 'ios' ? 16 : 20, // More padding for Android status bar
backgroundColor: '#ffffff',
borderBottomWidth: 1,
borderBottomColor: '#e9ecef',
},
headerTop: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
headerTitle: {
fontSize: 28,
fontWeight: 'bold',
color: '#212529',
marginBottom: 4,
},
headerSubtitle: {
fontSize: 16,
color: '#6c757d',
marginBottom: 16,
},
listContainer: {
padding: 16,
paddingBottom: Platform.OS === 'ios' ? 20 : 24, // Extra padding for Android
},
articleCard: {
backgroundColor: '#ffffff',
borderRadius: 12,
padding: 16,
marginBottom: 12,
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.1,
shadowRadius: 3.84,
elevation: Platform.OS === 'android' ? 5 : 0, // Android elevation
flexDirection: 'row', // Add this to position content and delete button side by side
alignItems: 'flex-start', // Align items to the top
},
articleCardRead: {
backgroundColor: '#f0f0f0',
},
articleContent: {
flex: 1, // Take up most of the space
marginRight: 12, // Add space between content and menu button
},
articleHeader: {
marginBottom: 8,
},
articleTitle: {
fontSize: 18,
fontWeight: '600',
color: '#212529',
lineHeight: 24,
},
articleTitleRead: {
color: '#6c757d',
},
articleType: {
backgroundColor: '#f8f9fa',
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 12,
},
articleTypeText: {
fontSize: 12,
fontWeight: '600',
},
htmlType: {
color: '#28a745',
},
linkType: {
color: '#007bff',
},
articleUrl: {
fontSize: 14,
color: '#007bff',
marginBottom: 8,
},
articleDate: {
fontSize: 12,
color: '#6c757d',
marginBottom: 4,
},
articleInfo: {
fontSize: 12,
color: '#28a745',
fontStyle: 'italic',
},
archiveSourceContainer: {
backgroundColor: '#f8f9fa',
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 12,
},
archiveSourceText: {
fontSize: 12,
color: '#6c757d',
fontStyle: 'italic',
},
emptyContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingVertical: 60,
},
emptyText: {
fontSize: 20,
fontWeight: '600',
color: '#6c757d',
marginBottom: 8,
},
emptySubtext: {
fontSize: 16,
color: '#adb5bd',
textAlign: 'center',
},
readStats: {
fontSize: 12,
color: '#6c757d',
fontStyle: 'italic',
},
readDate: {
fontSize: 12,
color: '#6c757d',
fontStyle: 'italic',
},
menuButton: {
backgroundColor: '#f8f9fa',
borderRadius: 12,
padding: 8,
alignItems: 'center',
justifyContent: 'center',
width: 32,
height: 32,
},
menuButtonText: {
color: '#6c757d',
fontSize: 18,
fontWeight: '600',
lineHeight: 18,
},
headerMenuButton: {
backgroundColor: '#f8f9fa',
borderRadius: 12,
padding: 8,
alignItems: 'center',
justifyContent: 'center',
width: 32,
height: 32,
},
headerMenuButtonText: {
color: '#6c757d',
fontSize: 18,
fontWeight: '600',
lineHeight: 18,
},
});
export default ArticlesScreen;

View File

@@ -0,0 +1,482 @@
import React, { useState, useEffect } from 'react';
import {
View,
Text,
TextInput,
TouchableOpacity,
StyleSheet,
Alert,
KeyboardAvoidingView,
Platform,
ScrollView,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { fetchArchiveResults } from '../utils/articleUtils';
const SettingsScreen: React.FC = () => {
const [url, setUrl] = useState('');
const [apiKey, setApiKey] = useState('');
const [isSaving, setIsSaving] = useState(false);
const [archiveResults, setArchiveResults] = useState<any>(null);
const [isLoadingResults, setIsLoadingResults] = useState(false);
useEffect(() => {
loadSettings();
}, []);
const loadSettings = async () => {
try {
const storedUrl = await AsyncStorage.getItem('settings_url');
const storedApiKey = await AsyncStorage.getItem('settings_api_key');
if (storedUrl) setUrl(storedUrl);
if (storedApiKey) setApiKey(storedApiKey);
} catch (error) {
console.error('Error loading settings:', error);
}
};
const validateUrl = (urlString: string): boolean => {
if (!urlString.trim()) return true; // Allow empty URL
try {
new URL(urlString);
return true;
} catch {
return false;
}
};
const handleSave = async () => {
if (url.trim() && !validateUrl(url.trim())) {
Alert.alert('Error', 'Please enter a valid URL');
return;
}
setIsSaving(true);
try {
await AsyncStorage.setItem('settings_url', url.trim());
await AsyncStorage.setItem('settings_api_key', apiKey.trim());
Alert.alert('Success', 'Settings saved successfully!', [{ text: 'OK' }]);
} catch (error) {
console.error('Error saving settings:', error);
Alert.alert('Error', 'Failed to save settings');
} finally {
setIsSaving(false);
}
};
const handleClear = async () => {
Alert.alert(
'Clear Settings',
'Are you sure you want to clear all settings?',
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Clear',
style: 'destructive',
onPress: async () => {
try {
await AsyncStorage.removeItem('settings_url');
await AsyncStorage.removeItem('settings_api_key');
setUrl('');
setApiKey('');
Alert.alert('Success', 'Settings cleared successfully!');
} catch (error) {
console.error('Error clearing settings:', error);
Alert.alert('Error', 'Failed to clear settings');
}
},
},
],
);
};
const handleFetchArchiveResults = async () => {
try {
setIsLoadingResults(true);
if (!url || !apiKey) {
Alert.alert(
'Settings Required',
'Please configure the API URL and API Key first.',
);
return;
}
const results = await fetchArchiveResults(url, apiKey);
setArchiveResults(results);
const downloadedCount = results.totalDownloaded || 0;
const totalCount = results.totalProcessed || 0;
Alert.alert(
'Download Complete',
`Successfully downloaded ${downloadedCount} out of ${totalCount} articles from the archive.`,
);
} catch (error) {
console.error('Error fetching archive results:', error);
Alert.alert('Error', `Failed to fetch archive results: ${error}`);
} finally {
setIsLoadingResults(false);
}
};
const renderArchiveResults = () => {
if (!archiveResults) return null;
return (
<View style={styles.archiveResultsContainer}>
<Text style={styles.archiveResultsTitle}>
Archive Results ({archiveResults.totalProcessed || 0} processed,{' '}
{archiveResults.totalDownloaded || 0} downloaded)
</Text>
<ScrollView style={styles.archiveResultsScroll}>
{archiveResults.processedItems &&
archiveResults.processedItems.length > 0 ? (
archiveResults.processedItems.map((item: any, index: number) => (
<View key={index} style={styles.archiveItemContainer}>
<Text style={styles.archiveItemTitle}>
{index + 1}. {item.title || 'Untitled'}
</Text>
<Text style={styles.archiveItemUrl}>
Original: {item.originalUrl || 'N/A'}
</Text>
<Text style={styles.archiveItemDownloadUrl}>
Download: {item.downloadableUrl || 'N/A'}
</Text>
<Text style={styles.archiveItemTimestamp}>
Timestamp: {item.timestamp || 'N/A'}
</Text>
{item.downloadError && (
<Text style={styles.archiveItemError}>
Download failed: {item.downloadError}
</Text>
)}
{item.downloadableUrl && !item.downloadError && (
<Text style={styles.archiveItemSuccess}>
Downloaded successfully
</Text>
)}
</View>
))
) : (
<Text style={styles.archiveResultsText}>
{JSON.stringify(archiveResults, null, 2)}
</Text>
)}
</ScrollView>
</View>
);
};
const isUrlValid = !url.trim() || validateUrl(url.trim());
return (
<SafeAreaView style={styles.safeArea} edges={['top']}>
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
>
<ScrollView contentContainerStyle={styles.scrollContainer}>
<View style={styles.header}>
<Text style={styles.headerTitle}>Settings</Text>
<Text style={styles.headerSubtitle}>
Configure your PocketDog preferences
</Text>
</View>
<View style={styles.formContainer}>
<View style={styles.inputGroup}>
<Text style={styles.label}>Service URL (Optional)</Text>
<TextInput
style={[
styles.input,
url.trim() && !isUrlValid && styles.inputError,
]}
placeholder="https://api.example.com"
value={url}
onChangeText={setUrl}
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
returnKeyType="next"
/>
{url.trim() && !isUrlValid && (
<Text style={styles.errorText}>Please enter a valid URL</Text>
)}
<Text style={styles.helpText}>
Leave empty to use default service
</Text>
</View>
<View style={styles.inputGroup}>
<Text style={styles.label}>API Key (Optional)</Text>
<TextInput
style={styles.input}
placeholder="Your API key"
value={apiKey}
onChangeText={setApiKey}
autoCapitalize="none"
autoCorrect={false}
secureTextEntry
returnKeyType="done"
/>
<Text style={styles.helpText}>Required for premium features</Text>
</View>
<View style={styles.buttonContainer}>
<TouchableOpacity
style={styles.saveButton}
onPress={handleSave}
disabled={isSaving || !isUrlValid}
>
<Text style={styles.saveButtonText}>
{isSaving ? 'Saving...' : 'Save Settings'}
</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.clearButton}
onPress={handleClear}
disabled={isSaving}
>
<Text style={styles.clearButtonText}>Clear Settings</Text>
</TouchableOpacity>
</View>
<View style={styles.archiveSection}>
<Text style={styles.sectionTitle}>Archive Manager</Text>
<Text style={styles.sectionSubtitle}>
Fetch and download articles from your archive service
</Text>
<TouchableOpacity
style={[
styles.fetchButton,
isLoadingResults && styles.fetchButtonDisabled,
]}
onPress={handleFetchArchiveResults}
disabled={isLoadingResults}
>
<Text style={styles.fetchButtonText}>
{isLoadingResults
? 'Fetching...'
: '📡 Fetch Archive Results'}
</Text>
</TouchableOpacity>
</View>
{renderArchiveResults()}
</View>
</ScrollView>
</KeyboardAvoidingView>
</SafeAreaView>
);
};
const styles = StyleSheet.create({
safeArea: {
flex: 1,
backgroundColor: '#f8f9fa',
},
container: {
flex: 1,
backgroundColor: '#f8f9fa',
},
scrollContainer: {
flexGrow: 1,
paddingBottom: Platform.OS === 'ios' ? 20 : 24, // Extra padding for Android
},
header: {
padding: 20,
paddingTop: Platform.OS === 'ios' ? 16 : 20, // More padding for Android status bar
backgroundColor: '#ffffff',
borderBottomWidth: 1,
borderBottomColor: '#e9ecef',
},
headerTitle: {
fontSize: 28,
fontWeight: 'bold',
color: '#212529',
marginBottom: 4,
},
headerSubtitle: {
fontSize: 16,
color: '#6c757d',
},
formContainer: {
padding: 20,
},
inputGroup: {
marginBottom: 24,
},
label: {
fontSize: 16,
fontWeight: '600',
color: '#495057',
marginBottom: 8,
},
input: {
backgroundColor: '#ffffff',
borderWidth: 1,
borderColor: '#dee2e6',
borderRadius: 8,
padding: 16,
fontSize: 16,
color: '#212529',
// Android-specific input styling
...(Platform.OS === 'android' && {
paddingVertical: 12,
paddingHorizontal: 16,
}),
},
inputError: {
borderColor: '#dc3545',
},
errorText: {
color: '#dc3545',
fontSize: 14,
marginTop: 4,
},
helpText: {
color: '#6c757d',
fontSize: 14,
marginTop: 4,
},
buttonContainer: {
marginTop: 20,
},
saveButton: {
backgroundColor: '#007bff',
borderRadius: 8,
padding: 16,
alignItems: 'center',
marginBottom: 12,
// Android-specific button styling
...(Platform.OS === 'android' && {
elevation: 2,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.2,
shadowRadius: 2,
}),
},
saveButtonText: {
color: '#ffffff',
fontSize: 16,
fontWeight: '600',
},
clearButton: {
backgroundColor: '#6c757d',
borderRadius: 8,
padding: 16,
alignItems: 'center',
// Android-specific button styling
...(Platform.OS === 'android' && {
elevation: 2,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.2,
shadowRadius: 2,
}),
},
clearButtonText: {
color: '#ffffff',
fontSize: 16,
fontWeight: '600',
},
archiveSection: {
marginTop: 20,
padding: 20,
backgroundColor: '#ffffff',
borderWidth: 1,
borderColor: '#e9ecef',
borderRadius: 8,
},
sectionTitle: {
fontSize: 20,
fontWeight: 'bold',
color: '#212529',
marginBottom: 8,
},
sectionSubtitle: {
fontSize: 16,
color: '#6c757d',
},
fetchButton: {
backgroundColor: '#007bff',
borderRadius: 8,
padding: 16,
alignItems: 'center',
marginTop: 20,
// Android-specific button styling
...(Platform.OS === 'android' && {
elevation: 2,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.2,
shadowRadius: 2,
}),
},
fetchButtonDisabled: {
backgroundColor: '#6c757d',
},
fetchButtonText: {
color: '#ffffff',
fontSize: 16,
fontWeight: '600',
},
archiveResultsContainer: {
padding: 20,
backgroundColor: '#ffffff',
borderWidth: 1,
borderColor: '#e9ecef',
borderRadius: 8,
},
archiveResultsTitle: {
fontSize: 20,
fontWeight: 'bold',
color: '#212529',
marginBottom: 8,
},
archiveResultsScroll: {
maxHeight: 200,
},
archiveItemContainer: {
marginBottom: 12,
},
archiveItemTitle: {
fontSize: 16,
fontWeight: 'bold',
color: '#212529',
},
archiveItemUrl: {
fontSize: 14,
color: '#6c757d',
},
archiveItemDownloadUrl: {
fontSize: 14,
color: '#6c757d',
},
archiveItemTimestamp: {
fontSize: 14,
color: '#6c757d',
},
archiveItemError: {
color: '#dc3545',
fontSize: 14,
},
archiveItemSuccess: {
color: '#28a745',
fontSize: 14,
},
archiveResultsText: {
color: '#6c757d',
fontSize: 14,
},
});
export default SettingsScreen;

530
src/utils/articleUtils.ts Normal file
View File

@@ -0,0 +1,530 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
export interface Article {
id: string;
title: string;
url: string;
htmlContent?: string;
archivedAt: string;
type: 'link' | 'html';
source?: 'manual' | 'archive_api';
timestamp?: string;
isRead?: boolean;
readAt?: string;
}
/**
* Fetches HTML content from a URL
*/
export const fetchHtmlFromUrl = async (url: string): Promise<string> => {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const html = await response.text();
return html;
} catch (error) {
console.error('Error fetching HTML:', error);
throw new Error(`Failed to fetch HTML from ${url}: ${error}`);
}
};
/**
* Extracts title from HTML content
*/
export const extractTitleFromHtml = (html: string): string => {
try {
// Try to extract title from <title> tag
const titleMatch = html.match(/<title[^>]*>([^<]+)<\/title>/i);
if (titleMatch && titleMatch[1]) {
return titleMatch[1].trim();
}
// Try to extract from h1 tag
const h1Match = html.match(/<h1[^>]*>([^<]+)<\/h1>/i);
if (h1Match && h1Match[1]) {
return h1Match[1].trim();
}
// Fallback to URL domain
return 'Untitled Article';
} catch (error) {
console.error('Error extracting title:', error);
return 'Untitled Article';
}
};
/**
* Saves an HTML article to local storage
*/
export const saveHtmlArticle = async (
url: string,
title?: string,
): Promise<Article> => {
try {
// Fetch HTML content
const htmlContent = await fetchHtmlFromUrl(url);
// Extract title if not provided
const extractedTitle = title || extractTitleFromHtml(htmlContent);
// Create article object
const article: Article = {
id: Date.now().toString(),
title: extractedTitle,
url: url,
htmlContent: htmlContent,
archivedAt: new Date().toISOString(),
type: 'html',
};
// Get existing articles
const existingArticles = await AsyncStorage.getItem('articles');
const articles: Article[] = existingArticles
? JSON.parse(existingArticles)
: [];
// Add new article to the beginning
const updatedArticles = [article, ...articles];
// Save to storage
await AsyncStorage.setItem('articles', JSON.stringify(updatedArticles));
return article;
} catch (error) {
console.error('Error saving HTML article:', error);
throw error;
}
};
/**
* Saves a regular link article to local storage
*/
export const saveLinkArticle = async (
url: string,
title?: string,
): Promise<Article> => {
try {
// Create article object
const article: Article = {
id: Date.now().toString(),
title: title || 'Untitled Article',
url: url,
archivedAt: new Date().toISOString(),
type: 'link',
};
// Get existing articles
const existingArticles = await AsyncStorage.getItem('articles');
const articles: Article[] = existingArticles
? JSON.parse(existingArticles)
: [];
// Add new article to the beginning
const updatedArticles = [article, ...articles];
// Save to storage
await AsyncStorage.setItem('articles', JSON.stringify(updatedArticles));
return article;
} catch (error) {
console.error('Error saving link article:', error);
throw error;
}
};
/**
* Loads all articles from local storage
*/
export const loadArticles = async (): Promise<Article[]> => {
try {
const storedArticles = await AsyncStorage.getItem('articles');
return storedArticles ? JSON.parse(storedArticles) : [];
} catch (error) {
console.error('Error loading articles:', error);
return [];
}
};
/**
* Deletes an article from local storage
*/
export const deleteArticle = async (articleId: string): Promise<void> => {
try {
const articles = await loadArticles();
const updatedArticles = articles.filter(
article => article.id !== articleId,
);
await AsyncStorage.setItem('articles', JSON.stringify(updatedArticles));
} catch (error) {
console.error('Error deleting article:', error);
throw error;
}
};
/**
* Validates if a URL is accessible and returns HTML
*/
export const validateHtmlUrl = async (url: string): Promise<boolean> => {
try {
const response = await fetch(url, { method: 'HEAD' });
return response.ok;
} catch (error) {
console.error('Error validating URL:', error);
return false;
}
};
/**
* Makes a GET request with custom headers
*/
export const makeGetRequest = async (
url: string,
apiKey: string,
): Promise<Response> => {
try {
const response = await fetch(url, {
method: 'GET',
headers: {
accept: 'application/json',
'X-ArchiveBox-API-Key': apiKey,
},
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response;
} catch (error) {
console.error('Error making GET request:', error);
throw error;
}
};
/**
* Fetches archive results from the API
*/
export const fetchArchiveResults = async (
baseUrl: string,
apiKey: string,
): Promise<any> => {
try {
// Use the provided parameters instead of hardcoded values
// Clean up the base URL (remove trailing slash and colon if present)
const cleanBaseUrl = baseUrl.replace(/[\/:]+$/, '');
// Try different possible API endpoints
const possibleEndpoints = [
`${cleanBaseUrl}/api/v1/core/archiveresults?limit=200&extractor=title`,
`${cleanBaseUrl}/api/archiveresults?limit=200&extractor=title`,
`${cleanBaseUrl}/archiveresults?limit=200&extractor=title`,
`${cleanBaseUrl}/api/v1/archiveresults?limit=200&extractor=title`,
];
console.log('=== API REQUEST DEBUG ===');
console.log('Original baseUrl:', baseUrl);
console.log('Cleaned baseUrl:', cleanBaseUrl);
console.log('Attempting to fetch from endpoints:', possibleEndpoints);
console.log('Using API key:', apiKey ? 'Present' : 'Missing');
let lastError: Error | null = null;
for (const endpoint of possibleEndpoints) {
try {
console.log(`\n🔗 Trying endpoint: ${endpoint}`);
const response = await fetch(endpoint, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'X-ArchiveBox-API-Key': apiKey,
Authorization: `Bearer ${apiKey}`,
'User-Agent': 'PocketDog/1.0',
},
});
console.log(`✅ Response status: ${response.status}`);
console.log(
`📋 Response headers:`,
Object.fromEntries(response.headers.entries()),
);
if (!response.ok) {
const errorText = await response.text();
console.log(`❌ Error response body: ${errorText}`);
throw new Error(`HTTP ${response.status}: ${errorText}`);
}
const data = await response.json();
console.log('🎉 Successfully fetched data:', data);
// Parse the results and download HTML content
const processedData = await processArchiveResults(data, cleanBaseUrl);
return processedData;
} catch (error) {
console.log(`❌ Failed to fetch from ${endpoint}:`, error);
lastError = error as Error;
continue;
}
}
// If all endpoints failed, throw the last error
throw lastError || new Error('All API endpoints failed');
} catch (error) {
console.error('🚨 Error fetching archive results:', error);
// Provide more specific error messages with URL info
if (error instanceof TypeError && error.message.includes('fetch')) {
throw new Error(
`Network error: Failed to reach ${baseUrl}. Tried endpoints: ${possibleEndpoints.join(
', ',
)}. Please check your internet connection and ensure the server is running.`,
);
}
if (error instanceof Error && error.message.includes('401')) {
throw new Error(
`Authentication failed: Please check your API key. Tried URL: ${baseUrl}`,
);
}
if (error instanceof Error && error.message.includes('404')) {
throw new Error(
`API endpoint not found: Tried multiple endpoints on ${baseUrl}. Attempted URLs: ${possibleEndpoints.join(
', ',
)}`,
);
}
throw new Error(
`Failed to fetch archive results from ${baseUrl}. Tried URLs: ${possibleEndpoints.join(
', ',
)}. Error: ${error.message}`,
);
}
};
/**
* Processes archive results and downloads HTML content
*/
const processArchiveResults = async (data: any, baseUrl: string) => {
try {
console.log('📊 Processing archive results...');
// Check if data has items array
const items = data.items || data.results || data.data || [];
if (!Array.isArray(items)) {
console.log('⚠️ No items array found in response:', data);
return {
...data,
processedItems: [],
totalProcessed: 0,
downloadedArticles: [],
};
}
console.log(`📦 Found ${items.length} items to process`);
const processedItems = [];
const downloadedArticles = [];
for (let index = 0; index < items.length; index++) {
const item = items[index];
// Extract timestamp from the item
const timestamp =
item.snapshot_timestamp ||
item.timestamp ||
item.date ||
item.created_at ||
item.archived_at;
if (!timestamp) {
console.log(`⚠️ No timestamp found for item ${index}:`, item);
processedItems.push({
...item,
downloadableUrl: null,
error: 'No timestamp found',
});
continue;
}
// Create downloadable URL using the format: {base_url}/archive/{timestamp}/mercury/content.html
const downloadableUrl = `${baseUrl}/archive/${timestamp}/mercury/content.html`;
console.log(
`🔗 Item ${index}: ${
item.title || item.url || 'Untitled'
} -> ${downloadableUrl}`,
);
const processedItem = {
...item,
downloadableUrl,
originalUrl: item.url || item.original_url || item.link,
title: item.output || item.title || item.name || 'Untitled',
timestamp: timestamp,
};
processedItems.push(processedItem);
// Download the HTML content using snapshot_timestamp
try {
console.log(`📥 Downloading HTML for item ${index}...`);
const htmlContent = await fetchHtmlFromUrl(downloadableUrl);
// Create article object
const article: Article = {
id: `archive_${timestamp}_${index}`,
title: processedItem.title,
url: processedItem.originalUrl,
htmlContent: htmlContent,
archivedAt: new Date().toISOString(),
type: 'html',
source: 'archive_api',
timestamp: timestamp,
};
// Save to local storage
await saveArticleToStorage(article);
downloadedArticles.push(article);
console.log(
`✅ Successfully downloaded and saved article: ${article.title}`,
);
} catch (downloadError) {
console.log(
`❌ Failed to download HTML for item ${index}:`,
downloadError,
);
processedItem.downloadError = downloadError.message;
}
}
console.log(
`✅ Successfully processed ${processedItems.length} items and downloaded ${downloadedArticles.length} articles`,
);
return {
...data,
processedItems,
totalProcessed: processedItems.length,
downloadedArticles,
totalDownloaded: downloadedArticles.length,
baseUrl: baseUrl,
};
} catch (error) {
console.error('❌ Error processing archive results:', error);
return {
...data,
processedItems: [],
totalProcessed: 0,
downloadedArticles: [],
totalDownloaded: 0,
error: `Processing error: ${error.message}`,
};
}
};
/**
* Saves an article to local storage
*/
const saveArticleToStorage = async (article: Article): Promise<void> => {
try {
// Get existing articles
const existingArticles = await AsyncStorage.getItem('articles');
const articles: Article[] = existingArticles
? JSON.parse(existingArticles)
: [];
// Check if article already exists (by ID)
const existingIndex = articles.findIndex(a => a.id === article.id);
if (existingIndex >= 0) {
// Update existing article
articles[existingIndex] = article;
} else {
// Add new article to the beginning
articles.unshift(article);
}
// Save to storage
await AsyncStorage.setItem('articles', JSON.stringify(articles));
} catch (error) {
console.error('Error saving article to storage:', error);
throw error;
}
};
/**
* Marks an article as read
*/
export const markArticleAsRead = async (articleId: string): Promise<void> => {
try {
const articles = await loadArticles();
const updatedArticles = articles.map(article => {
if (article.id === articleId) {
return {
...article,
isRead: true,
readAt: new Date().toISOString(),
};
}
return article;
});
await AsyncStorage.setItem('articles', JSON.stringify(updatedArticles));
} catch (error) {
console.error('Error marking article as read:', error);
throw error;
}
};
/**
* Marks an article as unread
*/
export const markArticleAsUnread = async (articleId: string): Promise<void> => {
try {
const articles = await loadArticles();
const updatedArticles = articles.map(article => {
if (article.id === articleId) {
return {
...article,
isRead: false,
readAt: undefined,
};
}
return article;
});
await AsyncStorage.setItem('articles', JSON.stringify(updatedArticles));
} catch (error) {
console.error('Error marking article as unread:', error);
throw error;
}
};
/**
* Gets read statistics
*/
export const getReadStats = async (): Promise<{
total: number;
read: number;
unread: number;
}> => {
try {
const articles = await loadArticles();
const total = articles.length;
const read = articles.filter(article => article.isRead).length;
const unread = total - read;
return { total, read, unread };
} catch (error) {
console.error('Error getting read stats:', error);
return { total: 0, read: 0, unread: 0 };
}
};

6662
yarn.lock Normal file

File diff suppressed because it is too large Load Diff