Files
contexta_mb/src/screens/chatbots/tabs/DocumentsTab.tsx
belviskhoremk 9e663bdc8b Initial commit
2026-05-08 13:01:47 +00:00

287 lines
10 KiB
TypeScript

import React, { useState } from 'react';
import {
View,
Text,
StyleSheet,
FlatList,
TouchableOpacity,
Alert,
} from 'react-native';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { COLORS, FONT_SIZE, SPACING, RADIUS } from '../../../theme';
import { useTheme } from '../../../theme';
import { Card, StatusBadge, Spinner, EmptyState, Button, Input } from '../../../components/ui';
import { useToast } from '../../../contexts/ToastContext';
import { documentsAPI, urlSourcesAPI } from '../../../services/api';
import { Document, URLSource } from '../../../types';
interface Props {
chatbotId: string;
}
export function DocumentsTab({ chatbotId }: Props) {
const { theme } = useTheme();
const toast = useToast();
const qc = useQueryClient();
const [urlInput, setUrlInput] = useState('');
const [addingUrl, setAddingUrl] = useState(false);
const [showUrlInput, setShowUrlInput] = useState(false);
const { data: documents, isLoading: loadingDocs } = useQuery({
queryKey: ['documents', chatbotId],
queryFn: () => documentsAPI.list(chatbotId),
});
const { data: urlSources } = useQuery({
queryKey: ['urlSources', chatbotId],
queryFn: () => urlSourcesAPI.list(chatbotId),
});
const deleteMutation = useMutation({
mutationFn: (docId: string) => documentsAPI.delete(chatbotId, docId),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['documents', chatbotId] });
toast.success('Document deleted');
},
onError: () => toast.error('Failed to delete document'),
});
const retryMutation = useMutation({
mutationFn: (docId: string) => documentsAPI.retry(chatbotId, docId),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['documents', chatbotId] });
toast.success('Retry started');
},
});
const deleteUrlMutation = useMutation({
mutationFn: (sourceId: string) => urlSourcesAPI.delete(chatbotId, sourceId),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['urlSources', chatbotId] });
toast.success('URL source removed');
},
});
const handleAddUrl = async () => {
const url = urlInput.trim();
if (!url) return;
if (!url.startsWith('http://') && !url.startsWith('https://')) {
toast.error('Enter a valid URL starting with http:// or https://');
return;
}
setAddingUrl(true);
try {
await urlSourcesAPI.add(chatbotId, url);
qc.invalidateQueries({ queryKey: ['urlSources', chatbotId] });
setUrlInput('');
setShowUrlInput(false);
toast.success('URL added! Scraping in background...');
} catch (err: any) {
toast.error(err?.response?.data?.detail ?? 'Failed to add URL');
} finally {
setAddingUrl(false);
}
};
const confirmDelete = (doc: Document) => {
Alert.alert('Delete Document', `Remove "${doc.file_name}"?`, [
{ text: 'Cancel', style: 'cancel' },
{ text: 'Delete', style: 'destructive', onPress: () => deleteMutation.mutate(doc.id) },
]);
};
const formatSize = (bytes: number) => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};
type ListItem =
| { type: 'doc'; data: Document }
| { type: 'url'; data: URLSource }
| { type: 'upload-tip' };
const listData: ListItem[] = [
{ type: 'upload-tip' },
...(documents ?? []).map((d: Document) => ({ type: 'doc' as const, data: d })),
...(urlSources ?? []).map((u: URLSource) => ({ type: 'url' as const, data: u })),
];
return (
<View style={[styles.container, { backgroundColor: theme.bg }]}>
{/* Add URL bar */}
<View style={[styles.toolbar, { backgroundColor: theme.surface, borderBottomColor: theme.border }]}>
<Button
title="+ Add URL"
size="sm"
onPress={() => setShowUrlInput(v => !v)}
/>
<Text style={[styles.toolbarHint, { color: theme.textMuted }]}>
{(documents ?? []).length + (urlSources ?? []).length} sources
</Text>
</View>
{showUrlInput && (
<View style={[styles.urlBar, { backgroundColor: theme.surface, borderBottomColor: theme.border }]}>
<Input
value={urlInput}
onChangeText={setUrlInput}
placeholder="https://example.com/page"
keyboardType="url"
autoCapitalize="none"
autoCorrect={false}
style={styles.urlInput}
/>
<View style={styles.urlActions}>
<Button title="Cancel" variant="ghost" size="sm" onPress={() => { setShowUrlInput(false); setUrlInput(''); }} />
<Button title="Scrape" size="sm" onPress={handleAddUrl} loading={addingUrl} />
</View>
</View>
)}
<FlatList
data={listData}
keyExtractor={(item, i) => (item.type === 'upload-tip' ? 'tip' : item.type === 'doc' ? item.data.id : item.data.id)}
contentContainerStyle={styles.list}
renderItem={({ item }) => {
if (item.type === 'upload-tip') {
return (
<Card style={[styles.tipCard, { borderColor: COLORS.primaryUltraLight }] as any}>
<Text style={styles.tipIcon}>💡</Text>
<View style={styles.tipBody}>
<Text style={[styles.tipTitle, { color: theme.text }]}>Upload files from the web app</Text>
<Text style={[styles.tipDesc, { color: theme.textSecondary }]}>
PDF, DOCX, CSV, XLSX, TXT and MD files can be uploaded from the Contexta web dashboard. Use "Add URL" here to scrape web pages directly.
</Text>
</View>
</Card>
);
}
if (item.type === 'doc') {
const doc = item.data as Document;
const icon = doc.file_type?.includes('pdf') ? '📄'
: doc.file_type?.includes('csv') || doc.file_type?.includes('xlsx') ? '📊'
: '📝';
return (
<View style={[styles.docItem, { backgroundColor: theme.surface, borderColor: theme.border }]}>
<View style={[styles.docIcon, { backgroundColor: COLORS.primaryUltraLight }]}>
<Text style={styles.docIconText}>{icon}</Text>
</View>
<View style={styles.docInfo}>
<Text style={[styles.docName, { color: theme.text }]} numberOfLines={1}>{doc.file_name}</Text>
<View style={styles.docMeta}>
<Text style={[styles.docSize, { color: theme.textMuted }]}>{formatSize(doc.file_size)}</Text>
{doc.chunk_count > 0 && (
<Text style={[styles.docSize, { color: theme.textMuted }]}> · {doc.chunk_count} chunks</Text>
)}
</View>
<View style={styles.docFooter}>
<StatusBadge status={doc.status} />
{doc.status === 'failed' && (
<TouchableOpacity onPress={() => retryMutation.mutate(doc.id)}>
<Text style={[styles.retryText, { color: COLORS.primary }]}>Retry</Text>
</TouchableOpacity>
)}
</View>
</View>
<TouchableOpacity onPress={() => confirmDelete(doc)} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
<Text style={styles.deleteIcon}></Text>
</TouchableOpacity>
</View>
);
}
// url source
const src = item.data as URLSource;
return (
<View style={[styles.docItem, { backgroundColor: theme.surface, borderColor: theme.border }]}>
<View style={[styles.docIcon, { backgroundColor: '#e0f2fe' }]}>
<Text style={styles.docIconText}>🌐</Text>
</View>
<View style={styles.docInfo}>
<Text style={[styles.docName, { color: theme.text }]} numberOfLines={1}>
{src.page_title ?? src.url}
</Text>
<Text style={[styles.docSize, { color: theme.textMuted }]} numberOfLines={1}>{src.url}</Text>
<View style={styles.docFooter}>
<StatusBadge status={src.status} />
{src.chunk_count ? (
<Text style={[styles.docSize, { color: theme.textMuted }]}>{src.chunk_count} chunks</Text>
) : null}
</View>
</View>
<TouchableOpacity onPress={() => deleteUrlMutation.mutate(src.id)} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
<Text style={styles.deleteIcon}></Text>
</TouchableOpacity>
</View>
);
}}
ListEmptyComponent={
loadingDocs ? <Spinner centered /> : null
}
/>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1 },
toolbar: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: SPACING.md,
paddingVertical: SPACING.sm,
borderBottomWidth: 1,
},
toolbarHint: { fontSize: FONT_SIZE.xs },
urlBar: {
paddingHorizontal: SPACING.md,
paddingBottom: SPACING.sm,
borderBottomWidth: 1,
gap: SPACING.xs,
},
urlInput: { marginBottom: 0 },
urlActions: { flexDirection: 'row', justifyContent: 'flex-end', gap: SPACING.sm },
list: { padding: SPACING.md, gap: SPACING.sm },
tipCard: {
flexDirection: 'row',
gap: SPACING.md,
backgroundColor: '#f0f4ff',
borderWidth: 1,
},
tipIcon: { fontSize: 22 },
tipBody: { flex: 1, gap: 4 },
tipTitle: { fontSize: FONT_SIZE.sm, fontWeight: '600' },
tipDesc: { fontSize: FONT_SIZE.xs, lineHeight: 18 },
docItem: {
flexDirection: 'row',
alignItems: 'center',
borderRadius: RADIUS.lg,
padding: SPACING.md,
borderWidth: 1,
gap: SPACING.sm,
},
docIcon: {
width: 40,
height: 40,
borderRadius: RADIUS.md,
alignItems: 'center',
justifyContent: 'center',
},
docIconText: { fontSize: 20 },
docInfo: { flex: 1, gap: 3 },
docName: { fontSize: FONT_SIZE.sm, fontWeight: '500' },
docMeta: { flexDirection: 'row', alignItems: 'center' },
docSize: { fontSize: FONT_SIZE.xs },
docFooter: { flexDirection: 'row', alignItems: 'center', gap: SPACING.sm, marginTop: 2 },
retryText: { fontSize: FONT_SIZE.xs, fontWeight: '600' },
deleteIcon: { fontSize: 16, color: '#9ca3af', padding: SPACING.xs },
});