391 lines
10 KiB
TypeScript
391 lines
10 KiB
TypeScript
import {
|
|
View, Text, ScrollView, TouchableOpacity, ActivityIndicator,
|
|
StyleSheet, Platform,
|
|
} from 'react-native'
|
|
import { SafeAreaView } from 'react-native-safe-area-context'
|
|
import { useLocalSearchParams, useRouter } from 'expo-router'
|
|
import { useEffect } from 'react'
|
|
import { Ionicons } from '@expo/vector-icons'
|
|
import { useNewsDetail } from '@/hooks/useNews'
|
|
import { useNewsReadStore } from '@/store/news.store'
|
|
import { AttachmentRow } from '@/components/news/AttachmentRow'
|
|
import { Badge } from '@/components/ui/Badge'
|
|
import { NEWS_KATEGORIE_LABELS } from '@innungsapp/shared/types'
|
|
import { format } from 'date-fns'
|
|
import { de } from 'date-fns/locale'
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Lightweight markdown renderer (headings, bold, bullets, paragraphs)
|
|
// ---------------------------------------------------------------------------
|
|
function MarkdownBody({ source }: { source: string }) {
|
|
const blocks = source.split(/\n\n+/)
|
|
return (
|
|
<View style={md.container}>
|
|
{blocks.map((block, i) => {
|
|
const trimmed = block.trim()
|
|
if (!trimmed) return null
|
|
|
|
// H2 ##
|
|
if (trimmed.startsWith('## ')) {
|
|
return (
|
|
<Text key={i} style={md.h2}>
|
|
{trimmed.slice(3)}
|
|
</Text>
|
|
)
|
|
}
|
|
// H3 ###
|
|
if (trimmed.startsWith('### ')) {
|
|
return (
|
|
<Text key={i} style={md.h3}>
|
|
{trimmed.slice(4)}
|
|
</Text>
|
|
)
|
|
}
|
|
// H1 #
|
|
if (trimmed.startsWith('# ')) {
|
|
return (
|
|
<Text key={i} style={md.h1}>
|
|
{trimmed.slice(2)}
|
|
</Text>
|
|
)
|
|
}
|
|
// Bullet list
|
|
if (trimmed.startsWith('- ')) {
|
|
const items = trimmed.split('\n').filter(Boolean)
|
|
return (
|
|
<View key={i} style={md.list}>
|
|
{items.map((line, j) => {
|
|
const text = line.replace(/^-\s+/, '')
|
|
return (
|
|
<View key={j} style={md.listItem}>
|
|
<View style={md.bullet} />
|
|
<Text style={md.listText}>{renderInline(text)}</Text>
|
|
</View>
|
|
)
|
|
})}
|
|
</View>
|
|
)
|
|
}
|
|
// Paragraph (with inline bold)
|
|
return (
|
|
<Text key={i} style={md.paragraph}>
|
|
{renderInline(trimmed)}
|
|
</Text>
|
|
)
|
|
})}
|
|
</View>
|
|
)
|
|
}
|
|
|
|
/** Render **bold** inline within a Text node */
|
|
function renderInline(text: string): React.ReactNode[] {
|
|
const parts = text.split(/(\*\*.*?\*\*)/)
|
|
return parts.map((part, i) => {
|
|
if (part.startsWith('**') && part.endsWith('**')) {
|
|
return (
|
|
<Text key={i} style={{ fontWeight: '700', color: '#0F172A' }}>
|
|
{part.slice(2, -2)}
|
|
</Text>
|
|
)
|
|
}
|
|
return <Text key={i}>{part}</Text>
|
|
})
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Screen
|
|
// ---------------------------------------------------------------------------
|
|
export default function NewsDetailScreen() {
|
|
const { id } = useLocalSearchParams<{ id: string }>()
|
|
const router = useRouter()
|
|
const { data: news, isLoading, onOpen } = useNewsDetail(id)
|
|
const markRead = useNewsReadStore((s) => s.markRead)
|
|
|
|
useEffect(() => {
|
|
if (news) {
|
|
markRead(news.id) // sofort lokal markieren → Badge weg beim Zurückgehen
|
|
onOpen()
|
|
}
|
|
}, [news?.id])
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<SafeAreaView style={styles.loadingContainer}>
|
|
<ActivityIndicator size="large" color="#003B7E" />
|
|
</SafeAreaView>
|
|
)
|
|
}
|
|
if (!news) return null
|
|
|
|
const initials = (news.author?.name ?? 'I')
|
|
.split(' ')
|
|
.map((n) => n.charAt(0))
|
|
.slice(0, 2)
|
|
.join('')
|
|
|
|
return (
|
|
<SafeAreaView style={styles.safeArea} edges={['top']}>
|
|
{/* Nav bar */}
|
|
<View style={styles.navBar}>
|
|
<TouchableOpacity
|
|
onPress={() => router.navigate('/(app)/news')}
|
|
style={styles.backBtn}
|
|
activeOpacity={0.7}
|
|
>
|
|
<Ionicons name="chevron-back" size={22} color="#003B7E" />
|
|
<Text style={styles.backText}>Neuigkeiten</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
|
|
<ScrollView
|
|
contentContainerStyle={styles.scrollContent}
|
|
showsVerticalScrollIndicator={false}
|
|
>
|
|
{/* ── Hero header ────────────────────────────────────────── */}
|
|
<View style={styles.hero}>
|
|
<Badge
|
|
label={NEWS_KATEGORIE_LABELS[news.kategorie]}
|
|
kategorie={news.kategorie}
|
|
/>
|
|
|
|
<Text style={styles.heroTitle}>{news.title}</Text>
|
|
|
|
{/* Author + date row */}
|
|
<View style={styles.metaRow}>
|
|
<View style={styles.avatarCircle}>
|
|
<Text style={styles.avatarText}>{initials}</Text>
|
|
</View>
|
|
<View>
|
|
<Text style={styles.authorName}>{news.author?.name ?? 'Innung'}</Text>
|
|
{news.publishedAt && (
|
|
<Text style={styles.dateText}>
|
|
{format(new Date(news.publishedAt), 'dd. MMMM yyyy', { locale: de })}
|
|
</Text>
|
|
)}
|
|
</View>
|
|
</View>
|
|
</View>
|
|
|
|
{/* ── Separator ──────────────────────────────────────────── */}
|
|
<View style={styles.heroSeparator} />
|
|
|
|
{/* ── Article body ───────────────────────────────────────── */}
|
|
<MarkdownBody source={news.body} />
|
|
|
|
{/* ── Attachments ────────────────────────────────────────── */}
|
|
{news.attachments.length > 0 && (
|
|
<View style={styles.attachmentsSection}>
|
|
<View style={styles.attachmentsHeader}>
|
|
<Ionicons name="attach" size={14} color="#64748B" />
|
|
<Text style={styles.attachmentsLabel}>
|
|
ANHÄNGE ({news.attachments.length})
|
|
</Text>
|
|
</View>
|
|
<View style={styles.attachmentsCard}>
|
|
{news.attachments.map((a, idx) => (
|
|
<View key={a.id}>
|
|
{idx > 0 && <View style={styles.attachmentsDivider} />}
|
|
<AttachmentRow attachment={a} />
|
|
</View>
|
|
))}
|
|
</View>
|
|
</View>
|
|
)}
|
|
|
|
{/* Bottom spacer */}
|
|
<View style={{ height: 48 }} />
|
|
</ScrollView>
|
|
</SafeAreaView>
|
|
)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Styles
|
|
// ---------------------------------------------------------------------------
|
|
const styles = StyleSheet.create({
|
|
safeArea: {
|
|
flex: 1,
|
|
backgroundColor: '#FAFAFA',
|
|
},
|
|
loadingContainer: {
|
|
flex: 1,
|
|
backgroundColor: '#FAFAFA',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
},
|
|
// Nav
|
|
navBar: {
|
|
backgroundColor: '#FAFAFA',
|
|
paddingHorizontal: 16,
|
|
paddingVertical: 10,
|
|
borderBottomWidth: StyleSheet.hairlineWidth,
|
|
borderBottomColor: '#E2E8F0',
|
|
},
|
|
backBtn: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: 2,
|
|
alignSelf: 'flex-start',
|
|
},
|
|
backText: {
|
|
fontSize: 15,
|
|
fontWeight: '600',
|
|
color: '#003B7E',
|
|
},
|
|
// Hero
|
|
hero: {
|
|
backgroundColor: '#FFFFFF',
|
|
paddingHorizontal: 20,
|
|
paddingTop: 22,
|
|
paddingBottom: 20,
|
|
gap: 12,
|
|
},
|
|
heroTitle: {
|
|
fontSize: 24,
|
|
fontWeight: '800',
|
|
color: '#0F172A',
|
|
letterSpacing: -0.5,
|
|
lineHeight: 32,
|
|
marginTop: 4,
|
|
},
|
|
metaRow: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: 10,
|
|
marginTop: 4,
|
|
},
|
|
avatarCircle: {
|
|
width: 38,
|
|
height: 38,
|
|
borderRadius: 19,
|
|
backgroundColor: '#003B7E',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
},
|
|
avatarText: {
|
|
color: '#FFFFFF',
|
|
fontSize: 13,
|
|
fontWeight: '700',
|
|
letterSpacing: 0.5,
|
|
},
|
|
authorName: {
|
|
fontSize: 14,
|
|
fontWeight: '600',
|
|
color: '#0F172A',
|
|
lineHeight: 18,
|
|
},
|
|
dateText: {
|
|
fontSize: 12,
|
|
color: '#94A3B8',
|
|
marginTop: 1,
|
|
},
|
|
heroSeparator: {
|
|
height: 4,
|
|
backgroundColor: '#F1F5F9',
|
|
},
|
|
// Scroll
|
|
scrollContent: {
|
|
flexGrow: 1,
|
|
},
|
|
// Attachments
|
|
attachmentsSection: {
|
|
marginHorizontal: 20,
|
|
marginTop: 28,
|
|
},
|
|
attachmentsHeader: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: 6,
|
|
marginBottom: 10,
|
|
},
|
|
attachmentsLabel: {
|
|
fontSize: 11,
|
|
fontWeight: '700',
|
|
color: '#64748B',
|
|
letterSpacing: 0.8,
|
|
},
|
|
attachmentsCard: {
|
|
backgroundColor: '#FFFFFF',
|
|
borderRadius: 16,
|
|
paddingHorizontal: 16,
|
|
overflow: 'hidden',
|
|
...Platform.select({
|
|
ios: {
|
|
shadowColor: '#1C1917',
|
|
shadowOffset: { width: 0, height: 2 },
|
|
shadowOpacity: 0.06,
|
|
shadowRadius: 10,
|
|
},
|
|
android: { elevation: 2 },
|
|
}),
|
|
},
|
|
attachmentsDivider: {
|
|
height: StyleSheet.hairlineWidth,
|
|
backgroundColor: '#F1F5F9',
|
|
},
|
|
})
|
|
|
|
// Markdown styles
|
|
const md = StyleSheet.create({
|
|
container: {
|
|
backgroundColor: '#FFFFFF',
|
|
paddingHorizontal: 20,
|
|
paddingTop: 22,
|
|
paddingBottom: 8,
|
|
gap: 14,
|
|
},
|
|
h1: {
|
|
fontSize: 22,
|
|
fontWeight: '800',
|
|
color: '#0F172A',
|
|
letterSpacing: -0.3,
|
|
lineHeight: 30,
|
|
},
|
|
h2: {
|
|
fontSize: 18,
|
|
fontWeight: '700',
|
|
color: '#0F172A',
|
|
letterSpacing: -0.2,
|
|
lineHeight: 26,
|
|
marginTop: 8,
|
|
paddingBottom: 6,
|
|
borderBottomWidth: 2,
|
|
borderBottomColor: '#EFF6FF',
|
|
},
|
|
h3: {
|
|
fontSize: 16,
|
|
fontWeight: '700',
|
|
color: '#1E293B',
|
|
lineHeight: 24,
|
|
marginTop: 4,
|
|
},
|
|
paragraph: {
|
|
fontSize: 16,
|
|
color: '#334155',
|
|
lineHeight: 28,
|
|
letterSpacing: 0.1,
|
|
},
|
|
list: {
|
|
gap: 8,
|
|
},
|
|
listItem: {
|
|
flexDirection: 'row',
|
|
alignItems: 'flex-start',
|
|
gap: 10,
|
|
},
|
|
bullet: {
|
|
width: 6,
|
|
height: 6,
|
|
borderRadius: 3,
|
|
backgroundColor: '#003B7E',
|
|
marginTop: 10,
|
|
flexShrink: 0,
|
|
},
|
|
listText: {
|
|
flex: 1,
|
|
fontSize: 16,
|
|
color: '#334155',
|
|
lineHeight: 28,
|
|
},
|
|
})
|