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
+41
View File
@@ -0,0 +1,41 @@
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
# dependencies
node_modules/
# Expo
.expo/
dist/
web-build/
expo-env.d.ts
# Native
.kotlin/
*.orig.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
# Metro
.metro-health-check*
# debug
npm-debug.*
yarn-debug.*
yarn-error.*
# macOS
.DS_Store
*.pem
# local env files
.env*.local
# typescript
*.tsbuildinfo
# generated native folders
/ios
/android
+95
View File
@@ -0,0 +1,95 @@
import { StatusBar } from 'expo-status-bar';
import { ShareIntent, useShareIntent } from 'expo-share-intent';
import React from 'react';
import { ActivityIndicator, SafeAreaView, StyleSheet, Text, View } from 'react-native';
import { AppProvider, useAppContext } from './src/context/AppContext';
import { colors } from './src/components/UI';
import { AuthScreen } from './src/screens/AuthScreen';
import { ConnectionSetupScreen } from './src/screens/ConnectionSetupScreen';
import { WebAppScreen } from './src/screens/WebAppScreen';
interface ShareIntentState {
hasShareIntent: boolean;
shareIntent: ShareIntent;
resetShareIntent: (clearNativeModule?: boolean) => void;
error: string | null;
}
function AppRouter({ shareIntentState }: { shareIntentState: ShareIntentState }) {
const { ready, instanceUrl, token, user, logout } = useAppContext();
if (!ready) {
return (
<SafeAreaView style={styles.bootContainer}>
<ActivityIndicator size="large" color={colors.primary} />
<Text style={styles.bootText}>Loading Trackeep Mobile...</Text>
</SafeAreaView>
);
}
if (!instanceUrl) {
return <ConnectionSetupScreen />;
}
if (!token) {
return <AuthScreen />;
}
if (!user) {
return (
<SafeAreaView style={styles.bootContainer}>
<ActivityIndicator size="large" color={colors.primary} />
<Text style={styles.bootText}>Loading user session...</Text>
</SafeAreaView>
);
}
return (
<WebAppScreen
instanceUrl={instanceUrl}
token={token}
user={user}
onLogout={logout}
shareIntentState={shareIntentState}
/>
);
}
export default function App() {
const { hasShareIntent, shareIntent, resetShareIntent, error } = useShareIntent({
resetOnBackground: false,
});
return (
<AppProvider>
<View style={styles.appRoot}>
<StatusBar style="dark" />
<SafeAreaView style={styles.safeArea}>
<AppRouter shareIntentState={{ hasShareIntent, shareIntent, resetShareIntent, error }} />
</SafeAreaView>
</View>
</AppProvider>
);
}
const styles = StyleSheet.create({
appRoot: {
flex: 1,
backgroundColor: colors.background,
},
safeArea: {
flex: 1,
},
bootContainer: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.background,
gap: 10,
},
bootText: {
color: colors.muted,
fontSize: 14,
},
});
+78
View File
@@ -0,0 +1,78 @@
# Trackeep Mobile (React Native)
Cross-platform mobile shell for Trackeep using React Native + Expo.
## Architecture
- **Same UI as web**: after login, the app renders your Trackeep web app (`/app`) inside a native WebView.
- **One backend, one data plane**: mobile uses your self-hosted Trackeep instance URL and the same API/auth as web.
- **Mobile-native extras**:
- incoming phone share intent (links, YouTube URLs, text, files)
- quick share panel to save bookmarks immediately
- shared files upload directly to Trackeep Files
## Implemented Features
- Instance setup and validation (`/health`, `/api/version`)
- Native login/register (`/api/v1/auth/...`) with secure token storage
- Token bootstrap into WebView localStorage so web UI works immediately
- Native/Web bridge for auth + route state sync
- Android hardware back support for in-web navigation
- External links open in system browser while Trackeep pages stay in-app
- Mobile route shortcuts (Dashboard/Bookmarks/Tasks/Notes/Files/YouTube/Time)
- Incoming share flow:
- Save shared links/YouTube as bookmarks (`POST /api/v1/bookmarks`)
- Smart link metadata enrichment (`POST /api/v1/bookmarks/metadata`)
- YouTube-aware action: `Save + Open YouTube`
- Upload shared files (`POST /api/v1/files/upload`)
- Broader Android share MIME support (`text/plain`, `text/uri-list`, `text/*`, `image/*`, `video/*`, `application/*`)
- Trackeep-branded mobile icon/splash copied from main project assets
## Prerequisites
- Node.js 18+
- npm 10+
- Reachable Trackeep backend
## Run
From repository root:
```bash
npm install
npm --workspace mobile run typecheck
npm --workspace mobile run start
```
## Important: Share Intent Requires Dev Client
Incoming share from other apps requires native code (`expo-share-intent`), so **Expo Go is not enough**.
Use a dev client or production build:
```bash
npm --workspace mobile run prebuild:clean
npm --workspace mobile run android:run
# or
npm --workspace mobile run ios:run
npm --workspace mobile run start:dev-client
```
`patch-package` is already configured at repository root to apply the required `xcode@3.0.1` patch during install.
## Share Examples
- Share a YouTube link from YouTube app → Trackeep Mobile appears in share sheet → Save to bookmarks.
- Share a webpage URL from browser → Save to bookmarks.
- Share an image/file from gallery/files app → Upload to Trackeep Files.
## Instance URL examples
- Production: `https://trackeep.example.com`
- Android emulator local backend: `http://10.0.2.2:8080`
- iOS simulator local backend: `http://localhost:8080`
## Notes
- Switching instance URL clears session intentionally (instance-isolated auth).
- If the share extension is not detected in Expo Go, this is expected; use dev client or release build.
+64
View File
@@ -0,0 +1,64 @@
{
"expo": {
"name": "Trackeep",
"slug": "trackeep-mobile",
"version": "1.0.0",
"scheme": "trackeepmobile",
"orientation": "portrait",
"icon": "./assets/trackeep-icon.png",
"userInterfaceStyle": "light",
"splash": {
"image": "./assets/trackeep-splash.png",
"resizeMode": "contain",
"backgroundColor": "#ffffff"
},
"ios": {
"supportsTablet": true,
"bundleIdentifier": "com.trackeep.mobile"
},
"android": {
"adaptiveIcon": {
"backgroundColor": "#E6F4FE",
"foregroundImage": "./assets/android-icon-foreground.png",
"backgroundImage": "./assets/android-icon-background.png",
"monochromeImage": "./assets/android-icon-monochrome.png"
},
"package": "com.trackeep.mobile",
"predictiveBackGestureEnabled": false
},
"web": {
"favicon": "./assets/favicon.png"
},
"plugins": [
[
"expo-share-intent",
{
"iosShareExtensionName": "Trackeep",
"iosActivationRules": {
"NSExtensionActivationSupportsWebURLWithMaxCount": 1,
"NSExtensionActivationSupportsWebPageWithMaxCount": 1,
"NSExtensionActivationSupportsText": true,
"NSExtensionActivationSupportsImageWithMaxCount": 1,
"NSExtensionActivationSupportsMovieWithMaxCount": 1,
"NSExtensionActivationSupportsFileWithMaxCount": 1
},
"androidIntentFilters": [
"text/plain",
"text/uri-list",
"text/*",
"image/*",
"video/*",
"application/*"
],
"androidMultiIntentFilters": [
"text/plain",
"text/*",
"image/*",
"video/*",
"*/*"
]
}
]
]
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 384 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

+8
View File
@@ -0,0 +1,8 @@
import { registerRootComponent } from 'expo';
import App from './App';
// registerRootComponent calls AppRegistry.registerComponent('main', () => App);
// It also ensures that whether you load the app in Expo Go or in a native build,
// the environment is set up appropriately
registerRootComponent(App);
+33
View File
@@ -0,0 +1,33 @@
{
"name": "trackeep-mobile",
"version": "1.0.0",
"main": "index.ts",
"scripts": {
"start": "expo start",
"start:dev-client": "expo start --dev-client",
"android": "expo start --android",
"ios": "expo start --ios",
"android:run": "expo run:android",
"ios:run": "expo run:ios",
"prebuild:clean": "expo prebuild --clean",
"web": "expo start --web",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@react-native-async-storage/async-storage": "2.2.0",
"expo": "~55.0.5",
"expo-document-picker": "~14.0.7",
"expo-linking": "~55.0.7",
"expo-secure-store": "~15.0.7",
"expo-share-intent": "~6.0.0",
"expo-status-bar": "~55.0.4",
"react": "19.2.0",
"react-native": "0.83.2",
"react-native-webview": "~13.16.1"
},
"devDependencies": {
"@types/react": "~19.2.2",
"typescript": "~5.9.2"
},
"private": true
}
+206
View File
@@ -0,0 +1,206 @@
import React from 'react';
import {
ActivityIndicator,
Pressable,
ScrollView,
StyleSheet,
Text,
TextInput,
TextInputProps,
View,
ViewStyle,
} from 'react-native';
export const colors = {
background: '#F4F7FB',
panel: '#FFFFFF',
border: '#D6DEE8',
text: '#17212B',
muted: '#5D6B79',
primary: '#0E7490',
primaryDark: '#155E75',
danger: '#B91C1C',
success: '#047857',
};
export function ScreenShell({ title, subtitle, children }: { title: string; subtitle?: string; children: React.ReactNode }) {
return (
<ScrollView style={styles.screen} contentContainerStyle={styles.content} keyboardShouldPersistTaps="handled">
<Text style={styles.title}>{title}</Text>
{subtitle ? <Text style={styles.subtitle}>{subtitle}</Text> : null}
{children}
<View style={styles.bottomSpace} />
</ScrollView>
);
}
export function SectionCard({ children, style }: { children: React.ReactNode; style?: ViewStyle }) {
return <View style={[styles.card, style]}>{children}</View>;
}
export function Label({ children }: { children: React.ReactNode }) {
return <Text style={styles.label}>{children}</Text>;
}
export function Input(props: TextInputProps) {
return <TextInput placeholderTextColor={colors.muted} style={[styles.input, props.multiline ? styles.textarea : undefined]} {...props} />;
}
export function Button({
label,
onPress,
variant = 'primary',
disabled,
loading,
}: {
label: string;
onPress: () => void;
variant?: 'primary' | 'secondary' | 'danger';
disabled?: boolean;
loading?: boolean;
}) {
const isDisabled = disabled || loading;
return (
<Pressable
onPress={onPress}
disabled={isDisabled}
style={({ pressed }) => [
styles.button,
variant === 'primary' ? styles.buttonPrimary : undefined,
variant === 'secondary' ? styles.buttonSecondary : undefined,
variant === 'danger' ? styles.buttonDanger : undefined,
pressed && !isDisabled ? styles.buttonPressed : undefined,
isDisabled ? styles.buttonDisabled : undefined,
]}
>
{loading ? <ActivityIndicator color={variant === 'secondary' ? colors.text : '#FFFFFF'} /> : <Text style={[styles.buttonText, variant === 'secondary' ? styles.buttonTextSecondary : undefined]}>{label}</Text>}
</Pressable>
);
}
export function ErrorText({ message }: { message?: string | null }) {
if (!message) {
return null;
}
return <Text style={styles.error}>{message}</Text>;
}
export const uiStyles = StyleSheet.create({
row: {
flexDirection: 'row',
gap: 8,
alignItems: 'center',
flexWrap: 'wrap',
},
splitRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
gap: 12,
},
muted: {
color: colors.muted,
fontSize: 13,
},
chip: {
alignSelf: 'flex-start',
backgroundColor: '#E6F6FB',
borderRadius: 999,
paddingHorizontal: 10,
paddingVertical: 5,
},
chipText: {
color: colors.primaryDark,
fontSize: 12,
fontWeight: '600',
},
});
const styles = StyleSheet.create({
screen: {
flex: 1,
backgroundColor: colors.background,
},
content: {
padding: 16,
gap: 12,
},
title: {
fontSize: 24,
fontWeight: '700',
color: colors.text,
},
subtitle: {
color: colors.muted,
marginTop: -4,
marginBottom: 2,
},
card: {
backgroundColor: colors.panel,
borderRadius: 14,
borderWidth: 1,
borderColor: colors.border,
padding: 14,
gap: 10,
},
label: {
fontSize: 13,
color: colors.text,
fontWeight: '600',
},
input: {
borderColor: colors.border,
borderWidth: 1,
borderRadius: 10,
backgroundColor: '#FFFFFF',
color: colors.text,
paddingHorizontal: 12,
paddingVertical: 10,
minHeight: 42,
},
textarea: {
minHeight: 96,
textAlignVertical: 'top',
},
button: {
minHeight: 40,
borderRadius: 10,
justifyContent: 'center',
alignItems: 'center',
paddingHorizontal: 14,
},
buttonPrimary: {
backgroundColor: colors.primary,
},
buttonSecondary: {
backgroundColor: '#EDF2F7',
borderWidth: 1,
borderColor: colors.border,
},
buttonDanger: {
backgroundColor: colors.danger,
},
buttonPressed: {
opacity: 0.85,
},
buttonDisabled: {
opacity: 0.55,
},
buttonText: {
color: '#FFFFFF',
fontWeight: '700',
fontSize: 14,
},
buttonTextSecondary: {
color: colors.text,
},
error: {
color: colors.danger,
fontWeight: '600',
},
bottomSpace: {
height: 36,
},
});
+226
View File
@@ -0,0 +1,226 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import * as SecureStore from 'expo-secure-store';
import React, { createContext, useContext, useEffect, useMemo, useState } from 'react';
import { trackeepApi } from '../lib/api';
import { normalizeInstanceUrl } from '../lib/url';
import { User } from '../types';
const INSTANCE_URL_KEY = 'trackeep_mobile_instance_url';
const USER_KEY = 'trackeep_mobile_user';
const TOKEN_KEY = 'trackeep_mobile_token';
interface RegisterPayload {
email: string;
username: string;
fullName: string;
password: string;
}
interface AppContextValue {
ready: boolean;
busy: boolean;
instanceUrl: string | null;
token: string | null;
user: User | null;
setInstanceUrl: (url: string) => Promise<void>;
clearInstance: () => Promise<void>;
login: (email: string, password: string) => Promise<void>;
register: (payload: RegisterPayload) => Promise<void>;
logout: () => Promise<void>;
refreshUser: () => Promise<void>;
}
const AppContext = createContext<AppContextValue | undefined>(undefined);
async function saveToken(token: string): Promise<void> {
await SecureStore.setItemAsync(TOKEN_KEY, token);
}
async function loadToken(): Promise<string | null> {
return SecureStore.getItemAsync(TOKEN_KEY);
}
async function clearToken(): Promise<void> {
await SecureStore.deleteItemAsync(TOKEN_KEY);
}
export function AppProvider({ children }: { children: React.ReactNode }) {
const [ready, setReady] = useState(false);
const [busy, setBusy] = useState(false);
const [instanceUrl, setInstanceUrlState] = useState<string | null>(null);
const [token, setToken] = useState<string | null>(null);
const [user, setUser] = useState<User | null>(null);
useEffect(() => {
const bootstrap = async () => {
try {
const [[, storedInstance], [, storedUser]] = await AsyncStorage.multiGet([INSTANCE_URL_KEY, USER_KEY]);
const storedToken = await loadToken();
let parsedUser: User | null = null;
if (storedUser) {
try {
parsedUser = JSON.parse(storedUser) as User;
} catch {
parsedUser = null;
}
}
let normalizedInstance: string | null = null;
if (storedInstance) {
try {
normalizedInstance = normalizeInstanceUrl(storedInstance);
} catch {
normalizedInstance = null;
await AsyncStorage.removeItem(INSTANCE_URL_KEY);
}
}
if (normalizedInstance && storedToken) {
try {
const freshUser = await trackeepApi.auth.me(normalizedInstance, storedToken);
setInstanceUrlState(normalizedInstance);
setToken(storedToken);
setUser(freshUser);
await AsyncStorage.setItem(USER_KEY, JSON.stringify(freshUser));
} catch {
setInstanceUrlState(normalizedInstance);
setToken(null);
setUser(null);
await clearToken();
await AsyncStorage.removeItem(USER_KEY);
}
} else {
setInstanceUrlState(normalizedInstance);
setToken(null);
setUser(null);
}
} finally {
setReady(true);
}
};
void bootstrap();
}, []);
const clearSession = async () => {
setToken(null);
setUser(null);
await clearToken();
await AsyncStorage.removeItem(USER_KEY);
};
const setInstanceUrl = async (url: string) => {
const normalized = normalizeInstanceUrl(url);
const hasChanged = normalized !== instanceUrl;
setInstanceUrlState(normalized);
await AsyncStorage.setItem(INSTANCE_URL_KEY, normalized);
if (hasChanged) {
await clearSession();
}
};
const clearInstance = async () => {
await clearSession();
setInstanceUrlState(null);
await AsyncStorage.removeItem(INSTANCE_URL_KEY);
};
const login = async (email: string, password: string) => {
if (!instanceUrl) {
throw new Error('Set your Trackeep instance URL first.');
}
setBusy(true);
try {
const response = await trackeepApi.auth.login(instanceUrl, email, password);
setToken(response.token);
setUser(response.user);
await saveToken(response.token);
await AsyncStorage.setItem(USER_KEY, JSON.stringify(response.user));
} finally {
setBusy(false);
}
};
const register = async (payload: RegisterPayload) => {
if (!instanceUrl) {
throw new Error('Set your Trackeep instance URL first.');
}
setBusy(true);
try {
const response = await trackeepApi.auth.register(instanceUrl, payload);
setToken(response.token);
setUser(response.user);
await saveToken(response.token);
await AsyncStorage.setItem(USER_KEY, JSON.stringify(response.user));
} finally {
setBusy(false);
}
};
const logout = async () => {
const currentToken = token;
const currentInstance = instanceUrl;
setBusy(true);
try {
if (currentToken && currentInstance) {
try {
await trackeepApi.auth.logout(currentInstance, currentToken);
} catch {
// Ignore logout API failures so local session can always be cleared.
}
}
await clearSession();
} finally {
setBusy(false);
}
};
const refreshUser = async () => {
if (!instanceUrl || !token) {
return;
}
setBusy(true);
try {
const freshUser = await trackeepApi.auth.me(instanceUrl, token);
setUser(freshUser);
await AsyncStorage.setItem(USER_KEY, JSON.stringify(freshUser));
} finally {
setBusy(false);
}
};
const value = useMemo<AppContextValue>(
() => ({
ready,
busy,
instanceUrl,
token,
user,
setInstanceUrl,
clearInstance,
login,
register,
logout,
refreshUser,
}),
[ready, busy, instanceUrl, token, user],
);
return <AppContext.Provider value={value}>{children}</AppContext.Provider>;
}
export function useAppContext(): AppContextValue {
const context = useContext(AppContext);
if (!context) {
throw new Error('useAppContext must be used inside AppProvider.');
}
return context;
}
+356
View File
@@ -0,0 +1,356 @@
import { DocumentPickerAsset } from 'expo-document-picker';
import {
AuthResponse,
Bookmark,
FileItem,
Note,
Task,
TimeEntry,
UpdateCheckInfo,
User,
VersionInfo,
} from '../types';
import { getApiBaseUrl, normalizeInstanceUrl } from './url';
interface RequestOptions {
method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
token?: string | null;
body?: unknown;
headers?: Record<string, string>;
}
async function parseResponse<T>(response: Response): Promise<T> {
if (response.status === 204) {
return undefined as T;
}
const contentType = response.headers.get('content-type') || '';
if (contentType.includes('application/json')) {
return (await response.json()) as T;
}
return (await response.text()) as T;
}
async function parseError(response: Response): Promise<string> {
const contentType = response.headers.get('content-type') || '';
if (contentType.includes('application/json')) {
const data = (await response.json().catch(() => null)) as
| { error?: string; message?: string }
| null;
if (data?.error) {
return data.error;
}
if (data?.message) {
return data.message;
}
}
const text = await response.text().catch(() => '');
return text || `Request failed with status ${response.status}`;
}
async function request<T>(instanceUrl: string, endpoint: string, options: RequestOptions = {}): Promise<T> {
const apiBaseUrl = getApiBaseUrl(instanceUrl);
const { method = 'GET', token, body, headers = {} } = options;
const requestHeaders: Record<string, string> = {
Accept: 'application/json',
...headers,
};
const isFormData = typeof FormData !== 'undefined' && body instanceof FormData;
if (!isFormData && body !== undefined) {
requestHeaders['Content-Type'] = 'application/json';
}
if (token) {
requestHeaders.Authorization = `Bearer ${token}`;
}
const response = await fetch(`${apiBaseUrl}${endpoint}`, {
method,
headers: requestHeaders,
body: body === undefined ? undefined : isFormData ? (body as BodyInit) : JSON.stringify(body),
});
if (!response.ok) {
throw new Error(await parseError(response));
}
return parseResponse<T>(response);
}
async function requestPublic<T>(absoluteUrl: string, init?: RequestInit): Promise<T> {
const response = await fetch(absoluteUrl, init);
if (!response.ok) {
throw new Error(await parseError(response));
}
return parseResponse<T>(response);
}
export const trackeepApi = {
async probeInstance(inputUrl: string): Promise<{ normalizedUrl: string; version?: string }> {
const normalizedUrl = normalizeInstanceUrl(inputUrl);
await requestPublic<string>(`${normalizedUrl}/health`);
let version: string | undefined;
try {
const versionInfo = await requestPublic<VersionInfo>(`${normalizedUrl}/api/version`);
const candidate = versionInfo.version || versionInfo.app_version;
if (typeof candidate === 'string') {
version = candidate;
}
} catch {
// Version endpoint is optional for initial connection setup.
}
return { normalizedUrl, version };
},
async checkForUpdates(instanceUrl: string): Promise<UpdateCheckInfo> {
const normalizedUrl = normalizeInstanceUrl(instanceUrl);
return requestPublic<UpdateCheckInfo>(`${normalizedUrl}/api/updates/check`);
},
auth: {
login(instanceUrl: string, email: string, password: string) {
return request<AuthResponse>(instanceUrl, '/auth/login', {
method: 'POST',
body: { email, password },
});
},
register(instanceUrl: string, payload: { email: string; username: string; fullName: string; password: string }) {
return request<AuthResponse>(instanceUrl, '/auth/register', {
method: 'POST',
body: payload,
});
},
me(instanceUrl: string, token: string) {
return request<User>(instanceUrl, '/auth/me', {
method: 'GET',
token,
});
},
async logout(instanceUrl: string, token: string): Promise<void> {
await request(instanceUrl, '/auth/logout', {
method: 'POST',
token,
});
},
},
tasks: {
list(instanceUrl: string, token: string) {
return request<Task[]>(instanceUrl, '/tasks', { token });
},
create(
instanceUrl: string,
token: string,
payload: { title: string; description?: string; status?: string; priority?: string },
) {
return request<Task>(instanceUrl, '/tasks', {
method: 'POST',
token,
body: payload,
});
},
update(instanceUrl: string, token: string, id: number, payload: Partial<Task>) {
return request<Task>(instanceUrl, `/tasks/${id}`, {
method: 'PUT',
token,
body: payload,
});
},
async remove(instanceUrl: string, token: string, id: number): Promise<void> {
await request(instanceUrl, `/tasks/${id}`, {
method: 'DELETE',
token,
});
},
},
notes: {
list(instanceUrl: string, token: string) {
return request<Note[]>(instanceUrl, '/notes', { token });
},
create(
instanceUrl: string,
token: string,
payload: { title: string; content?: string; description?: string; is_public?: boolean },
) {
return request<Note>(instanceUrl, '/notes', {
method: 'POST',
token,
body: payload,
});
},
update(instanceUrl: string, token: string, id: number, payload: Partial<Note>) {
return request<Note>(instanceUrl, `/notes/${id}`, {
method: 'PUT',
token,
body: payload,
});
},
async remove(instanceUrl: string, token: string, id: number): Promise<void> {
await request(instanceUrl, `/notes/${id}`, {
method: 'DELETE',
token,
});
},
},
files: {
list(instanceUrl: string, token: string) {
return request<FileItem[]>(instanceUrl, '/files', { token });
},
async upload(
instanceUrl: string,
token: string,
file: DocumentPickerAsset,
description?: string,
): Promise<FileItem> {
const formData = new FormData();
formData.append(
'file',
{
uri: file.uri,
name: file.name || `upload-${Date.now()}`,
type: file.mimeType || 'application/octet-stream',
} as unknown as Blob,
);
if (description?.trim()) {
formData.append('description', description.trim());
}
return request<FileItem>(instanceUrl, '/files/upload', {
method: 'POST',
token,
body: formData,
});
},
async uploadFromUri(
instanceUrl: string,
token: string,
payload: { uri: string; name: string; mimeType?: string; description?: string },
): Promise<FileItem> {
const formData = new FormData();
formData.append(
'file',
{
uri: payload.uri,
name: payload.name || `shared-${Date.now()}`,
type: payload.mimeType || 'application/octet-stream',
} as unknown as Blob,
);
if (payload.description?.trim()) {
formData.append('description', payload.description.trim());
}
return request<FileItem>(instanceUrl, '/files/upload', {
method: 'POST',
token,
body: formData,
});
},
async remove(instanceUrl: string, token: string, id: number): Promise<void> {
await request(instanceUrl, `/files/${id}`, {
method: 'DELETE',
token,
});
},
getDownloadUrl(instanceUrl: string, token: string, id: number): string {
const apiBaseUrl = getApiBaseUrl(instanceUrl);
return `${apiBaseUrl}/files/${id}/download?token=${encodeURIComponent(token)}`;
},
},
bookmarks: {
list(instanceUrl: string, token: string) {
return request<Bookmark[]>(instanceUrl, '/bookmarks', { token });
},
metadata(instanceUrl: string, token: string, url: string) {
return request<{ title?: string; description?: string; image?: string; favicon?: string }>(
instanceUrl,
'/bookmarks/metadata',
{
method: 'POST',
token,
body: { url },
},
);
},
create(
instanceUrl: string,
token: string,
payload: {
title: string;
url: string;
description?: string;
is_read?: boolean;
is_favorite?: boolean;
},
) {
return request<Bookmark>(instanceUrl, '/bookmarks', {
method: 'POST',
token,
body: payload,
});
},
},
timeEntries: {
list(instanceUrl: string, token: string) {
return request<{ time_entries: TimeEntry[] }>(instanceUrl, '/time-entries', { token });
},
create(
instanceUrl: string,
token: string,
payload: { description: string; billable?: boolean; source?: string; tags?: string[] },
) {
return request<{ time_entry: TimeEntry }>(instanceUrl, '/time-entries', {
method: 'POST',
token,
body: payload,
});
},
stop(instanceUrl: string, token: string, id: number) {
return request<{ time_entry: TimeEntry }>(instanceUrl, `/time-entries/${id}/stop`, {
method: 'POST',
token,
});
},
async remove(instanceUrl: string, token: string, id: number): Promise<void> {
await request(instanceUrl, `/time-entries/${id}`, {
method: 'DELETE',
token,
});
},
},
};
+57
View File
@@ -0,0 +1,57 @@
import { Tag } from '../types';
export function formatDate(date: string | null | undefined): string {
if (!date) {
return 'N/A';
}
const parsed = new Date(date);
if (Number.isNaN(parsed.getTime())) {
return date;
}
return parsed.toLocaleString();
}
export function formatDuration(seconds: number): string {
const safeSeconds = Math.max(0, Math.floor(seconds));
const hours = Math.floor(safeSeconds / 3600);
const minutes = Math.floor((safeSeconds % 3600) / 60);
const remaining = safeSeconds % 60;
if (hours > 0) {
return `${hours}h ${minutes}m ${remaining}s`;
}
if (minutes > 0) {
return `${minutes}m ${remaining}s`;
}
return `${remaining}s`;
}
export function formatFileSize(size: number): string {
if (size < 1024) {
return `${size} B`;
}
const units = ['KB', 'MB', 'GB', 'TB'];
let value = size / 1024;
let unitIndex = 0;
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024;
unitIndex += 1;
}
return `${value.toFixed(1)} ${units[unitIndex]}`;
}
export function tagsToText(tags?: Array<Tag | string>): string {
if (!tags || tags.length === 0) {
return '';
}
return tags
.map((tag) => (typeof tag === 'string' ? tag : tag.name))
.filter(Boolean)
.join(', ');
}
+109
View File
@@ -0,0 +1,109 @@
import { ShareIntent } from 'expo-share-intent';
const URL_PATTERN = /(https?:\/\/[^\s]+)/gi;
export interface ShareDraft {
title: string;
url: string;
description: string;
source: 'share-intent' | 'manual';
}
function firstUrlFromText(text: string | null | undefined): string | null {
if (!text) {
return null;
}
const matches = text.match(URL_PATTERN);
if (!matches || matches.length === 0) {
return null;
}
return matches[0];
}
function firstCandidateTitleFromText(text: string | null | undefined, url: string): string | null {
if (!text) {
return null;
}
const lines = text
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.filter((line) => !line.includes(url) && !/https?:\/\//i.test(line));
if (lines.length === 0) {
return null;
}
return lines[0];
}
function normalizeUrl(url: string | null | undefined): string | null {
if (!url) {
return null;
}
const trimmed = url.trim();
if (!trimmed) {
return null;
}
try {
const parsed = new URL(trimmed);
return parsed.toString();
} catch {
return null;
}
}
function titleFromUrl(url: string): string {
try {
const parsed = new URL(url);
const host = parsed.hostname.toLowerCase();
if (host.includes('youtube.com') || host.includes('youtu.be')) {
return 'YouTube Video';
}
return host.replace(/^www\./, '');
} catch {
return 'Shared Link';
}
}
export function buildShareDraft(shareIntent: ShareIntent): ShareDraft | null {
const url = normalizeUrl(shareIntent.webUrl) || normalizeUrl(firstUrlFromText(shareIntent.text));
if (!url) {
return null;
}
const rawTitle = shareIntent.meta?.title?.trim() || firstCandidateTitleFromText(shareIntent.text, url);
const title = rawTitle && rawTitle.length > 0 ? rawTitle : titleFromUrl(url);
let description = '';
if (shareIntent.text) {
const cleaned = shareIntent.text.replace(url, '').trim();
if (cleaned && cleaned.length > 0) {
description = cleaned.slice(0, 1200);
}
}
return {
title,
url,
description,
source: 'share-intent',
};
}
export function looksLikeYouTube(url: string): boolean {
try {
const host = new URL(url).hostname.toLowerCase();
return host.includes('youtube.com') || host.includes('youtu.be');
} catch {
return false;
}
}
+35
View File
@@ -0,0 +1,35 @@
const LOCAL_HOST_RE = /^(localhost|127\.0\.0\.1|10\.0\.2\.2|10\.0\.3\.2|192\.168\.|172\.(1[6-9]|2\d|3[0-1])\.)/i;
const API_SUFFIX_RE = /\/api\/v1\/?$/i;
function hasProtocol(value: string): boolean {
return /^[a-zA-Z][a-zA-Z\d+\-.]*:\/\//.test(value);
}
export function normalizeInstanceUrl(input: string): string {
const trimmed = input.trim();
if (!trimmed) {
throw new Error('Instance URL is required.');
}
const hostCandidate = trimmed.replace(/^[a-zA-Z][a-zA-Z\d+\-.]*:\/\//, '').split('/')[0].toLowerCase();
const scheme = LOCAL_HOST_RE.test(hostCandidate) ? 'http' : 'https';
const withScheme = hasProtocol(trimmed) ? trimmed : `${scheme}://${trimmed}`;
let parsed: URL;
try {
parsed = new URL(withScheme);
} catch {
throw new Error('Invalid instance URL format.');
}
let path = parsed.pathname.replace(/\/+$/, '');
path = path.replace(API_SUFFIX_RE, '');
const normalizedPath = path === '/' ? '' : path;
return `${parsed.protocol}//${parsed.host}${normalizedPath}`;
}
export function getApiBaseUrl(instanceUrl: string): string {
const root = normalizeInstanceUrl(instanceUrl);
return `${root}/api/v1`;
}
+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,
},
});
+128
View File
@@ -0,0 +1,128 @@
export interface Tag {
id: number;
name: string;
}
export interface User {
id: number;
email: string;
username: string;
full_name: string;
role?: string;
theme?: string;
created_at: string;
updated_at: string;
}
export interface AuthResponse {
token: string;
user: User;
}
export interface Bookmark {
id: number;
user_id: number;
title: string;
url: string;
description?: string;
favicon?: string;
screenshot?: string;
is_read?: boolean;
is_favorite?: boolean;
content?: string;
author?: string;
published_at?: string | null;
tags?: Array<Tag | string>;
created_at: string;
updated_at: string;
}
export type TaskStatus = 'pending' | 'in_progress' | 'completed' | 'cancelled';
export type TaskPriority = 'low' | 'medium' | 'high' | 'urgent';
export interface Task {
id: number;
user_id: number;
title: string;
description?: string;
status: TaskStatus;
priority: TaskPriority;
progress?: number;
due_date?: string | null;
completed_at?: string | null;
tags?: Array<Tag | string>;
created_at: string;
updated_at: string;
}
export interface Note {
id: number;
user_id: number;
title: string;
content?: string;
description?: string;
is_public: boolean;
is_pinned?: boolean;
content_type?: string;
tags?: Array<Tag | string>;
created_at: string;
updated_at: string;
}
export type FileType = 'document' | 'image' | 'video' | 'audio' | 'archive' | 'other';
export interface FileItem {
id: number;
user_id: number;
original_name: string;
file_name: string;
file_path: string;
file_size: number;
mime_type: string;
file_type: FileType;
description?: string;
is_public: boolean;
created_at: string;
updated_at: string;
}
export interface TimeEntry {
id: number;
user_id: number;
task_id?: number;
bookmark_id?: number;
note_id?: number;
start_time: string;
end_time?: string | null;
duration?: number | null;
description: string;
billable: boolean;
hourly_rate?: number | null;
is_running: boolean;
source: string;
tags?: Array<Tag | string>;
created_at: string;
updated_at: string;
}
export interface TimeStats {
total_time_seconds: number;
total_entries: number;
running_entries: number;
billable_time_seconds: number;
total_billable_amount: number;
}
export interface VersionInfo {
version?: string;
app_version?: string;
[key: string]: unknown;
}
export interface UpdateCheckInfo {
update_available?: boolean;
current_version?: string;
latest_version?: string;
message?: string;
[key: string]: unknown;
}
+6
View File
@@ -0,0 +1,6 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true
}
}