613 lines
16 KiB
TypeScript
613 lines
16 KiB
TypeScript
import React, { useState, useEffect, useCallback } from 'react';
|
|
import {
|
|
View,
|
|
Text,
|
|
TextInput,
|
|
Pressable,
|
|
FlatList,
|
|
ActivityIndicator,
|
|
StyleSheet,
|
|
RefreshControl,
|
|
KeyboardAvoidingView,
|
|
Platform,
|
|
} from 'react-native';
|
|
import { toast } from 'sonner-native';
|
|
import {
|
|
CheckCircle2,
|
|
Circle,
|
|
Trash2,
|
|
Plus,
|
|
RefreshCw,
|
|
Edit2,
|
|
X,
|
|
Check,
|
|
Activity,
|
|
Database,
|
|
HardDrive,
|
|
} from 'lucide-react-native';
|
|
import { getApiUrl } from '../../utils/api';
|
|
|
|
type Todo = {
|
|
id: number;
|
|
title: string;
|
|
description: string | null;
|
|
completed: boolean;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
attachment: unknown | null;
|
|
};
|
|
|
|
type HealthResponse = {
|
|
status: string;
|
|
projectId: string;
|
|
ready: boolean;
|
|
config: {
|
|
apiPrefix: string;
|
|
databaseConfigured: boolean;
|
|
storageConfigured: boolean;
|
|
missingEnv: string[];
|
|
};
|
|
};
|
|
|
|
export default function Index() {
|
|
const [todos, setTodos] = useState<Todo[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
// Form State
|
|
const [title, setTitle] = useState('');
|
|
const [description, setDescription] = useState('');
|
|
const [submitting, setSubmitting] = useState(false);
|
|
|
|
// Edit State
|
|
const [editingId, setEditingId] = useState<number | null>(null);
|
|
const [editTitle, setEditTitle] = useState('');
|
|
const [editDescription, setEditDescription] = useState('');
|
|
|
|
// Health State
|
|
const [health, setHealth] = useState<HealthResponse | null>(null);
|
|
const [healthLoading, setHealthLoading] = useState(true);
|
|
|
|
// Fetch Health Check Status
|
|
const fetchHealth = useCallback(async () => {
|
|
try {
|
|
setHealthLoading(true);
|
|
const res = await fetch(getApiUrl('/health'));
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
const data = await res.json();
|
|
setHealth(data);
|
|
} catch (err) {
|
|
console.warn('Health check failed', err);
|
|
setHealth(null);
|
|
} finally {
|
|
setHealthLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
// Fetch Todos List
|
|
const fetchTodos = useCallback(async () => {
|
|
try {
|
|
setLoading(true);
|
|
setError(null);
|
|
const res = await fetch(getApiUrl('/todos'));
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
const data = await res.json();
|
|
setTodos(data.todos ?? []);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Failed to fetch todos');
|
|
toast.error('Fetch Failed', {
|
|
description: 'Could not retrieve todo items from backend.',
|
|
});
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
// Initial Load
|
|
useEffect(() => {
|
|
fetchHealth();
|
|
fetchTodos();
|
|
}, [fetchHealth, fetchTodos]);
|
|
|
|
const handleRefresh = async () => {
|
|
await Promise.all([fetchHealth(), fetchTodos()]);
|
|
toast.success('Refreshed', {
|
|
description: 'Dashboard updated successfully.',
|
|
});
|
|
};
|
|
|
|
// Create Todo
|
|
const createTodo = async () => {
|
|
if (!title.trim()) return;
|
|
try {
|
|
setSubmitting(true);
|
|
const res = await fetch(getApiUrl('/todos'), {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
title: title.trim(),
|
|
description: description.trim() || null,
|
|
}),
|
|
});
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
setTitle('');
|
|
setDescription('');
|
|
toast.success('Success', {
|
|
description: 'Todo item created.',
|
|
});
|
|
await fetchTodos();
|
|
} catch (err) {
|
|
toast.error('Error', {
|
|
description: 'Failed to create todo item.',
|
|
});
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
};
|
|
|
|
// Toggle Completed
|
|
const toggleCompleted = async (todo: Todo) => {
|
|
try {
|
|
const res = await fetch(getApiUrl(`/todos/${todo.id}`), {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ completed: !todo.completed }),
|
|
});
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
toast.success(todo.completed ? 'Reopened' : 'Completed', {
|
|
description: `"${todo.title}" updated.`,
|
|
});
|
|
await fetchTodos();
|
|
} catch (err) {
|
|
toast.error('Error', {
|
|
description: 'Failed to update todo status.',
|
|
});
|
|
}
|
|
};
|
|
|
|
// Update Title/Description
|
|
const saveEdit = async (id: number) => {
|
|
if (!editTitle.trim()) return;
|
|
try {
|
|
const res = await fetch(getApiUrl(`/todos/${id}`), {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
title: editTitle.trim(),
|
|
description: editDescription.trim() || null,
|
|
}),
|
|
});
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
setEditingId(null);
|
|
toast.success('Updated', {
|
|
description: 'Todo changes saved.',
|
|
});
|
|
await fetchTodos();
|
|
} catch (err) {
|
|
toast.error('Error', {
|
|
description: 'Failed to update todo details.',
|
|
});
|
|
}
|
|
};
|
|
|
|
// Delete Todo
|
|
const deleteTodo = async (id: number) => {
|
|
try {
|
|
const res = await fetch(getApiUrl(`/todos/${id}`), {
|
|
method: 'DELETE',
|
|
});
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
toast.success('Deleted', {
|
|
description: 'Todo item removed.',
|
|
});
|
|
await fetchTodos();
|
|
} catch (err) {
|
|
toast.error('Error', {
|
|
description: 'Failed to delete todo.',
|
|
});
|
|
}
|
|
};
|
|
|
|
return (
|
|
<KeyboardAvoidingView
|
|
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
|
style={styles.container}
|
|
>
|
|
{/* Header Banner */}
|
|
<View style={styles.header}>
|
|
<View style={styles.headerRow}>
|
|
<Text style={styles.headerTitle}>Todo Workspace</Text>
|
|
<Pressable style={styles.refreshBtn} onPress={handleRefresh}>
|
|
<RefreshCw size={18} color="#4F46E5" />
|
|
</Pressable>
|
|
</View>
|
|
|
|
{/* Health Check Panel */}
|
|
{healthLoading ? (
|
|
<View style={styles.healthContainer}>
|
|
<ActivityIndicator size="small" color="#4F46E5" />
|
|
<Text style={styles.healthText}>Verifying system health...</Text>
|
|
</View>
|
|
) : health ? (
|
|
<View style={[styles.healthContainer, health.status === 'ok' ? styles.healthOk : styles.healthErr]}>
|
|
<View style={styles.healthRow}>
|
|
<Activity size={14} color={health.status === 'ok' ? '#10B981' : '#EF4444'} />
|
|
<Text style={styles.healthStatusText}>
|
|
Status: {health.status.toUpperCase()}
|
|
</Text>
|
|
</View>
|
|
<View style={styles.healthIcons}>
|
|
<View style={styles.healthIconItem}>
|
|
<Database size={12} color={health.config.databaseConfigured ? '#10B981' : '#EF4444'} />
|
|
<Text style={styles.healthIconLabel}>DB</Text>
|
|
</View>
|
|
<View style={styles.healthIconItem}>
|
|
<HardDrive size={12} color={health.config.storageConfigured ? '#10B981' : '#EF4444'} />
|
|
<Text style={styles.healthIconLabel}>Storage</Text>
|
|
</View>
|
|
</View>
|
|
</View>
|
|
) : (
|
|
<View style={[styles.healthContainer, styles.healthErr]}>
|
|
<Activity size={14} color="#EF4444" />
|
|
<Text style={styles.healthStatusText}>Backend Offline</Text>
|
|
</View>
|
|
)}
|
|
</View>
|
|
|
|
{/* Main Content List */}
|
|
<FlatList
|
|
data={todos}
|
|
keyExtractor={(item) => item.id.toString()}
|
|
refreshControl={
|
|
<RefreshControl refreshing={false} onRefresh={handleRefresh} colors={['#4F46E5']} />
|
|
}
|
|
contentContainerStyle={styles.listContent}
|
|
ListHeaderComponent={
|
|
/* Add Todo Panel */
|
|
<View style={styles.formContainer}>
|
|
<Text style={styles.sectionTitle}>Add New Task</Text>
|
|
<TextInput
|
|
style={styles.input}
|
|
placeholder="Task title..."
|
|
placeholderTextColor="#9CA3AF"
|
|
value={title}
|
|
onChangeText={setTitle}
|
|
/>
|
|
<TextInput
|
|
style={[styles.input, styles.textArea]}
|
|
placeholder="Description (optional)..."
|
|
placeholderTextColor="#9CA3AF"
|
|
multiline
|
|
numberOfLines={2}
|
|
value={description}
|
|
onChangeText={setDescription}
|
|
/>
|
|
<Pressable
|
|
style={[styles.createBtn, !title.trim() && styles.createBtnDisabled]}
|
|
onPress={createTodo}
|
|
disabled={!title.trim() || submitting}
|
|
>
|
|
{submitting ? (
|
|
<ActivityIndicator size="small" color="#FFFFFF" />
|
|
) : (
|
|
<>
|
|
<Plus size={18} color="#FFFFFF" style={styles.btnIcon} />
|
|
<Text style={styles.createBtnText}>Create Todo</Text>
|
|
</>
|
|
)}
|
|
</Pressable>
|
|
</View>
|
|
}
|
|
ListEmptyComponent={
|
|
!loading ? (
|
|
<View style={styles.emptyContainer}>
|
|
<Text style={styles.emptyText}>No tasks yet. Create one above!</Text>
|
|
</View>
|
|
) : null
|
|
}
|
|
renderItem={({ item }) => (
|
|
<View style={[styles.todoCard, item.completed && styles.todoCardCompleted]}>
|
|
{editingId === item.id ? (
|
|
/* Editing State */
|
|
<View style={styles.editForm}>
|
|
<TextInput
|
|
style={styles.editInput}
|
|
value={editTitle}
|
|
onChangeText={setEditTitle}
|
|
placeholder="Task title..."
|
|
/>
|
|
<TextInput
|
|
style={[styles.editInput, styles.textArea]}
|
|
value={editDescription}
|
|
onChangeText={setEditDescription}
|
|
placeholder="Description..."
|
|
multiline
|
|
/>
|
|
<View style={styles.editActions}>
|
|
<Pressable
|
|
style={[styles.iconBtn, styles.saveBtn]}
|
|
onPress={() => saveEdit(item.id)}
|
|
>
|
|
<Check size={16} color="#FFFFFF" />
|
|
</Pressable>
|
|
<Pressable
|
|
style={[styles.iconBtn, styles.cancelBtn]}
|
|
onPress={() => setEditingId(null)}
|
|
>
|
|
<X size={16} color="#FFFFFF" />
|
|
</Pressable>
|
|
</View>
|
|
</View>
|
|
) : (
|
|
/* Display State */
|
|
<View style={styles.todoRow}>
|
|
<Pressable onPress={() => toggleCompleted(item)} style={styles.checkboxContainer}>
|
|
{item.completed ? (
|
|
<CheckCircle2 size={22} color="#10B981" />
|
|
) : (
|
|
<Circle size={22} color="#D1D5DB" />
|
|
)}
|
|
</Pressable>
|
|
|
|
<View style={styles.todoTexts}>
|
|
<Text style={[styles.todoTitle, item.completed && styles.lineThrough]}>
|
|
{item.title}
|
|
</Text>
|
|
{item.description ? (
|
|
<Text style={[styles.todoDesc, item.completed && styles.lineThrough]}>
|
|
{item.description}
|
|
</Text>
|
|
) : null}
|
|
</View>
|
|
|
|
<View style={styles.todoActions}>
|
|
<Pressable
|
|
style={styles.actionIconBtn}
|
|
onPress={() => {
|
|
setEditingId(item.id);
|
|
setEditTitle(item.title);
|
|
setEditDescription(item.description ?? '');
|
|
}}
|
|
>
|
|
<Edit2 size={16} color="#4F46E5" />
|
|
</Pressable>
|
|
<Pressable style={styles.actionIconBtn} onPress={() => deleteTodo(item.id)}>
|
|
<Trash2 size={16} color="#EF4444" />
|
|
</Pressable>
|
|
</View>
|
|
</View>
|
|
)}
|
|
</View>
|
|
)}
|
|
/>
|
|
</KeyboardAvoidingView>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
container: {
|
|
flex: 1,
|
|
backgroundColor: '#F9FAFB',
|
|
},
|
|
header: {
|
|
backgroundColor: '#FFFFFF',
|
|
paddingTop: 50,
|
|
paddingHorizontal: 20,
|
|
paddingBottom: 16,
|
|
borderBottomWidth: 1,
|
|
borderBottomColor: '#E5E7EB',
|
|
},
|
|
headerRow: {
|
|
flexDirection: 'row',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center',
|
|
marginBottom: 12,
|
|
},
|
|
headerTitle: {
|
|
fontSize: 22,
|
|
fontWeight: 'bold',
|
|
color: '#111827',
|
|
},
|
|
refreshBtn: {
|
|
padding: 8,
|
|
borderRadius: 8,
|
|
backgroundColor: '#EEF2F6',
|
|
},
|
|
healthContainer: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
paddingVertical: 8,
|
|
paddingHorizontal: 12,
|
|
borderRadius: 8,
|
|
backgroundColor: '#F3F4F6',
|
|
},
|
|
healthOk: {
|
|
backgroundColor: '#ECFDF5',
|
|
},
|
|
healthErr: {
|
|
backgroundColor: '#FEF2F2',
|
|
},
|
|
healthRow: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: 6,
|
|
},
|
|
healthStatusText: {
|
|
fontSize: 12,
|
|
fontWeight: '600',
|
|
color: '#374151',
|
|
},
|
|
healthIcons: {
|
|
flexDirection: 'row',
|
|
gap: 12,
|
|
},
|
|
healthIconItem: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: 4,
|
|
},
|
|
healthIconLabel: {
|
|
fontSize: 10,
|
|
fontWeight: '500',
|
|
color: '#6B7280',
|
|
},
|
|
healthText: {
|
|
fontSize: 12,
|
|
color: '#6B7280',
|
|
marginLeft: 8,
|
|
},
|
|
listContent: {
|
|
padding: 20,
|
|
paddingBottom: 40,
|
|
},
|
|
formContainer: {
|
|
backgroundColor: '#FFFFFF',
|
|
borderRadius: 12,
|
|
padding: 16,
|
|
marginBottom: 20,
|
|
borderWidth: 1,
|
|
borderColor: '#E5E7EB',
|
|
shadowColor: '#000',
|
|
shadowOffset: { width: 0, height: 1 },
|
|
shadowOpacity: 0.05,
|
|
shadowRadius: 2,
|
|
elevation: 2,
|
|
},
|
|
sectionTitle: {
|
|
fontSize: 16,
|
|
fontWeight: '600',
|
|
color: '#374151',
|
|
marginBottom: 12,
|
|
},
|
|
input: {
|
|
backgroundColor: '#F9FAFB',
|
|
borderWidth: 1,
|
|
borderColor: '#D1D5DB',
|
|
borderRadius: 8,
|
|
paddingHorizontal: 12,
|
|
paddingVertical: 8,
|
|
fontSize: 14,
|
|
color: '#111827',
|
|
marginBottom: 10,
|
|
},
|
|
textArea: {
|
|
height: 60,
|
|
textAlignVertical: 'top',
|
|
},
|
|
createBtn: {
|
|
backgroundColor: '#4F46E5',
|
|
flexDirection: 'row',
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
paddingVertical: 10,
|
|
borderRadius: 8,
|
|
marginTop: 4,
|
|
},
|
|
createBtnDisabled: {
|
|
backgroundColor: '#93C5FD',
|
|
},
|
|
btnIcon: {
|
|
marginRight: 6,
|
|
},
|
|
createBtnText: {
|
|
color: '#FFFFFF',
|
|
fontWeight: '600',
|
|
fontSize: 14,
|
|
},
|
|
todoCard: {
|
|
backgroundColor: '#FFFFFF',
|
|
borderRadius: 12,
|
|
padding: 16,
|
|
marginBottom: 12,
|
|
borderWidth: 1,
|
|
borderColor: '#E5E7EB',
|
|
shadowColor: '#000',
|
|
shadowOffset: { width: 0, height: 1 },
|
|
shadowOpacity: 0.03,
|
|
shadowRadius: 2,
|
|
elevation: 1,
|
|
},
|
|
todoCardCompleted: {
|
|
backgroundColor: '#FAFAFA',
|
|
borderColor: '#E5E7EB',
|
|
},
|
|
todoRow: {
|
|
flexDirection: 'row',
|
|
alignItems: 'flex-start',
|
|
},
|
|
checkboxContainer: {
|
|
marginRight: 12,
|
|
marginTop: 2,
|
|
},
|
|
todoTexts: {
|
|
flex: 1,
|
|
marginRight: 8,
|
|
},
|
|
todoTitle: {
|
|
fontSize: 15,
|
|
fontWeight: '600',
|
|
color: '#111827',
|
|
marginBottom: 4,
|
|
},
|
|
todoDesc: {
|
|
fontSize: 13,
|
|
color: '#6B7280',
|
|
},
|
|
lineThrough: {
|
|
textDecorationLine: 'line-through',
|
|
color: '#9CA3AF',
|
|
},
|
|
todoActions: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: 8,
|
|
},
|
|
actionIconBtn: {
|
|
padding: 6,
|
|
borderRadius: 6,
|
|
backgroundColor: '#F3F4F6',
|
|
},
|
|
editForm: {
|
|
gap: 8,
|
|
},
|
|
editInput: {
|
|
backgroundColor: '#F9FAFB',
|
|
borderWidth: 1,
|
|
borderColor: '#D1D5DB',
|
|
borderRadius: 6,
|
|
paddingHorizontal: 10,
|
|
paddingVertical: 6,
|
|
fontSize: 14,
|
|
color: '#111827',
|
|
},
|
|
editActions: {
|
|
flexDirection: 'row',
|
|
justifyContent: 'flex-end',
|
|
gap: 8,
|
|
marginTop: 4,
|
|
},
|
|
iconBtn: {
|
|
paddingVertical: 6,
|
|
paddingHorizontal: 12,
|
|
borderRadius: 6,
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
},
|
|
saveBtn: {
|
|
backgroundColor: '#10B981',
|
|
},
|
|
cancelBtn: {
|
|
backgroundColor: '#EF4444',
|
|
},
|
|
emptyContainer: {
|
|
alignItems: 'center',
|
|
paddingVertical: 40,
|
|
},
|
|
emptyText: {
|
|
fontSize: 14,
|
|
color: '#9CA3AF',
|
|
},
|
|
});
|