stadtwerke/innungsapp/apps/mobile/app/(app)/news/[id].tsx

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,
},
})