mirror of
https://github.com/Dvorinka/Trackeep.git
synced 2026-06-03 20:12:58 +00:00
869 lines
25 KiB
TypeScript
869 lines
25 KiB
TypeScript
import { ShareIntent, ShareIntentFile } from 'expo-share-intent';
|
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
import {
|
|
ActivityIndicator,
|
|
BackHandler,
|
|
Linking,
|
|
Platform,
|
|
Pressable,
|
|
ScrollView,
|
|
StyleSheet,
|
|
Text,
|
|
TextInput,
|
|
View,
|
|
} from 'react-native';
|
|
import { WebView, WebViewMessageEvent, WebViewNavigation } from 'react-native-webview';
|
|
|
|
import { colors } from '../components/UI';
|
|
import { trackeepApi } from '../lib/api';
|
|
import { buildShareDraft, looksLikeYouTube, ShareDraft } from '../lib/share';
|
|
import { User } from '../types';
|
|
|
|
interface ShareIntentState {
|
|
hasShareIntent: boolean;
|
|
shareIntent: ShareIntent;
|
|
resetShareIntent: (clearNativeModule?: boolean) => void;
|
|
error: string | null;
|
|
}
|
|
|
|
interface WebAppScreenProps {
|
|
instanceUrl: string;
|
|
token: string;
|
|
user: User;
|
|
onLogout: () => Promise<void>;
|
|
shareIntentState: ShareIntentState;
|
|
}
|
|
|
|
type BridgeMessage = {
|
|
type: 'NAV_CHANGE' | 'AUTH_LOGOUT' | 'AUTH_TOKEN' | 'BOOTSTRAP_DONE';
|
|
payload?: Record<string, unknown>;
|
|
};
|
|
|
|
const ROUTES: Array<{ label: string; path: string }> = [
|
|
{ label: 'Dashboard', path: '/app' },
|
|
{ label: 'Bookmarks', path: '/app/bookmarks' },
|
|
{ label: 'Tasks', path: '/app/tasks' },
|
|
{ label: 'Notes', path: '/app/notes' },
|
|
{ label: 'Files', path: '/app/files' },
|
|
{ label: 'YouTube', path: '/app/youtube' },
|
|
{ label: 'Time', path: '/app/time-tracking' },
|
|
];
|
|
|
|
function isInternalUrl(url: string, instanceUrl: string): boolean {
|
|
if (!url) {
|
|
return false;
|
|
}
|
|
|
|
if (url.startsWith('about:blank') || url.startsWith('data:') || url.startsWith('javascript:')) {
|
|
return true;
|
|
}
|
|
|
|
try {
|
|
const target = new URL(url);
|
|
const base = new URL(instanceUrl);
|
|
return target.origin === base.origin;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function pathFromUrl(url: string): string {
|
|
try {
|
|
const parsed = new URL(url);
|
|
return `${parsed.pathname}${parsed.search}${parsed.hash}`;
|
|
} catch {
|
|
return '/app';
|
|
}
|
|
}
|
|
|
|
function toAbsolute(instanceUrl: string, path: string): string {
|
|
return new URL(path, `${instanceUrl}/`).toString();
|
|
}
|
|
|
|
function isGenericShareTitle(title: string, url: string): boolean {
|
|
const normalized = title.trim().toLowerCase();
|
|
if (!normalized) {
|
|
return true;
|
|
}
|
|
|
|
if (normalized === 'shared link' || normalized === 'youtube video') {
|
|
return true;
|
|
}
|
|
|
|
try {
|
|
const host = new URL(url).hostname.toLowerCase().replace(/^www\./, '');
|
|
return normalized === host;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function uniqueFiles(files: ShareIntentFile[]): ShareIntentFile[] {
|
|
const seen = new Set<string>();
|
|
const result: ShareIntentFile[] = [];
|
|
|
|
for (const file of files) {
|
|
const key = `${file.path}|${file.fileName}|${file.size || 0}`;
|
|
if (seen.has(key)) {
|
|
continue;
|
|
}
|
|
seen.add(key);
|
|
result.push(file);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
export function WebAppScreen({ instanceUrl, token, user, onLogout, shareIntentState }: WebAppScreenProps) {
|
|
const { hasShareIntent, shareIntent, resetShareIntent, error: shareIntentError } = shareIntentState;
|
|
|
|
const webRef = useRef<WebView>(null);
|
|
|
|
const [canGoBack, setCanGoBack] = useState(false);
|
|
const [canGoForward, setCanGoForward] = useState(false);
|
|
const [webLoading, setWebLoading] = useState(true);
|
|
const [webError, setWebError] = useState<string | null>(null);
|
|
const [currentPath, setCurrentPath] = useState('/app');
|
|
|
|
const [showManualShare, setShowManualShare] = useState(false);
|
|
const [manualTitle, setManualTitle] = useState('');
|
|
const [manualUrl, setManualUrl] = useState('');
|
|
const [manualDescription, setManualDescription] = useState('');
|
|
|
|
const [incomingDraft, setIncomingDraft] = useState<ShareDraft | null>(null);
|
|
const [incomingFiles, setIncomingFiles] = useState<ShareIntentFile[]>([]);
|
|
const [draftMetadataLoading, setDraftMetadataLoading] = useState(false);
|
|
|
|
const [shareBusy, setShareBusy] = useState(false);
|
|
const [shareError, setShareError] = useState<string | null>(null);
|
|
const [shareInfo, setShareInfo] = useState<string | null>(null);
|
|
|
|
const appUrl = toAbsolute(instanceUrl, '/app');
|
|
const incomingIsYouTube = incomingDraft ? looksLikeYouTube(incomingDraft.url) : false;
|
|
|
|
const injectedBeforeLoad = useMemo(() => {
|
|
const injectedUser = JSON.stringify(user);
|
|
|
|
return `
|
|
(function() {
|
|
try {
|
|
localStorage.setItem('trackeep_token', ${JSON.stringify(token)});
|
|
localStorage.setItem('token', ${JSON.stringify(token)});
|
|
localStorage.setItem('trackeep_user', JSON.stringify(${injectedUser}));
|
|
localStorage.setItem('user', JSON.stringify(${injectedUser}));
|
|
window.__TRACKEEP_MOBILE__ = true;
|
|
|
|
if (!window.__TRACKEEP_BRIDGE__) {
|
|
window.__TRACKEEP_BRIDGE__ = true;
|
|
|
|
var postBridge = function(type, payload) {
|
|
try {
|
|
window.ReactNativeWebView && window.ReactNativeWebView.postMessage(
|
|
JSON.stringify({ type: type, payload: payload || {} })
|
|
);
|
|
} catch (_) {}
|
|
};
|
|
|
|
var notifyAuthState = function() {
|
|
var activeToken = localStorage.getItem('trackeep_token') || localStorage.getItem('token');
|
|
if (!activeToken) {
|
|
postBridge('AUTH_LOGOUT', {});
|
|
} else {
|
|
postBridge('AUTH_TOKEN', {});
|
|
}
|
|
};
|
|
|
|
var notifyNav = function() {
|
|
postBridge('NAV_CHANGE', {
|
|
href: window.location.href,
|
|
path: window.location.pathname + window.location.search + window.location.hash,
|
|
});
|
|
};
|
|
|
|
var originalSetItem = localStorage.setItem.bind(localStorage);
|
|
var originalRemoveItem = localStorage.removeItem.bind(localStorage);
|
|
|
|
localStorage.setItem = function(key, value) {
|
|
originalSetItem(key, value);
|
|
if (key === 'trackeep_token' || key === 'token') {
|
|
notifyAuthState();
|
|
}
|
|
};
|
|
|
|
localStorage.removeItem = function(key) {
|
|
originalRemoveItem(key);
|
|
if (key === 'trackeep_token' || key === 'token') {
|
|
notifyAuthState();
|
|
}
|
|
};
|
|
|
|
var originalPushState = history.pushState.bind(history);
|
|
history.pushState = function() {
|
|
var result = originalPushState.apply(history, arguments);
|
|
notifyNav();
|
|
return result;
|
|
};
|
|
|
|
var originalReplaceState = history.replaceState.bind(history);
|
|
history.replaceState = function() {
|
|
var result = originalReplaceState.apply(history, arguments);
|
|
notifyNav();
|
|
return result;
|
|
};
|
|
|
|
window.addEventListener('popstate', notifyNav);
|
|
notifyNav();
|
|
postBridge('BOOTSTRAP_DONE', { hasToken: true });
|
|
}
|
|
} catch (error) {
|
|
console.error('Mobile bootstrap failed', error);
|
|
}
|
|
true;
|
|
})();
|
|
`;
|
|
}, [token, user]);
|
|
|
|
const instanceLabel = useMemo(() => {
|
|
try {
|
|
return new URL(instanceUrl).host;
|
|
} catch {
|
|
return instanceUrl;
|
|
}
|
|
}, [instanceUrl]);
|
|
|
|
useEffect(() => {
|
|
setShareError(shareIntentError);
|
|
}, [shareIntentError]);
|
|
|
|
useEffect(() => {
|
|
if (!hasShareIntent) {
|
|
return;
|
|
}
|
|
|
|
setShareInfo(null);
|
|
setShareError(null);
|
|
|
|
const draft = buildShareDraft(shareIntent);
|
|
const files = uniqueFiles(shareIntent.files || []);
|
|
|
|
if (draft) {
|
|
setIncomingDraft(draft);
|
|
setIncomingFiles([]);
|
|
return;
|
|
}
|
|
|
|
if (files.length > 0) {
|
|
setIncomingFiles(files);
|
|
setIncomingDraft(null);
|
|
return;
|
|
}
|
|
|
|
setShareError('Shared content was received but no supported URL or file was detected.');
|
|
resetShareIntent();
|
|
}, [
|
|
hasShareIntent,
|
|
shareIntent.text,
|
|
shareIntent.webUrl,
|
|
shareIntent.files,
|
|
shareIntent.meta,
|
|
resetShareIntent,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
if (!incomingDraft?.url) {
|
|
return;
|
|
}
|
|
|
|
let cancelled = false;
|
|
setDraftMetadataLoading(true);
|
|
|
|
void trackeepApi.bookmarks
|
|
.metadata(instanceUrl, token, incomingDraft.url)
|
|
.then((metadata) => {
|
|
if (cancelled || !metadata) {
|
|
return;
|
|
}
|
|
|
|
setIncomingDraft((current) => {
|
|
if (!current || current.url !== incomingDraft.url) {
|
|
return current;
|
|
}
|
|
|
|
const betterTitle =
|
|
metadata.title && metadata.title.trim().length > 0 && isGenericShareTitle(current.title, current.url)
|
|
? metadata.title.trim()
|
|
: current.title;
|
|
|
|
const betterDescription =
|
|
metadata.description && metadata.description.trim().length > 0 && current.description.trim().length < 12
|
|
? metadata.description.trim()
|
|
: current.description;
|
|
|
|
return {
|
|
...current,
|
|
title: betterTitle,
|
|
description: betterDescription,
|
|
};
|
|
});
|
|
})
|
|
.catch(() => {
|
|
// metadata enrichment is best-effort only
|
|
})
|
|
.finally(() => {
|
|
if (!cancelled) {
|
|
setDraftMetadataLoading(false);
|
|
}
|
|
});
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [incomingDraft?.url, instanceUrl, token]);
|
|
|
|
useEffect(() => {
|
|
if (Platform.OS !== 'android') {
|
|
return;
|
|
}
|
|
|
|
const sub = BackHandler.addEventListener('hardwareBackPress', () => {
|
|
if (canGoBack) {
|
|
webRef.current?.goBack();
|
|
return true;
|
|
}
|
|
return false;
|
|
});
|
|
|
|
return () => sub.remove();
|
|
}, [canGoBack]);
|
|
|
|
const clearIncomingShare = useCallback(() => {
|
|
setIncomingDraft(null);
|
|
setIncomingFiles([]);
|
|
setDraftMetadataLoading(false);
|
|
resetShareIntent();
|
|
}, [resetShareIntent]);
|
|
|
|
const refreshWebView = useCallback(() => {
|
|
webRef.current?.reload();
|
|
}, []);
|
|
|
|
const navigateTo = useCallback(
|
|
(path: string) => {
|
|
const target = toAbsolute(instanceUrl, path);
|
|
webRef.current?.injectJavaScript(`window.location.assign(${JSON.stringify(target)}); true;`);
|
|
setCurrentPath(path);
|
|
},
|
|
[instanceUrl],
|
|
);
|
|
|
|
const openCurrentInBrowser = useCallback(async () => {
|
|
const target = toAbsolute(instanceUrl, currentPath || '/app');
|
|
await Linking.openURL(target);
|
|
}, [instanceUrl, currentPath]);
|
|
|
|
const saveBookmark = async (draft: ShareDraft, routeAfterSave = '/app/bookmarks') => {
|
|
setShareBusy(true);
|
|
setShareError(null);
|
|
setShareInfo(null);
|
|
|
|
try {
|
|
await trackeepApi.bookmarks.create(instanceUrl, token, {
|
|
title: draft.title.trim(),
|
|
url: draft.url.trim(),
|
|
description: draft.description.trim() || 'Shared from Trackeep Mobile',
|
|
});
|
|
|
|
const isYoutube = looksLikeYouTube(draft.url);
|
|
setShareInfo(isYoutube ? 'Saved YouTube link to Trackeep.' : 'Saved bookmark to Trackeep.');
|
|
clearIncomingShare();
|
|
navigateTo(routeAfterSave);
|
|
} catch (error) {
|
|
setShareError(error instanceof Error ? error.message : 'Failed to save shared bookmark.');
|
|
} finally {
|
|
setShareBusy(false);
|
|
}
|
|
};
|
|
|
|
const uploadIncomingFiles = async (files: ShareIntentFile[]) => {
|
|
setShareBusy(true);
|
|
setShareError(null);
|
|
setShareInfo(null);
|
|
|
|
try {
|
|
for (const file of files) {
|
|
await trackeepApi.files.uploadFromUri(instanceUrl, token, {
|
|
uri: file.path,
|
|
name: file.fileName,
|
|
mimeType: file.mimeType,
|
|
description: 'Shared from mobile',
|
|
});
|
|
}
|
|
|
|
setShareInfo(
|
|
files.length === 1
|
|
? `Uploaded \"${files[0].fileName}\" to Trackeep Files.`
|
|
: `Uploaded ${files.length} shared files to Trackeep Files.`,
|
|
);
|
|
clearIncomingShare();
|
|
navigateTo('/app/files');
|
|
} catch (error) {
|
|
setShareError(error instanceof Error ? error.message : 'Failed to upload shared files.');
|
|
} finally {
|
|
setShareBusy(false);
|
|
}
|
|
};
|
|
|
|
const submitManualShare = async () => {
|
|
const draft: ShareDraft = {
|
|
title: manualTitle.trim(),
|
|
url: manualUrl.trim(),
|
|
description: manualDescription.trim(),
|
|
source: 'manual',
|
|
};
|
|
|
|
if (!draft.title || !draft.url) {
|
|
setShareError('Title and URL are required for quick share.');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
new URL(draft.url);
|
|
} catch {
|
|
setShareError('Quick share URL must be a valid absolute URL.');
|
|
return;
|
|
}
|
|
|
|
await saveBookmark(draft, looksLikeYouTube(draft.url) ? '/app/youtube' : '/app/bookmarks');
|
|
setManualTitle('');
|
|
setManualUrl('');
|
|
setManualDescription('');
|
|
setShowManualShare(false);
|
|
};
|
|
|
|
const onWebMessage = useCallback(
|
|
(event: WebViewMessageEvent) => {
|
|
const raw = event.nativeEvent.data;
|
|
if (!raw) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const message = JSON.parse(raw) as BridgeMessage;
|
|
|
|
if (message.type === 'NAV_CHANGE') {
|
|
const path = typeof message.payload?.path === 'string' ? message.payload.path : null;
|
|
if (path) {
|
|
setCurrentPath(path);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (message.type === 'AUTH_LOGOUT') {
|
|
void onLogout();
|
|
return;
|
|
}
|
|
} catch {
|
|
// Ignore malformed bridge messages.
|
|
}
|
|
},
|
|
[onLogout],
|
|
);
|
|
|
|
const onShouldStartLoadWithRequest = useCallback(
|
|
(request: { url: string }) => {
|
|
if (isInternalUrl(request.url, instanceUrl)) {
|
|
return true;
|
|
}
|
|
|
|
void Linking.openURL(request.url);
|
|
return false;
|
|
},
|
|
[instanceUrl],
|
|
);
|
|
|
|
const onNavigationStateChange = useCallback((state: WebViewNavigation) => {
|
|
setCanGoBack(state.canGoBack);
|
|
setCanGoForward(state.canGoForward);
|
|
setCurrentPath(pathFromUrl(state.url));
|
|
}, []);
|
|
|
|
return (
|
|
<View style={styles.root}>
|
|
<View style={styles.topBar}>
|
|
<Text style={styles.instanceText}>Connected: {instanceLabel}</Text>
|
|
|
|
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.routePills}>
|
|
{ROUTES.map((route) => {
|
|
const active = currentPath.startsWith(route.path);
|
|
return (
|
|
<Pressable
|
|
key={route.path}
|
|
style={[styles.routePill, active ? styles.routePillActive : undefined]}
|
|
onPress={() => navigateTo(route.path)}
|
|
>
|
|
<Text style={[styles.routePillText, active ? styles.routePillTextActive : undefined]}>{route.label}</Text>
|
|
</Pressable>
|
|
);
|
|
})}
|
|
</ScrollView>
|
|
|
|
<View style={styles.topButtons}>
|
|
<Pressable style={styles.topButton} onPress={() => setShowManualShare((prev) => !prev)}>
|
|
<Text style={styles.topButtonText}>Quick Share</Text>
|
|
</Pressable>
|
|
<Pressable style={styles.topButton} onPress={refreshWebView}>
|
|
<Text style={styles.topButtonText}>Reload</Text>
|
|
</Pressable>
|
|
<Pressable style={styles.topButton} onPress={() => void openCurrentInBrowser()}>
|
|
<Text style={styles.topButtonText}>Browser</Text>
|
|
</Pressable>
|
|
<Pressable style={[styles.topButton, styles.topButtonDanger]} onPress={() => void onLogout()}>
|
|
<Text style={[styles.topButtonText, styles.topButtonDangerText]}>Logout</Text>
|
|
</Pressable>
|
|
</View>
|
|
</View>
|
|
|
|
{showManualShare ? (
|
|
<View style={styles.sharePanel}>
|
|
<Text style={styles.sharePanelTitle}>Quick Share To Trackeep</Text>
|
|
<TextInput
|
|
placeholder="Title"
|
|
placeholderTextColor={colors.muted}
|
|
style={styles.input}
|
|
value={manualTitle}
|
|
onChangeText={setManualTitle}
|
|
/>
|
|
<TextInput
|
|
placeholder="https://example.com"
|
|
autoCapitalize="none"
|
|
autoCorrect={false}
|
|
keyboardType="url"
|
|
placeholderTextColor={colors.muted}
|
|
style={styles.input}
|
|
value={manualUrl}
|
|
onChangeText={setManualUrl}
|
|
/>
|
|
<TextInput
|
|
placeholder="Description (optional)"
|
|
placeholderTextColor={colors.muted}
|
|
style={[styles.input, styles.textarea]}
|
|
value={manualDescription}
|
|
onChangeText={setManualDescription}
|
|
multiline
|
|
/>
|
|
{looksLikeYouTube(manualUrl.trim()) ? (
|
|
<Text style={styles.infoTextInline}>YouTube link detected. It will be saved and opened in YouTube section.</Text>
|
|
) : null}
|
|
<View style={styles.shareActions}>
|
|
<Pressable style={styles.secondaryButton} onPress={() => setShowManualShare(false)}>
|
|
<Text style={styles.secondaryButtonText}>Cancel</Text>
|
|
</Pressable>
|
|
<Pressable style={styles.primaryButton} onPress={() => void submitManualShare()} disabled={shareBusy}>
|
|
<Text style={styles.primaryButtonText}>{shareBusy ? 'Saving...' : 'Save Bookmark'}</Text>
|
|
</Pressable>
|
|
</View>
|
|
</View>
|
|
) : null}
|
|
|
|
{incomingDraft ? (
|
|
<View style={styles.sharePanel}>
|
|
<Text style={styles.sharePanelTitle}>Incoming Shared Link</Text>
|
|
<Text style={styles.shareLine}>Title: {incomingDraft.title}</Text>
|
|
<Text style={styles.shareLine}>URL: {incomingDraft.url}</Text>
|
|
{incomingDraft.description ? <Text style={styles.shareLine}>Text: {incomingDraft.description}</Text> : null}
|
|
{draftMetadataLoading ? <Text style={styles.infoTextInline}>Fetching page metadata...</Text> : null}
|
|
<View style={styles.shareActions}>
|
|
<Pressable style={styles.secondaryButton} onPress={clearIncomingShare} disabled={shareBusy}>
|
|
<Text style={styles.secondaryButtonText}>Dismiss</Text>
|
|
</Pressable>
|
|
{incomingIsYouTube ? (
|
|
<Pressable
|
|
style={styles.primaryButton}
|
|
onPress={() => void saveBookmark(incomingDraft, '/app/youtube')}
|
|
disabled={shareBusy}
|
|
>
|
|
<Text style={styles.primaryButtonText}>{shareBusy ? 'Saving...' : 'Save + Open YouTube'}</Text>
|
|
</Pressable>
|
|
) : null}
|
|
<Pressable
|
|
style={[styles.primaryButton, incomingIsYouTube ? styles.primaryButtonAlt : undefined]}
|
|
onPress={() => void saveBookmark(incomingDraft)}
|
|
disabled={shareBusy}
|
|
>
|
|
<Text style={styles.primaryButtonText}>{shareBusy ? 'Saving...' : 'Save To Bookmarks'}</Text>
|
|
</Pressable>
|
|
</View>
|
|
</View>
|
|
) : null}
|
|
|
|
{incomingFiles.length > 0 ? (
|
|
<View style={styles.sharePanel}>
|
|
<Text style={styles.sharePanelTitle}>Incoming Shared Files ({incomingFiles.length})</Text>
|
|
{incomingFiles.slice(0, 3).map((file) => (
|
|
<Text key={`${file.path}-${file.fileName}`} style={styles.shareLine}>
|
|
• {file.fileName}
|
|
</Text>
|
|
))}
|
|
{incomingFiles.length > 3 ? <Text style={styles.shareLine}>...and {incomingFiles.length - 3} more</Text> : null}
|
|
<View style={styles.shareActions}>
|
|
<Pressable style={styles.secondaryButton} onPress={clearIncomingShare} disabled={shareBusy}>
|
|
<Text style={styles.secondaryButtonText}>Dismiss</Text>
|
|
</Pressable>
|
|
<Pressable
|
|
style={styles.primaryButton}
|
|
onPress={() => void uploadIncomingFiles(incomingFiles)}
|
|
disabled={shareBusy}
|
|
>
|
|
<Text style={styles.primaryButtonText}>{shareBusy ? 'Uploading...' : 'Upload To Files'}</Text>
|
|
</Pressable>
|
|
</View>
|
|
</View>
|
|
) : null}
|
|
|
|
{shareError ? <Text style={styles.errorText}>{shareError}</Text> : null}
|
|
{shareInfo ? <Text style={styles.infoText}>{shareInfo}</Text> : null}
|
|
{webError ? <Text style={styles.errorText}>Web app failed to load: {webError}</Text> : null}
|
|
|
|
<View style={styles.webContainer}>
|
|
<WebView
|
|
ref={webRef}
|
|
source={{ uri: appUrl }}
|
|
injectedJavaScriptBeforeContentLoaded={injectedBeforeLoad}
|
|
sharedCookiesEnabled
|
|
thirdPartyCookiesEnabled
|
|
javaScriptEnabled
|
|
domStorageEnabled
|
|
cacheEnabled
|
|
setSupportMultipleWindows={false}
|
|
onMessage={onWebMessage}
|
|
onShouldStartLoadWithRequest={onShouldStartLoadWithRequest}
|
|
onLoadStart={() => {
|
|
setWebLoading(true);
|
|
setWebError(null);
|
|
}}
|
|
onLoadEnd={() => setWebLoading(false)}
|
|
onError={(event) => {
|
|
setWebLoading(false);
|
|
setWebError(event.nativeEvent.description || 'Unknown network error');
|
|
}}
|
|
onNavigationStateChange={onNavigationStateChange}
|
|
/>
|
|
{webLoading ? (
|
|
<View style={styles.loadingOverlay}>
|
|
<ActivityIndicator size="large" color={colors.primary} />
|
|
</View>
|
|
) : null}
|
|
</View>
|
|
|
|
<View style={styles.navBar}>
|
|
<Pressable
|
|
style={[styles.navButton, !canGoBack ? styles.navButtonDisabled : undefined]}
|
|
onPress={() => webRef.current?.goBack()}
|
|
disabled={!canGoBack}
|
|
>
|
|
<Text style={styles.navButtonText}>Back</Text>
|
|
</Pressable>
|
|
<Pressable
|
|
style={[styles.navButton, !canGoForward ? styles.navButtonDisabled : undefined]}
|
|
onPress={() => webRef.current?.goForward()}
|
|
disabled={!canGoForward}
|
|
>
|
|
<Text style={styles.navButtonText}>Forward</Text>
|
|
</Pressable>
|
|
<Pressable style={styles.navButton} onPress={refreshWebView}>
|
|
<Text style={styles.navButtonText}>Refresh</Text>
|
|
</Pressable>
|
|
</View>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
root: {
|
|
flex: 1,
|
|
backgroundColor: colors.background,
|
|
},
|
|
topBar: {
|
|
borderBottomWidth: 1,
|
|
borderColor: '#D8E1EC',
|
|
backgroundColor: '#FFFFFF',
|
|
paddingHorizontal: 12,
|
|
paddingVertical: 8,
|
|
gap: 8,
|
|
},
|
|
instanceText: {
|
|
color: colors.muted,
|
|
fontSize: 12,
|
|
fontWeight: '600',
|
|
},
|
|
routePills: {
|
|
gap: 8,
|
|
paddingRight: 8,
|
|
},
|
|
routePill: {
|
|
borderWidth: 1,
|
|
borderColor: '#D6DEE8',
|
|
borderRadius: 999,
|
|
paddingHorizontal: 10,
|
|
paddingVertical: 6,
|
|
backgroundColor: '#F8FAFC',
|
|
},
|
|
routePillActive: {
|
|
backgroundColor: '#E6F6FB',
|
|
borderColor: '#8adcf2',
|
|
},
|
|
routePillText: {
|
|
fontSize: 12,
|
|
fontWeight: '600',
|
|
color: colors.muted,
|
|
},
|
|
routePillTextActive: {
|
|
color: colors.primaryDark,
|
|
},
|
|
topButtons: {
|
|
flexDirection: 'row',
|
|
gap: 8,
|
|
flexWrap: 'wrap',
|
|
},
|
|
topButton: {
|
|
backgroundColor: '#EDF2F7',
|
|
borderRadius: 8,
|
|
paddingHorizontal: 10,
|
|
paddingVertical: 7,
|
|
},
|
|
topButtonText: {
|
|
color: colors.text,
|
|
fontSize: 12,
|
|
fontWeight: '700',
|
|
},
|
|
topButtonDanger: {
|
|
backgroundColor: '#FEE2E2',
|
|
},
|
|
topButtonDangerText: {
|
|
color: '#991B1B',
|
|
},
|
|
sharePanel: {
|
|
marginHorizontal: 12,
|
|
marginTop: 10,
|
|
padding: 12,
|
|
borderRadius: 12,
|
|
borderWidth: 1,
|
|
borderColor: '#D6DEE8',
|
|
backgroundColor: '#FFFFFF',
|
|
gap: 8,
|
|
},
|
|
sharePanelTitle: {
|
|
color: colors.text,
|
|
fontWeight: '700',
|
|
fontSize: 14,
|
|
},
|
|
shareLine: {
|
|
color: colors.muted,
|
|
fontSize: 13,
|
|
},
|
|
input: {
|
|
borderWidth: 1,
|
|
borderColor: '#D6DEE8',
|
|
borderRadius: 10,
|
|
backgroundColor: '#FFFFFF',
|
|
color: colors.text,
|
|
paddingHorizontal: 10,
|
|
paddingVertical: 9,
|
|
},
|
|
textarea: {
|
|
minHeight: 80,
|
|
textAlignVertical: 'top',
|
|
},
|
|
shareActions: {
|
|
flexDirection: 'row',
|
|
gap: 8,
|
|
justifyContent: 'flex-end',
|
|
flexWrap: 'wrap',
|
|
},
|
|
secondaryButton: {
|
|
borderWidth: 1,
|
|
borderColor: '#D6DEE8',
|
|
borderRadius: 8,
|
|
paddingHorizontal: 10,
|
|
paddingVertical: 8,
|
|
backgroundColor: '#F8FAFC',
|
|
},
|
|
secondaryButtonText: {
|
|
color: colors.text,
|
|
fontWeight: '600',
|
|
fontSize: 12,
|
|
},
|
|
primaryButton: {
|
|
borderRadius: 8,
|
|
paddingHorizontal: 10,
|
|
paddingVertical: 8,
|
|
backgroundColor: colors.primary,
|
|
},
|
|
primaryButtonAlt: {
|
|
backgroundColor: colors.primaryDark,
|
|
},
|
|
primaryButtonText: {
|
|
color: '#FFFFFF',
|
|
fontWeight: '700',
|
|
fontSize: 12,
|
|
},
|
|
errorText: {
|
|
color: '#B91C1C',
|
|
fontSize: 12,
|
|
fontWeight: '600',
|
|
paddingHorizontal: 14,
|
|
paddingTop: 8,
|
|
},
|
|
infoText: {
|
|
color: '#047857',
|
|
fontSize: 12,
|
|
fontWeight: '600',
|
|
paddingHorizontal: 14,
|
|
paddingTop: 8,
|
|
},
|
|
infoTextInline: {
|
|
color: '#047857',
|
|
fontSize: 12,
|
|
fontWeight: '600',
|
|
},
|
|
webContainer: {
|
|
flex: 1,
|
|
marginTop: 8,
|
|
overflow: 'hidden',
|
|
backgroundColor: '#FFFFFF',
|
|
},
|
|
loadingOverlay: {
|
|
...StyleSheet.absoluteFillObject,
|
|
backgroundColor: 'rgba(255,255,255,0.7)',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
},
|
|
navBar: {
|
|
flexDirection: 'row',
|
|
borderTopWidth: 1,
|
|
borderColor: '#D8E1EC',
|
|
backgroundColor: '#FFFFFF',
|
|
paddingHorizontal: 10,
|
|
paddingVertical: 8,
|
|
gap: 8,
|
|
},
|
|
navButton: {
|
|
flex: 1,
|
|
borderRadius: 8,
|
|
borderWidth: 1,
|
|
borderColor: '#D6DEE8',
|
|
backgroundColor: '#F8FAFC',
|
|
minHeight: 36,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
},
|
|
navButtonDisabled: {
|
|
opacity: 0.45,
|
|
},
|
|
navButtonText: {
|
|
color: colors.text,
|
|
fontWeight: '700',
|
|
fontSize: 12,
|
|
},
|
|
});
|