feat: F022 source acquisition, crawling & connector abstraction

This commit is contained in:
Benjamin Sutter
2026-05-15 15:32:53 +02:00
parent 8efd6e1974
commit fce0f684af
20 changed files with 1647 additions and 0 deletions
+122
View File
@@ -0,0 +1,122 @@
import { Box, Chip, MenuItem, Select, TextField, Typography } from '@mui/material'
import { Search } from 'lucide-react'
import type { DataSource, SourceFilters } from '../../domain/dataSource'
import {
DataSourceType,
SourceStatus,
DATA_SOURCE_TYPE_LABELS,
SOURCE_STATUS_LABELS,
} from '../../domain/dataSource'
import { SourceCard } from './SourceCard'
import { MarketSignalSkeleton } from './MarketSignalSkeleton'
import { MarketSignalEmptyState } from './MarketSignalEmptyState'
interface SourceListProps {
sources: DataSource[]
isLoading: boolean
selectedId: string | null
onSelect: (id: string) => void
filters: SourceFilters
onFiltersChange: (f: SourceFilters) => void
}
export function SourceList({
sources,
isLoading,
selectedId,
onSelect,
filters,
onFiltersChange,
}: SourceListProps) {
const hasActiveFilters = filters.sourceType || filters.status || filters.search
return (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
{/* Header */}
<Box sx={{ px: 1.5, py: 1.25, borderBottom: '1px solid #e2e8f0', display: 'flex', alignItems: 'center', gap: 1, flexShrink: 0 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, flex: 1 }}>
Datenquellen
</Typography>
<Chip
label={isLoading ? '…' : sources.length}
size="small"
sx={{ bgcolor: '#1e3a5f', color: '#fff', fontSize: '0.7rem', height: 20 }}
/>
</Box>
{/* Search */}
<Box sx={{ px: 1.5, py: 1, borderBottom: '1px solid #f1f5f9', flexShrink: 0 }}>
<TextField
size="small"
placeholder="Suchen..."
fullWidth
value={filters.search ?? ''}
onChange={(e) => onFiltersChange({ ...filters, search: e.target.value || undefined })}
slotProps={{
input: {
startAdornment: <Search size={14} color="#94a3b8" style={{ marginRight: 6 }} />,
},
}}
sx={{ '& .MuiOutlinedInput-root': { fontSize: '0.8rem' } }}
/>
</Box>
{/* Filters */}
<Box sx={{ px: 1.5, py: 1, borderBottom: '1px solid #f1f5f9', display: 'flex', gap: 1, flexShrink: 0 }}>
<Select
size="small"
displayEmpty
value={filters.sourceType ?? ''}
onChange={(e) => onFiltersChange({ ...filters, sourceType: (e.target.value as typeof DataSourceType[keyof typeof DataSourceType]) || undefined })}
sx={{ flex: 1, fontSize: '0.75rem', '& .MuiSelect-select': { py: 0.75 } }}
>
<MenuItem value=""><em>Alle Typen</em></MenuItem>
{Object.values(DataSourceType).map((t) => (
<MenuItem key={t} value={t} sx={{ fontSize: '0.8rem' }}>{DATA_SOURCE_TYPE_LABELS[t]}</MenuItem>
))}
</Select>
<Select
size="small"
displayEmpty
value={filters.status ?? ''}
onChange={(e) => onFiltersChange({ ...filters, status: (e.target.value as typeof SourceStatus[keyof typeof SourceStatus]) || undefined })}
sx={{ flex: 1, fontSize: '0.75rem', '& .MuiSelect-select': { py: 0.75 } }}
>
<MenuItem value=""><em>Alle Status</em></MenuItem>
{Object.values(SourceStatus).map((s) => (
<MenuItem key={s} value={s} sx={{ fontSize: '0.8rem' }}>{SOURCE_STATUS_LABELS[s]}</MenuItem>
))}
</Select>
</Box>
{hasActiveFilters && (
<Box sx={{ px: 1.5, py: 0.5, flexShrink: 0 }}>
<Chip
size="small"
label="Filter zurücksetzen"
onClick={() => onFiltersChange({})}
sx={{ fontSize: '0.7rem', cursor: 'pointer' }}
/>
</Box>
)}
{/* List */}
<Box sx={{ flex: 1, overflowY: 'auto' }}>
{isLoading ? (
Array.from({ length: 4 }).map((_, i) => <MarketSignalSkeleton key={i} />)
) : sources.length === 0 ? (
<MarketSignalEmptyState variant={hasActiveFilters ? 'no-results' : 'empty-inbox'} />
) : (
sources.map((source) => (
<SourceCard
key={source.id}
source={source}
selected={source.id === selectedId}
onClick={() => onSelect(source.id)}
/>
))
)}
</Box>
</Box>
)
}