150 lines
6.7 KiB
TypeScript
150 lines
6.7 KiB
TypeScript
import React, { useState, useCallback, useEffect } from 'react'
|
|
import {
|
|
View, Text, FlatList, TextInput, TouchableOpacity, Modal,
|
|
StyleSheet, Share, RefreshControl, ScrollView,
|
|
} from 'react-native'
|
|
import { useFocusEffect } from 'expo-router'
|
|
import { COLORS } from '../../constants/Config'
|
|
import { getKBList, getKBDetail } from '../../services/api'
|
|
import { useKBBookmark } from '../../hooks/useKBBookmark'
|
|
import { useOffline } from '../../contexts/OfflineContext'
|
|
import MarkdownViewer from '../../components/MarkdownViewer'
|
|
|
|
const CATEGORIES = ['전체', '서버', '네트워크', '보안', 'CSAP', '기타']
|
|
|
|
export default function KBBrowserScreen() {
|
|
const [items, setItems] = useState<any[]>([])
|
|
const [query, setQuery] = useState('')
|
|
const [cat, setCat] = useState('전체')
|
|
const [loading, setLoading] = useState(false)
|
|
const [detail, setDetail] = useState<any>(null)
|
|
const { isBookmarked, toggle } = useKBBookmark()
|
|
const { isOffline, getCache, setCache } = useOffline()
|
|
|
|
const load = useCallback(async () => {
|
|
setLoading(true)
|
|
try {
|
|
if (isOffline) {
|
|
const cached = await getCache('kb_list')
|
|
if (cached) { setItems(cached as any[]); return }
|
|
}
|
|
const r = await getKBList(query || undefined)
|
|
const data = r.data?.items ?? r.data ?? []
|
|
setItems(data)
|
|
await setCache('kb_list', data)
|
|
} catch {
|
|
const cached = await getCache('kb_list')
|
|
if (cached) setItems(cached as any[])
|
|
} finally { setLoading(false) }
|
|
}, [query, isOffline])
|
|
|
|
useFocusEffect(useCallback(() => { load() }, [load]))
|
|
|
|
const openDetail = async (id: number) => {
|
|
try {
|
|
const r = await getKBDetail(id)
|
|
setDetail(r.data)
|
|
} catch { setDetail({ id, title: '상세 로드 실패', content: '네트워크 오류가 발생했습니다.' }) }
|
|
}
|
|
|
|
const shareKB = async () => {
|
|
if (!detail) return
|
|
await Share.share({ message: `[GUARDiA KB] ${detail.title}\n\n${(detail.content ?? '').slice(0, 200)}...` })
|
|
}
|
|
|
|
const filtered = items.filter(i => cat === '전체' || (i.category ?? '') === cat)
|
|
|
|
return (
|
|
<View style={s.container}>
|
|
{/* 검색 */}
|
|
<View style={s.searchBar}>
|
|
<TextInput
|
|
style={s.searchInput}
|
|
value={query}
|
|
onChangeText={setQuery}
|
|
placeholder="KB 검색..."
|
|
onSubmitEditing={load}
|
|
returnKeyType="search"
|
|
/>
|
|
{isOffline && <View style={s.offlineBadge}><Text style={s.offlineText}>오프라인</Text></View>}
|
|
</View>
|
|
|
|
{/* 카테고리 */}
|
|
<ScrollView horizontal showsHorizontalScrollIndicator={false} style={s.catBar}>
|
|
{CATEGORIES.map(c => (
|
|
<TouchableOpacity key={c} style={[s.catChip, cat===c && s.catChipActive]} onPress={() => setCat(c)}>
|
|
<Text style={[s.catText, cat===c && s.catTextActive]}>{c}</Text>
|
|
</TouchableOpacity>
|
|
))}
|
|
</ScrollView>
|
|
|
|
{/* 목록 */}
|
|
<FlatList
|
|
data={filtered}
|
|
keyExtractor={i => String(i.id ?? i.kb_id)}
|
|
refreshControl={<RefreshControl refreshing={loading} onRefresh={load} />}
|
|
ListEmptyComponent={<Text style={s.empty}>검색 결과가 없습니다.</Text>}
|
|
contentContainerStyle={{ padding: 12 }}
|
|
renderItem={({ item }) => (
|
|
<TouchableOpacity style={s.card} onPress={() => openDetail(item.id ?? item.kb_id)}>
|
|
<View style={s.cardRow}>
|
|
<View style={{ flex: 1 }}>
|
|
<Text style={s.title} numberOfLines={2}>{item.title}</Text>
|
|
<View style={s.metaRow}>
|
|
<Text style={s.chip}>{item.category ?? '기타'}</Text>
|
|
<Text style={s.meta}>조회 {item.view_count ?? 0}</Text>
|
|
</View>
|
|
</View>
|
|
<TouchableOpacity onPress={() => toggle(item.id ?? item.kb_id)} style={s.bookmark}>
|
|
<Text style={{ fontSize: 20 }}>{isBookmarked(item.id ?? item.kb_id) ? '⭐' : '☆'}</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
</TouchableOpacity>
|
|
)}
|
|
/>
|
|
|
|
{/* 상세 모달 */}
|
|
<Modal visible={!!detail} animationType="slide">
|
|
<View style={s.modalContainer}>
|
|
<View style={s.modalHeader}>
|
|
<TouchableOpacity onPress={() => setDetail(null)}>
|
|
<Text style={s.back}>← 닫기</Text>
|
|
</TouchableOpacity>
|
|
<TouchableOpacity onPress={shareKB}>
|
|
<Text style={s.shareBtn}>공유</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
<Text style={s.modalTitle}>{detail?.title}</Text>
|
|
<MarkdownViewer content={detail?.content ?? ''} style={{ flex: 1, padding: 16 }} />
|
|
</View>
|
|
</Modal>
|
|
</View>
|
|
)
|
|
}
|
|
|
|
const s = StyleSheet.create({
|
|
container: { flex: 1, backgroundColor: COLORS.bg },
|
|
searchBar: { flexDirection: 'row', alignItems: 'center', padding: 10, backgroundColor: '#fff', borderBottomWidth: 1, borderBottomColor: COLORS.border, gap: 8 },
|
|
searchInput: { flex: 1, backgroundColor: COLORS.bg, borderRadius: 8, paddingHorizontal: 12, paddingVertical: 8, fontSize: 14, color: COLORS.text },
|
|
offlineBadge: { backgroundColor: COLORS.warning, borderRadius: 4, paddingHorizontal: 6, paddingVertical: 2 },
|
|
offlineText: { fontSize: 10, color: '#fff', fontWeight: '700' },
|
|
catBar: { backgroundColor: '#fff', borderBottomWidth: 1, borderBottomColor: COLORS.border },
|
|
catChip: { paddingHorizontal: 14, paddingVertical: 8, marginHorizontal: 4 },
|
|
catChipActive: { borderBottomWidth: 2, borderBottomColor: COLORS.accent },
|
|
catText: { fontSize: 13, color: COLORS.muted },
|
|
catTextActive: { color: COLORS.accent, fontWeight: '700' },
|
|
card: { backgroundColor: '#fff', borderRadius: 10, padding: 14, marginBottom: 8, elevation: 1 },
|
|
cardRow: { flexDirection: 'row', alignItems: 'flex-start' },
|
|
title: { fontSize: 14, fontWeight: '600', color: COLORS.text, marginBottom: 6 },
|
|
metaRow: { flexDirection: 'row', alignItems: 'center', gap: 8 },
|
|
chip: { fontSize: 11, backgroundColor: COLORS.light, color: COLORS.blue, paddingHorizontal: 6, paddingVertical: 2, borderRadius: 4 },
|
|
meta: { fontSize: 12, color: COLORS.muted },
|
|
bookmark: { paddingLeft: 8 },
|
|
empty: { textAlign: 'center', color: COLORS.muted, marginTop: 40 },
|
|
modalContainer: { flex: 1, backgroundColor: '#fff' },
|
|
modalHeader: { flexDirection: 'row', justifyContent: 'space-between', padding: 16, borderBottomWidth: 1, borderBottomColor: COLORS.border },
|
|
back: { fontSize: 15, color: COLORS.accent, fontWeight: '600' },
|
|
shareBtn: { fontSize: 15, color: COLORS.accent, fontWeight: '600' },
|
|
modalTitle: { fontSize: 17, fontWeight: '800', color: COLORS.text, padding: 16, paddingBottom: 0 },
|
|
})
|