small fix, don't worry about it

This commit is contained in:
Tomas Dvorak
2026-04-10 12:06:01 +02:00
parent 954a1a1080
commit c6a99c7e21
214 changed files with 40237 additions and 2828 deletions
+102
View File
@@ -0,0 +1,102 @@
import React, { useMemo, useState } from 'react';
import { Text, View } from 'react-native';
import { useAppContext } from '../context/AppContext';
import {
Button,
ErrorText,
Input,
Label,
ScreenShell,
SectionCard,
uiStyles,
} from '../components/UI';
type AuthMode = 'login' | 'register';
export function AuthScreen() {
const { instanceUrl, login, register, busy } = useAppContext();
const [mode, setMode] = useState<AuthMode>('login');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [username, setUsername] = useState('');
const [fullName, setFullName] = useState('');
const [error, setError] = useState<string | null>(null);
const ctaLabel = useMemo(() => (mode === 'login' ? 'Sign In' : 'Create Account'), [mode]);
const submit = async () => {
setError(null);
try {
if (mode === 'login') {
await login(email.trim(), password);
return;
}
await register({
email: email.trim(),
username: username.trim(),
fullName: fullName.trim(),
password,
});
} catch (err) {
setError(err instanceof Error ? err.message : 'Authentication failed.');
}
};
return (
<ScreenShell
title={mode === 'login' ? 'Sign In' : 'Register'}
subtitle={instanceUrl ? `Instance: ${instanceUrl}` : undefined}
>
<SectionCard>
<View style={uiStyles.row}>
<Button
label="Login"
onPress={() => setMode('login')}
variant={mode === 'login' ? 'primary' : 'secondary'}
/>
<Button
label="Register"
onPress={() => setMode('register')}
variant={mode === 'register' ? 'primary' : 'secondary'}
/>
</View>
<Label>Email</Label>
<Input
autoCapitalize="none"
autoCorrect={false}
keyboardType="email-address"
placeholder="you@example.com"
value={email}
onChangeText={setEmail}
/>
{mode === 'register' ? (
<>
<Label>Username</Label>
<Input autoCapitalize="none" autoCorrect={false} placeholder="yourname" value={username} onChangeText={setUsername} />
<Label>Full Name</Label>
<Input placeholder="Your Name" value={fullName} onChangeText={setFullName} />
</>
) : null}
<Label>Password</Label>
<Input secureTextEntry placeholder="••••••••" value={password} onChangeText={setPassword} />
<Button label={ctaLabel} onPress={submit} loading={busy} />
<ErrorText message={error} />
{mode === 'register' ? (
<Text style={uiStyles.muted}>
Registration succeeds for first setup or when an admin allows user creation.
</Text>
) : null}
</SectionCard>
</ScreenShell>
);
}
@@ -0,0 +1,83 @@
import React, { useState } from 'react';
import { Text, View } from 'react-native';
import { useAppContext } from '../context/AppContext';
import { trackeepApi } from '../lib/api';
import {
Button,
ErrorText,
Input,
Label,
ScreenShell,
SectionCard,
uiStyles,
} from '../components/UI';
export function ConnectionSetupScreen() {
const { setInstanceUrl } = useAppContext();
const [inputUrl, setInputUrl] = useState('');
const [loading, setLoading] = useState(false);
const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const handleTest = async () => {
setLoading(true);
setError(null);
setMessage(null);
try {
const result = await trackeepApi.probeInstance(inputUrl);
const versionText = result.version ? ` | Version ${result.version}` : '';
setMessage(`Connected: ${result.normalizedUrl}${versionText}`);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to connect to this instance.');
} finally {
setLoading(false);
}
};
const handleSave = async () => {
setLoading(true);
setError(null);
try {
const result = await trackeepApi.probeInstance(inputUrl);
await setInstanceUrl(result.normalizedUrl);
} catch (err) {
setError(err instanceof Error ? err.message : 'Unable to save this instance URL.');
} finally {
setLoading(false);
}
};
return (
<ScreenShell
title="Connect To Your Trackeep"
subtitle="Point the mobile app at your self-hosted instance."
>
<SectionCard>
<Label>Instance URL</Label>
<Input
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
placeholder="https://trackeep.yourdomain.com"
value={inputUrl}
onChangeText={setInputUrl}
/>
<Text style={uiStyles.muted}>
For local testing use `http://10.0.2.2:8080` on Android emulator or `http://localhost:8080` on iOS simulator.
</Text>
<View style={uiStyles.row}>
<Button label="Test Connection" variant="secondary" onPress={handleTest} loading={loading} />
<Button label="Save & Continue" onPress={handleSave} loading={loading} />
</View>
<ErrorText message={error} />
{message ? <Text style={[uiStyles.muted, { color: '#047857', fontWeight: '600' }]}>{message}</Text> : null}
</SectionCard>
</ScreenShell>
);
}
+155
View File
@@ -0,0 +1,155 @@
import * as DocumentPicker from 'expo-document-picker';
import React, { useCallback, useEffect, useState } from 'react';
import { Linking, Text, View } from 'react-native';
import { trackeepApi } from '../lib/api';
import { formatDate, formatFileSize } from '../lib/format';
import { FileItem } from '../types';
import {
Button,
ErrorText,
Input,
Label,
ScreenShell,
SectionCard,
colors,
uiStyles,
} from '../components/UI';
interface FilesScreenProps {
instanceUrl: string;
token: string;
isActive: boolean;
}
export function FilesScreen({ instanceUrl, token, isActive }: FilesScreenProps) {
const [files, setFiles] = useState<FileItem[]>([]);
const [description, setDescription] = useState('');
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const loadFiles = useCallback(async () => {
setLoading(true);
setError(null);
try {
const list = await trackeepApi.files.list(instanceUrl, token);
setFiles(list);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load files.');
} finally {
setLoading(false);
}
}, [instanceUrl, token]);
useEffect(() => {
if (isActive) {
void loadFiles();
}
}, [isActive, loadFiles]);
const pickAndUpload = async () => {
setError(null);
const pickerResult = await DocumentPicker.getDocumentAsync({
multiple: false,
copyToCacheDirectory: true,
});
if (pickerResult.canceled || pickerResult.assets.length === 0) {
return;
}
setSaving(true);
try {
await trackeepApi.files.upload(instanceUrl, token, pickerResult.assets[0], description);
setDescription('');
await loadFiles();
} catch (err) {
setError(err instanceof Error ? err.message : 'File upload failed.');
} finally {
setSaving(false);
}
};
const deleteFile = async (id: number) => {
setSaving(true);
setError(null);
try {
await trackeepApi.files.remove(instanceUrl, token, id);
await loadFiles();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete file.');
} finally {
setSaving(false);
}
};
const openDownload = async (id: number) => {
const url = trackeepApi.files.getDownloadUrl(instanceUrl, token, id);
try {
const supported = await Linking.canOpenURL(url);
if (!supported) {
throw new Error('This device cannot open the download URL.');
}
await Linking.openURL(url);
} catch (err) {
setError(err instanceof Error ? err.message : 'Unable to open download URL.');
}
};
return (
<ScreenShell title="Files" subtitle="Upload from your phone and manage file records on your server.">
<SectionCard>
<Label>Upload</Label>
<Input
placeholder="Optional description"
value={description}
onChangeText={setDescription}
/>
<View style={uiStyles.row}>
<Button label="Refresh" variant="secondary" onPress={loadFiles} loading={loading} />
<Button label="Pick & Upload" onPress={pickAndUpload} loading={saving} />
</View>
<ErrorText message={error} />
</SectionCard>
{files.map((file) => (
<SectionCard key={file.id}>
<View style={uiStyles.splitRow}>
<Text style={{ color: colors.text, fontWeight: '700', flex: 1 }}>{file.original_name}</Text>
<View style={uiStyles.chip}>
<Text style={uiStyles.chipText}>{file.file_type}</Text>
</View>
</View>
<Text style={uiStyles.muted}>Size: {formatFileSize(file.file_size)}</Text>
<Text style={uiStyles.muted}>Uploaded: {formatDate(file.created_at)}</Text>
{file.description ? <Text style={uiStyles.muted}>Description: {file.description}</Text> : null}
<View style={uiStyles.row}>
<Button label="Download" variant="secondary" onPress={() => void openDownload(file.id)} />
<Button
label="Delete"
variant="danger"
onPress={() => {
void deleteFile(file.id);
}}
loading={saving}
/>
</View>
</SectionCard>
))}
{!loading && files.length === 0 ? (
<SectionCard>
<Text style={uiStyles.muted}>No files uploaded yet.</Text>
</SectionCard>
) : null}
</ScreenShell>
);
}
+110
View File
@@ -0,0 +1,110 @@
import React, { useMemo, useState } from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { colors } from '../components/UI';
import { FilesScreen } from './FilesScreen';
import { NotesScreen } from './NotesScreen';
import { SettingsScreen } from './SettingsScreen';
import { TasksScreen } from './TasksScreen';
import { TimeEntriesScreen } from './TimeEntriesScreen';
type TabKey = 'tasks' | 'notes' | 'files' | 'time' | 'settings';
const TABS: Array<{ key: TabKey; label: string }> = [
{ key: 'tasks', label: 'Tasks' },
{ key: 'notes', label: 'Notes' },
{ key: 'files', label: 'Files' },
{ key: 'time', label: 'Time' },
{ key: 'settings', label: 'Settings' },
];
interface MainTabsProps {
instanceUrl: string;
token: string;
}
export function MainTabs({ instanceUrl, token }: MainTabsProps) {
const [activeTab, setActiveTab] = useState<TabKey>('tasks');
const content = useMemo(() => {
switch (activeTab) {
case 'tasks':
return <TasksScreen instanceUrl={instanceUrl} token={token} isActive />;
case 'notes':
return <NotesScreen instanceUrl={instanceUrl} token={token} isActive />;
case 'files':
return <FilesScreen instanceUrl={instanceUrl} token={token} isActive />;
case 'time':
return <TimeEntriesScreen instanceUrl={instanceUrl} token={token} isActive />;
case 'settings':
return <SettingsScreen isActive />;
default:
return null;
}
}, [activeTab, instanceUrl, token]);
return (
<View style={styles.container}>
<View style={styles.content}>{content}</View>
<View style={styles.tabBar}>
{TABS.map((tab) => {
const isActive = tab.key === activeTab;
return (
<Pressable
key={tab.key}
onPress={() => setActiveTab(tab.key)}
style={({ pressed }) => [
styles.tabButton,
isActive ? styles.tabButtonActive : undefined,
pressed ? styles.tabPressed : undefined,
]}
>
<Text style={[styles.tabLabel, isActive ? styles.tabLabelActive : undefined]}>{tab.label}</Text>
</Pressable>
);
})}
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background,
},
content: {
flex: 1,
},
tabBar: {
flexDirection: 'row',
borderTopWidth: 1,
borderColor: colors.border,
backgroundColor: '#FFFFFF',
paddingHorizontal: 4,
paddingBottom: 8,
paddingTop: 6,
},
tabButton: {
flex: 1,
minHeight: 44,
borderRadius: 10,
justifyContent: 'center',
alignItems: 'center',
marginHorizontal: 2,
},
tabButtonActive: {
backgroundColor: '#E6F6FB',
},
tabPressed: {
opacity: 0.8,
},
tabLabel: {
fontSize: 12,
color: colors.muted,
fontWeight: '600',
},
tabLabelActive: {
color: colors.primaryDark,
},
});
+172
View File
@@ -0,0 +1,172 @@
import React, { useCallback, useEffect, useState } from 'react';
import { Text, View } from 'react-native';
import { trackeepApi } from '../lib/api';
import { formatDate, tagsToText } from '../lib/format';
import { Note } from '../types';
import {
Button,
ErrorText,
Input,
Label,
ScreenShell,
SectionCard,
colors,
uiStyles,
} from '../components/UI';
interface NotesScreenProps {
instanceUrl: string;
token: string;
isActive: boolean;
}
export function NotesScreen({ instanceUrl, token, isActive }: NotesScreenProps) {
const [notes, setNotes] = useState<Note[]>([]);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [title, setTitle] = useState('');
const [content, setContent] = useState('');
const [editingId, setEditingId] = useState<number | null>(null);
const loadNotes = useCallback(async () => {
setLoading(true);
setError(null);
try {
const list = await trackeepApi.notes.list(instanceUrl, token);
setNotes(list);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load notes.');
} finally {
setLoading(false);
}
}, [instanceUrl, token]);
useEffect(() => {
if (isActive) {
void loadNotes();
}
}, [isActive, loadNotes]);
const resetForm = () => {
setTitle('');
setContent('');
setEditingId(null);
};
const saveNote = async () => {
const trimmedTitle = title.trim();
if (!trimmedTitle) {
setError('Note title is required.');
return;
}
setSaving(true);
setError(null);
try {
if (editingId) {
await trackeepApi.notes.update(instanceUrl, token, editingId, {
title: trimmedTitle,
content,
});
} else {
await trackeepApi.notes.create(instanceUrl, token, {
title: trimmedTitle,
content,
is_public: false,
});
}
resetForm();
await loadNotes();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save note.');
} finally {
setSaving(false);
}
};
const beginEdit = (note: Note) => {
setEditingId(note.id);
setTitle(note.title);
setContent(note.content || '');
};
const deleteNote = async (id: number) => {
setSaving(true);
setError(null);
try {
await trackeepApi.notes.remove(instanceUrl, token, id);
if (editingId === id) {
resetForm();
}
await loadNotes();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete note.');
} finally {
setSaving(false);
}
};
return (
<ScreenShell title="Notes" subtitle="Write notes and keep them synced with your server.">
<SectionCard>
<Label>{editingId ? 'Edit Note' : 'New Note'}</Label>
<Input placeholder="Note title" value={title} onChangeText={setTitle} />
<Input multiline placeholder="Write your note..." value={content} onChangeText={setContent} />
<View style={uiStyles.row}>
<Button label="Refresh" variant="secondary" onPress={loadNotes} loading={loading} />
<Button label={editingId ? 'Save Changes' : 'Create Note'} onPress={saveNote} loading={saving} />
{editingId ? <Button label="Cancel" variant="secondary" onPress={resetForm} /> : null}
</View>
<ErrorText message={error} />
</SectionCard>
{notes.map((note) => {
const tags = tagsToText(note.tags);
return (
<SectionCard key={note.id}>
<View style={uiStyles.splitRow}>
<Text style={{ fontWeight: '700', color: colors.text, flex: 1 }}>{note.title}</Text>
{note.is_public ? (
<View style={[uiStyles.chip, { backgroundColor: '#FEF3C7' }]}>
<Text style={uiStyles.chipText}>Public</Text>
</View>
) : null}
</View>
{note.content ? <Text style={uiStyles.muted}>{note.content}</Text> : null}
{tags ? <Text style={uiStyles.muted}>Tags: {tags}</Text> : null}
<Text style={uiStyles.muted}>Updated: {formatDate(note.updated_at)}</Text>
<View style={uiStyles.row}>
<Button label="Edit" variant="secondary" onPress={() => beginEdit(note)} />
<Button
label="Delete"
variant="danger"
onPress={() => {
void deleteNote(note.id);
}}
loading={saving}
/>
</View>
</SectionCard>
);
})}
{!loading && notes.length === 0 ? (
<SectionCard>
<Text style={uiStyles.muted}>No notes found yet.</Text>
</SectionCard>
) : null}
</ScreenShell>
);
}
+127
View File
@@ -0,0 +1,127 @@
import React, { useEffect, useState } from 'react';
import { Text, View } from 'react-native';
import { useAppContext } from '../context/AppContext';
import { trackeepApi } from '../lib/api';
import {
Button,
ErrorText,
Input,
Label,
ScreenShell,
SectionCard,
uiStyles,
} from '../components/UI';
interface SettingsScreenProps {
isActive: boolean;
}
export function SettingsScreen({ isActive }: SettingsScreenProps) {
const { instanceUrl, user, logout, setInstanceUrl, clearInstance, refreshUser, busy } = useAppContext();
const [instanceDraft, setInstanceDraft] = useState(instanceUrl ?? '');
const [error, setError] = useState<string | null>(null);
const [info, setInfo] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [checkingUpdates, setCheckingUpdates] = useState(false);
useEffect(() => {
if (isActive) {
setInstanceDraft(instanceUrl ?? '');
}
}, [instanceUrl, isActive]);
const saveInstance = async () => {
setSaving(true);
setError(null);
setInfo(null);
try {
const probe = await trackeepApi.probeInstance(instanceDraft);
await setInstanceUrl(probe.normalizedUrl);
const versionText = probe.version ? ` (version ${probe.version})` : '';
setInfo(`Instance updated to ${probe.normalizedUrl}${versionText}. Sign in again to continue.`);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update instance URL.');
} finally {
setSaving(false);
}
};
const checkUpdates = async () => {
if (!instanceUrl) {
return;
}
setCheckingUpdates(true);
setError(null);
setInfo(null);
try {
const result = await trackeepApi.checkForUpdates(instanceUrl);
const updateAvailable = result.update_available ? 'Yes' : 'No';
const current = result.current_version || 'unknown';
const latest = result.latest_version || 'unknown';
const message = result.message ? ` | ${result.message}` : '';
setInfo(`Update available: ${updateAvailable} | Current: ${current} | Latest: ${latest}${message}`);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to check updates.');
} finally {
setCheckingUpdates(false);
}
};
return (
<ScreenShell title="Settings" subtitle="Manage instance connection, session and server checks.">
<SectionCard>
<Label>Connected User</Label>
<Text style={uiStyles.muted}>{user ? `${user.full_name || user.username} (${user.email})` : 'Not signed in'}</Text>
<View style={uiStyles.row}>
<Button label="Refresh Profile" variant="secondary" onPress={() => void refreshUser()} loading={busy} />
<Button label="Logout" variant="danger" onPress={() => void logout()} loading={busy} />
</View>
</SectionCard>
<SectionCard>
<Label>Trackeep Instance</Label>
<Input
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
placeholder="https://trackeep.yourdomain.com"
value={instanceDraft}
onChangeText={setInstanceDraft}
/>
<View style={uiStyles.row}>
<Button label="Save Instance" onPress={saveInstance} loading={saving} />
<Button
label="Disconnect"
variant="secondary"
onPress={() => {
void clearInstance();
}}
/>
</View>
<Text style={uiStyles.muted}>
Switching instance clears your current session to keep auth isolated per server.
</Text>
</SectionCard>
<SectionCard>
<Label>Server Update Check</Label>
<Text style={uiStyles.muted}>Checks `/api/updates/check` on your connected Trackeep instance.</Text>
<Button
label="Check For Updates"
variant="secondary"
onPress={checkUpdates}
loading={checkingUpdates}
disabled={!instanceUrl}
/>
</SectionCard>
<ErrorText message={error} />
{info ? <Text style={[uiStyles.muted, { color: '#047857', fontWeight: '600' }]}>{info}</Text> : null}
</ScreenShell>
);
}
+191
View File
@@ -0,0 +1,191 @@
import React, { useCallback, useEffect, useState } from 'react';
import { Text, View } from 'react-native';
import { trackeepApi } from '../lib/api';
import { formatDate, tagsToText } from '../lib/format';
import { Task } from '../types';
import {
Button,
ErrorText,
Input,
Label,
ScreenShell,
SectionCard,
colors,
uiStyles,
} from '../components/UI';
interface TasksScreenProps {
instanceUrl: string;
token: string;
isActive: boolean;
}
export function TasksScreen({ instanceUrl, token, isActive }: TasksScreenProps) {
const [tasks, setTasks] = useState<Task[]>([]);
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [priority, setPriority] = useState<'low' | 'medium' | 'high'>('medium');
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const loadTasks = useCallback(async () => {
setLoading(true);
setError(null);
try {
const list = await trackeepApi.tasks.list(instanceUrl, token);
setTasks(list);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load tasks.');
} finally {
setLoading(false);
}
}, [instanceUrl, token]);
useEffect(() => {
if (isActive) {
void loadTasks();
}
}, [isActive, loadTasks]);
const createTask = async () => {
const trimmedTitle = title.trim();
if (!trimmedTitle) {
setError('Task title is required.');
return;
}
setSaving(true);
setError(null);
try {
await trackeepApi.tasks.create(instanceUrl, token, {
title: trimmedTitle,
description: description.trim(),
priority,
status: 'pending',
});
setTitle('');
setDescription('');
await loadTasks();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create task.');
} finally {
setSaving(false);
}
};
const toggleTaskStatus = async (task: Task) => {
setSaving(true);
setError(null);
const nextStatus = task.status === 'completed' ? 'pending' : 'completed';
try {
await trackeepApi.tasks.update(instanceUrl, token, task.id, {
status: nextStatus,
progress: nextStatus === 'completed' ? 100 : 0,
});
await loadTasks();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update task.');
} finally {
setSaving(false);
}
};
const deleteTask = async (id: number) => {
setSaving(true);
setError(null);
try {
await trackeepApi.tasks.remove(instanceUrl, token, id);
await loadTasks();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete task.');
} finally {
setSaving(false);
}
};
return (
<ScreenShell title="Tasks" subtitle="Create, update and sync task state with your Trackeep backend.">
<SectionCard>
<Label>New Task</Label>
<Input placeholder="Task title" value={title} onChangeText={setTitle} />
<Input
multiline
placeholder="Task description"
value={description}
onChangeText={setDescription}
/>
<Label>Priority</Label>
<View style={uiStyles.row}>
{(['low', 'medium', 'high'] as const).map((value) => (
<Button
key={value}
label={value}
variant={priority === value ? 'primary' : 'secondary'}
onPress={() => setPriority(value)}
/>
))}
</View>
<View style={uiStyles.row}>
<Button label="Refresh" variant="secondary" onPress={loadTasks} loading={loading} />
<Button label="Create Task" onPress={createTask} loading={saving} />
</View>
<ErrorText message={error} />
</SectionCard>
{tasks.map((task) => {
const tags = tagsToText(task.tags);
const isCompleted = task.status === 'completed';
return (
<SectionCard key={task.id}>
<View style={uiStyles.splitRow}>
<Text style={{ fontWeight: '700', color: colors.text, flex: 1 }}>{task.title}</Text>
<View style={[uiStyles.chip, { backgroundColor: isCompleted ? '#DCFCE7' : '#E6F6FB' }]}>
<Text style={uiStyles.chipText}>{task.status}</Text>
</View>
</View>
{task.description ? <Text style={uiStyles.muted}>{task.description}</Text> : null}
<Text style={uiStyles.muted}>Priority: {task.priority}</Text>
<Text style={uiStyles.muted}>Updated: {formatDate(task.updated_at)}</Text>
{tags ? <Text style={uiStyles.muted}>Tags: {tags}</Text> : null}
<View style={uiStyles.row}>
<Button
label={isCompleted ? 'Mark Pending' : 'Mark Complete'}
variant="secondary"
onPress={() => {
void toggleTaskStatus(task);
}}
loading={saving}
/>
<Button
label="Delete"
variant="danger"
onPress={() => {
void deleteTask(task.id);
}}
loading={saving}
/>
</View>
</SectionCard>
);
})}
{!loading && tasks.length === 0 ? (
<SectionCard>
<Text style={uiStyles.muted}>No tasks found yet.</Text>
</SectionCard>
) : null}
</ScreenShell>
);
}
+202
View File
@@ -0,0 +1,202 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Text, View } from 'react-native';
import { trackeepApi } from '../lib/api';
import { formatDate, formatDuration } from '../lib/format';
import { TimeEntry } from '../types';
import {
Button,
ErrorText,
Input,
Label,
ScreenShell,
SectionCard,
colors,
uiStyles,
} from '../components/UI';
interface TimeEntriesScreenProps {
instanceUrl: string;
token: string;
isActive: boolean;
}
function resolveDurationSeconds(entry: TimeEntry, nowMs: number): number {
if (typeof entry.duration === 'number' && entry.duration >= 0) {
return entry.duration;
}
const startMs = new Date(entry.start_time).getTime();
if (Number.isNaN(startMs)) {
return 0;
}
if (entry.is_running) {
return Math.floor((nowMs - startMs) / 1000);
}
if (entry.end_time) {
const endMs = new Date(entry.end_time).getTime();
if (!Number.isNaN(endMs)) {
return Math.floor((endMs - startMs) / 1000);
}
}
return 0;
}
export function TimeEntriesScreen({ instanceUrl, token, isActive }: TimeEntriesScreenProps) {
const [entries, setEntries] = useState<TimeEntry[]>([]);
const [description, setDescription] = useState('');
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [nowMs, setNowMs] = useState(() => Date.now());
const hasRunningEntries = useMemo(() => entries.some((entry) => entry.is_running), [entries]);
useEffect(() => {
if (!hasRunningEntries) {
return;
}
const interval = setInterval(() => {
setNowMs(Date.now());
}, 1000);
return () => clearInterval(interval);
}, [hasRunningEntries]);
const loadEntries = useCallback(async () => {
setLoading(true);
setError(null);
try {
const response = await trackeepApi.timeEntries.list(instanceUrl, token);
setEntries(response.time_entries);
setNowMs(Date.now());
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load time entries.');
} finally {
setLoading(false);
}
}, [instanceUrl, token]);
useEffect(() => {
if (isActive) {
void loadEntries();
}
}, [isActive, loadEntries]);
const startEntry = async () => {
const trimmed = description.trim();
if (!trimmed) {
setError('Description is required to start tracking time.');
return;
}
setSaving(true);
setError(null);
try {
await trackeepApi.timeEntries.create(instanceUrl, token, {
description: trimmed,
source: 'mobile',
});
setDescription('');
await loadEntries();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to start timer.');
} finally {
setSaving(false);
}
};
const stopEntry = async (id: number) => {
setSaving(true);
setError(null);
try {
await trackeepApi.timeEntries.stop(instanceUrl, token, id);
await loadEntries();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to stop timer.');
} finally {
setSaving(false);
}
};
const deleteEntry = async (id: number) => {
setSaving(true);
setError(null);
try {
await trackeepApi.timeEntries.remove(instanceUrl, token, id);
await loadEntries();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete entry.');
} finally {
setSaving(false);
}
};
return (
<ScreenShell title="Time Tracking" subtitle="Start and stop timers synced with Trackeep time entries.">
<SectionCard>
<Label>Start New Timer</Label>
<Input placeholder="What are you working on?" value={description} onChangeText={setDescription} />
<View style={uiStyles.row}>
<Button label="Refresh" variant="secondary" onPress={loadEntries} loading={loading} />
<Button label="Start Timer" onPress={startEntry} loading={saving} />
</View>
<ErrorText message={error} />
</SectionCard>
{entries.map((entry) => {
const duration = formatDuration(resolveDurationSeconds(entry, nowMs));
return (
<SectionCard key={entry.id}>
<View style={uiStyles.splitRow}>
<Text style={{ color: colors.text, fontWeight: '700', flex: 1 }}>{entry.description || 'Untitled entry'}</Text>
<View style={[uiStyles.chip, { backgroundColor: entry.is_running ? '#DCFCE7' : '#E6F6FB' }]}>
<Text style={uiStyles.chipText}>{entry.is_running ? 'Running' : 'Stopped'}</Text>
</View>
</View>
<Text style={uiStyles.muted}>Duration: {duration}</Text>
<Text style={uiStyles.muted}>Started: {formatDate(entry.start_time)}</Text>
{entry.end_time ? <Text style={uiStyles.muted}>Ended: {formatDate(entry.end_time)}</Text> : null}
<View style={uiStyles.row}>
{entry.is_running ? (
<Button
label="Stop"
variant="secondary"
onPress={() => {
void stopEntry(entry.id);
}}
loading={saving}
/>
) : null}
<Button
label="Delete"
variant="danger"
onPress={() => {
void deleteEntry(entry.id);
}}
loading={saving}
/>
</View>
</SectionCard>
);
})}
{!loading && entries.length === 0 ? (
<SectionCard>
<Text style={uiStyles.muted}>No time entries found yet.</Text>
</SectionCard>
) : null}
</ScreenShell>
);
}
+868
View File
@@ -0,0 +1,868 @@
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,
},
});