@wasto/cms-components 1.5.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/README.md +47 -0
  2. package/dist/index.d.mts +465 -0
  3. package/dist/index.d.ts +465 -0
  4. package/dist/index.js +10445 -0
  5. package/dist/index.mjs +10553 -0
  6. package/package.json +90 -0
  7. package/src/components/announcement-modal/AnnouncementModal.tsx +183 -0
  8. package/src/components/article/Article.tsx +22 -0
  9. package/src/components/article/ArticleCard.tsx +81 -0
  10. package/src/components/article/ArticleExcerpts.tsx +80 -0
  11. package/src/components/article/ArticleList.tsx +114 -0
  12. package/src/components/article/ArticleMarkdown.tsx +170 -0
  13. package/src/components/article/ArticleReaderModal.tsx +93 -0
  14. package/src/components/article/ArticleSingle.tsx +57 -0
  15. package/src/components/bottom-tab/BottomTab.tsx +392 -0
  16. package/src/components/button/Button.tsx +154 -0
  17. package/src/components/challenge/Badges.tsx +84 -0
  18. package/src/components/challenge/BadgesModal.tsx +166 -0
  19. package/src/components/challenge/Challenge.tsx +380 -0
  20. package/src/components/challenge/ChallengeModal.tsx +231 -0
  21. package/src/components/challenge/ChallengeVignette.tsx +249 -0
  22. package/src/components/challenge/ChallengesModal.tsx +243 -0
  23. package/src/components/challenge/QuizModal.tsx +381 -0
  24. package/src/components/challenge/ResultModal.tsx +144 -0
  25. package/src/components/chat/Chat.tsx +928 -0
  26. package/src/components/collection-calendar/CollectionCalendar.tsx +274 -0
  27. package/src/components/columns/Columns.tsx +34 -0
  28. package/src/components/consignes-modal/ConsignesModal.tsx +267 -0
  29. package/src/components/content-inset/ContentInset.tsx +33 -0
  30. package/src/components/header/Header.tsx +391 -0
  31. package/src/components/image/Image.tsx +16 -0
  32. package/src/components/image/ImageFrame.tsx +102 -0
  33. package/src/components/image/ImageMedia.tsx +31 -0
  34. package/src/components/image/ImageUrl.tsx +30 -0
  35. package/src/components/locations-map/LocationSheet.tsx +417 -0
  36. package/src/components/locations-map/LocationsMap.native.tsx +260 -0
  37. package/src/components/locations-map/LocationsMap.tsx +13 -0
  38. package/src/components/locations-map/LocationsMap.web.tsx +10 -0
  39. package/src/components/locations-map/LocationsMapShell.tsx +527 -0
  40. package/src/components/locations-map/LocationsMapView.tsx +108 -0
  41. package/src/components/locations-map/LocationsMapView.web.tsx +331 -0
  42. package/src/components/nav-button/NavButton.tsx +59 -0
  43. package/src/components/next-collection/NextCollection.tsx +621 -0
  44. package/src/components/notifications/NotificationsScreen.tsx +247 -0
  45. package/src/components/page/Page.tsx +64 -0
  46. package/src/components/parapraph/Paragraph.tsx +67 -0
  47. package/src/components/profile/ProfileScreen.tsx +236 -0
  48. package/src/components/recycling-info/RecyclingInfo.tsx +189 -0
  49. package/src/components/safe-area/OptionalSafeAreaProvider.tsx +17 -0
  50. package/src/components/search/Search.tsx +320 -0
  51. package/src/components/select-street-modal/SelectStreetModal.tsx +475 -0
  52. package/src/components/title/Title.tsx +100 -0
  53. package/src/components/vision/Vision.tsx +447 -0
  54. package/src/components/vision/VisionActionCard.tsx +117 -0
  55. package/src/components/vision/VisionCapture.tsx +30 -0
  56. package/src/components/vision/VisionTapOverlay.tsx +122 -0
  57. package/src/contexts/AnnouncementsContext.tsx +171 -0
  58. package/src/contexts/ChallengesContext.tsx +182 -0
  59. package/src/contexts/LocalContext.tsx +227 -0
  60. package/src/contexts/ThemeContext.tsx +37 -0
  61. package/src/contexts/WastoContext.tsx +127 -0
  62. package/src/helpers/blogPost.ts +72 -0
  63. package/src/helpers/generateQuizQuestions.ts +247 -0
  64. package/src/helpers/getContrastColor.ts +19 -0
  65. package/src/helpers/haptics.ts +20 -0
  66. package/src/helpers/hoursSchedule.ts +230 -0
  67. package/src/helpers/htmlContentToMarkdown.ts +114 -0
  68. package/src/helpers/layout.ts +54 -0
  69. package/src/helpers/loadLinearGradient.ts +22 -0
  70. package/src/helpers/maquetteTheme.ts +76 -0
  71. package/src/helpers/markdownUnderlinePlugin.ts +32 -0
  72. package/src/helpers/normalizeWasteTypeRecyclingInfo.ts +80 -0
  73. package/src/helpers/publicMediaUrl.ts +8 -0
  74. package/src/helpers/resolveApiAssetUrl.ts +21 -0
  75. package/src/helpers/systemRoutes.ts +7 -0
  76. package/src/helpers/themeStyles.ts +114 -0
  77. package/src/helpers/useSafeAreaPadding.ts +14 -0
  78. package/src/hooks/useBadges.ts +100 -0
  79. package/src/hooks/useBlogPosts.ts +105 -0
  80. package/src/hooks/useChallenges.ts +110 -0
  81. package/src/hooks/useCities.ts +56 -0
  82. package/src/hooks/useCityAnnouncements.ts +118 -0
  83. package/src/hooks/useCollectionCalendar.ts +421 -0
  84. package/src/hooks/useColumns.ts +20 -0
  85. package/src/hooks/useConsignes.ts +62 -0
  86. package/src/hooks/useLocations.ts +248 -0
  87. package/src/hooks/useNextCollection.ts +238 -0
  88. package/src/hooks/useRecyclingInfo.ts +90 -0
  89. package/src/hooks/useWastoClient.ts +16 -0
  90. package/src/index.ts +142 -0
  91. package/src/navigation/NavigationContext.tsx +56 -0
  92. package/src/sentry/initSentry.ts +120 -0
  93. package/src/types/Theme.ts +72 -0
  94. package/src/types/css-modules.d.ts +1 -0
package/package.json ADDED
@@ -0,0 +1,90 @@
1
+ {
2
+ "name": "@wasto/cms-components",
3
+ "version": "1.5.10",
4
+ "description": "Wast'O CMS React Native components",
5
+ "main": "./src/index.ts",
6
+ "module": "./src/index.ts",
7
+ "types": "./src/index.ts",
8
+ "react-native": "./src/index.ts",
9
+ "exports": {
10
+ ".": {
11
+ "react-native": "./src/index.ts",
12
+ "browser": "./src/index.ts",
13
+ "types": "./src/index.ts",
14
+ "import": "./src/index.ts",
15
+ "require": "./src/index.ts",
16
+ "default": "./src/index.ts"
17
+ },
18
+ "./package.json": "./package.json"
19
+ },
20
+ "files": [
21
+ "dist",
22
+ "src"
23
+ ],
24
+ "publishConfig": {
25
+ "access": "public"
26
+ },
27
+ "peerDependencies": {
28
+ "@maplibre/maplibre-react-native": "*",
29
+ "@react-native-async-storage/async-storage": "*",
30
+ "@react-navigation/bottom-tabs": "*",
31
+ "@sentry/react-native": "*",
32
+ "@wasto/sdk": "*",
33
+ "expo-haptics": "*",
34
+ "expo-image": "*",
35
+ "expo-image-picker": "*",
36
+ "expo-linear-gradient": "*",
37
+ "expo-router": "*",
38
+ "lucide-react-native": "*",
39
+ "maplibre-gl": "*",
40
+ "react": "*",
41
+ "react-native": "*",
42
+ "react-native-gesture-handler": "*",
43
+ "react-native-markdown-display": "*",
44
+ "react-native-reanimated": "*",
45
+ "react-native-safe-area-context": "*"
46
+ },
47
+ "peerDependenciesMeta": {
48
+ "@maplibre/maplibre-react-native": {
49
+ "optional": true
50
+ },
51
+ "@sentry/react-native": {
52
+ "optional": true
53
+ },
54
+ "expo-image-picker": {
55
+ "optional": true
56
+ },
57
+ "expo-linear-gradient": {
58
+ "optional": true
59
+ },
60
+ "expo-router": {
61
+ "optional": true
62
+ },
63
+ "maplibre-gl": {
64
+ "optional": true
65
+ }
66
+ },
67
+ "devDependencies": {
68
+ "@maplibre/maplibre-react-native": "11.3.6",
69
+ "@react-navigation/bottom-tabs": "^7.4.0",
70
+ "@sentry/react-native": "~6.22.0",
71
+ "@types/react": "~19.2.2",
72
+ "expo-haptics": "~15.0.7",
73
+ "expo-linear-gradient": "~15.0.8",
74
+ "maplibre-gl": "^4.7.0",
75
+ "react-native-gesture-handler": "~2.28.0",
76
+ "react-native-markdown-display": "^7.0.2",
77
+ "react-native-reanimated": "~4.1.1",
78
+ "tsup": "^8.5.1",
79
+ "typescript": "^6.0.2",
80
+ "@wasto/sdk": "1.0.3"
81
+ },
82
+ "keywords": [],
83
+ "author": "",
84
+ "license": "ISC",
85
+ "scripts": {
86
+ "build": "tsup",
87
+ "dev": "tsup --watch",
88
+ "typecheck": "tsc --noEmit"
89
+ }
90
+ }
@@ -0,0 +1,183 @@
1
+ import type { ReactNode } from "react";
2
+ import { Modal, Pressable, ScrollView, StyleSheet, Text, View } from "react-native";
3
+ import { X } from "lucide-react-native";
4
+ import { useAnnouncements } from "../../contexts/AnnouncementsContext";
5
+ import { useTheme } from "../../contexts/ThemeContext";
6
+ import { useSafeAreaPadding } from "../../helpers/useSafeAreaPadding";
7
+ import { resolveTheme } from "../../helpers/themeStyles";
8
+ import { getContrastColor } from "../../helpers/getContrastColor";
9
+ import { Button } from "../button/Button";
10
+ import { hapticLight } from "../../helpers/haptics";
11
+ import { SPACE } from "../../helpers/layout";
12
+
13
+ /** Oversized circle (relative to sheet width) → gentle convex arc. */
14
+ const CIRCLE_WIDTH_PCT = "500%";
15
+ const CIRCLE_LEFT_PCT = "-200%";
16
+ /** White sheet overlaps the green header just below the title. */
17
+ const CIRCLE_OVERLAP = 28;
18
+
19
+ export function AnnouncementModal(): ReactNode {
20
+ const { announcement, forceVisible, dismiss } = useAnnouncements();
21
+ const { theme } = useTheme();
22
+ const t = resolveTheme(theme);
23
+ const topInset = useSafeAreaPadding(["top"]);
24
+ const bottomInset = useSafeAreaPadding(["bottom"]);
25
+
26
+ const visible = forceVisible && !!announcement;
27
+ const contrast = getContrastColor(theme.colors.primary);
28
+ const sheetColor = theme.colors.surface;
29
+
30
+ const handleDismiss = async () => {
31
+ await hapticLight();
32
+ await dismiss();
33
+ };
34
+
35
+ return (
36
+ <Modal visible={visible} animationType="slide" onRequestClose={handleDismiss}>
37
+ <View style={[styles.root, { backgroundColor: theme.colors.primary }]}>
38
+ <View
39
+ style={[
40
+ styles.header,
41
+ {
42
+ paddingTop:
43
+ (typeof topInset.paddingTop === "number"
44
+ ? topInset.paddingTop
45
+ : 0) + SPACE.sectionY,
46
+ },
47
+ ]}
48
+ >
49
+ <View style={styles.headerTop}>
50
+ <Text style={[styles.eyebrow, { color: contrast }]}>Annonce</Text>
51
+ <Pressable
52
+ onPress={handleDismiss}
53
+ style={[styles.closeBtn, { backgroundColor: sheetColor }]}
54
+ hitSlop={12}
55
+ accessibilityRole="button"
56
+ accessibilityLabel="Fermer"
57
+ >
58
+ <X size={18} color={theme.colors.primary} />
59
+ </Pressable>
60
+ </View>
61
+ <Text style={[styles.title, { color: contrast }]}>
62
+ {announcement?.title}
63
+ </Text>
64
+ </View>
65
+
66
+ <View style={styles.sheet}>
67
+ <View
68
+ pointerEvents="none"
69
+ style={[
70
+ styles.circle,
71
+ {
72
+ backgroundColor: sheetColor,
73
+ top: -CIRCLE_OVERLAP,
74
+ },
75
+ ]}
76
+ />
77
+ <View
78
+ style={[
79
+ styles.body,
80
+ {
81
+ paddingBottom: Math.max(
82
+ SPACE.sectionY,
83
+ typeof bottomInset.paddingBottom === "number"
84
+ ? bottomInset.paddingBottom + SPACE.pageX
85
+ : SPACE.sectionY,
86
+ ),
87
+ },
88
+ ]}
89
+ >
90
+ <ScrollView
91
+ style={styles.bodyScroll}
92
+ contentContainerStyle={styles.bodyScrollContent}
93
+ bounces={false}
94
+ showsVerticalScrollIndicator={false}
95
+ >
96
+ <Text style={[styles.bodyText, { color: t.colors.muted }]}>
97
+ {announcement?.body}
98
+ </Text>
99
+ </ScrollView>
100
+ <Button
101
+ onPress={handleDismiss}
102
+ style={[styles.cta, { borderRadius: t.radii.pill }]}
103
+ >
104
+ C'est compris !
105
+ </Button>
106
+ </View>
107
+ </View>
108
+ </View>
109
+ </Modal>
110
+ );
111
+ }
112
+
113
+ const styles = StyleSheet.create({
114
+ root: {
115
+ flex: 1,
116
+ height: "100%",
117
+ overflow: "hidden",
118
+ },
119
+ header: {
120
+ paddingHorizontal: SPACE.pageX + 16,
121
+ paddingBottom: SPACE.sectionY,
122
+ gap: SPACE.stack,
123
+ },
124
+ headerTop: {
125
+ flexDirection: "row",
126
+ alignItems: "center",
127
+ justifyContent: "space-between",
128
+ paddingVertical: 4,
129
+ },
130
+ eyebrow: {
131
+ fontSize: 16,
132
+ fontWeight: "700",
133
+ paddingVertical: 4,
134
+ },
135
+ closeBtn: {
136
+ width: SPACE.iconHit,
137
+ height: SPACE.iconHit,
138
+ borderRadius: SPACE.iconHit / 2,
139
+ alignItems: "center",
140
+ justifyContent: "center",
141
+ },
142
+ title: {
143
+ fontSize: 28,
144
+ fontWeight: "800",
145
+ lineHeight: 34,
146
+ paddingRight: SPACE.iconHit + SPACE.stack,
147
+ paddingBottom: SPACE.stack,
148
+ },
149
+ sheet: {
150
+ flex: 1,
151
+ position: "relative",
152
+ },
153
+ circle: {
154
+ position: "absolute",
155
+ left: CIRCLE_LEFT_PCT,
156
+ width: CIRCLE_WIDTH_PCT,
157
+ aspectRatio: 1,
158
+ borderRadius: 9999,
159
+ },
160
+ body: {
161
+ flex: 1,
162
+ paddingHorizontal: SPACE.pageX + 16,
163
+ paddingTop: CIRCLE_OVERLAP + SPACE.sectionY,
164
+ justifyContent: "space-between",
165
+ gap: 24,
166
+ zIndex: 1,
167
+ },
168
+ bodyScroll: {
169
+ flexGrow: 0,
170
+ flexShrink: 1,
171
+ },
172
+ bodyScrollContent: {
173
+ paddingBottom: 8,
174
+ },
175
+ bodyText: {
176
+ fontSize: 16,
177
+ lineHeight: 24,
178
+ },
179
+ cta: {
180
+ height: 52,
181
+ alignSelf: "stretch",
182
+ },
183
+ });
@@ -0,0 +1,22 @@
1
+ import type { ReactNode } from "react";
2
+ import { ArticleExcerpts, ArticlePropsExcerpts } from "./ArticleExcerpts";
3
+ import { ArticleList, ArticlePropsList } from "./ArticleList";
4
+ import { ArticlePropsSingle, ArticleSingle } from "./ArticleSingle";
5
+
6
+ export type ArticleDisplayMode = "single" | "list" | "excerpts";
7
+
8
+ export type ArticleProps =
9
+ | ArticlePropsSingle
10
+ | ArticlePropsList
11
+ | ArticlePropsExcerpts
12
+
13
+ export function Article(props: ArticleProps): ReactNode {
14
+ switch(props.displayMode) {
15
+ case "single":
16
+ return <ArticleSingle {...props}/>
17
+ case "list":
18
+ return <ArticleList {...props}/>
19
+ case "excerpts":
20
+ return <ArticleExcerpts {...props}/>
21
+ }
22
+ }
@@ -0,0 +1,81 @@
1
+ import type { ReactNode } from "react";
2
+ import { StyleSheet, Text, View } from "react-native";
3
+ import { Image } from "expo-image";
4
+ import { useTheme } from "../../contexts/ThemeContext";
5
+ import { Theme } from "../../types/Theme";
6
+ import { Title } from "../title/Title";
7
+ import { Button } from "../button/Button";
8
+ import { formatArticleDate, stripToExcerpt, type BlogPost } from "../../helpers/blogPost";
9
+ import { publicMediaUrl } from "../../helpers/publicMediaUrl";
10
+ import { useWasto } from "../../contexts/WastoContext";
11
+ import { cardStyle } from "../../helpers/themeStyles";
12
+
13
+ export type ArticleCardProps = {
14
+ post: BlogPost;
15
+ excerptLength?: number;
16
+ onRead: () => void;
17
+ };
18
+
19
+ export function ArticleCard({
20
+ post,
21
+ excerptLength = 140,
22
+ onRead,
23
+ }: ArticleCardProps): ReactNode {
24
+ const { theme } = useTheme();
25
+ const { apiUrl } = useWasto();
26
+ const themedStyles = createThemedStyles(theme);
27
+ const cover = publicMediaUrl(apiUrl, post.coverMediaId);
28
+ const date = formatArticleDate(post.publishedAt || post.createdAt);
29
+ const excerpt = stripToExcerpt(post.content, excerptLength);
30
+
31
+ return (
32
+ <View style={[styles.card, cardStyle(theme)]}>
33
+ {cover ? (
34
+ <Image source={cover} style={styles.cover} contentFit="cover" />
35
+ ) : (
36
+ <View style={[styles.accentBar, { backgroundColor: theme.colors.primary }]} />
37
+ )}
38
+ <View style={styles.body}>
39
+ <Title text={post.title} level="3" />
40
+ {date ? <Text style={themedStyles.meta}>{date}</Text> : null}
41
+ {excerpt ? <Text style={themedStyles.excerpt}>{excerpt}</Text> : null}
42
+ <Button variant="outline" onPress={onRead}>
43
+ Lire l'article
44
+ </Button>
45
+ </View>
46
+ </View>
47
+ );
48
+ }
49
+
50
+ const createThemedStyles = (theme: Theme) =>
51
+ StyleSheet.create({
52
+ meta: {
53
+ color: theme.colors.muted ?? theme.colors.onSurface,
54
+ fontSize: 12,
55
+ fontWeight: "500",
56
+ },
57
+ excerpt: {
58
+ color: theme.colors.onSurface,
59
+ opacity: 0.85,
60
+ fontSize: 13,
61
+ lineHeight: 18,
62
+ },
63
+ });
64
+
65
+ const styles = StyleSheet.create({
66
+ card: {
67
+ overflow: "hidden",
68
+ },
69
+ accentBar: {
70
+ height: 5,
71
+ width: "100%",
72
+ },
73
+ cover: {
74
+ width: "100%",
75
+ height: 160,
76
+ },
77
+ body: {
78
+ padding: 16,
79
+ gap: 10,
80
+ },
81
+ });
@@ -0,0 +1,80 @@
1
+ import { useState, type ReactNode } from "react";
2
+ import { ActivityIndicator, StyleSheet, Text, View } from "react-native";
3
+ import { useTheme } from "../../contexts/ThemeContext";
4
+ import { useBlogPosts } from "../../hooks/useBlogPosts";
5
+ import type { BlogPost } from "../../helpers/blogPost";
6
+ import { ArticleCard } from "./ArticleCard";
7
+ import { ArticleReaderModal } from "./ArticleReaderModal";
8
+
9
+ export type ArticlePropsExcerpts = {
10
+ displayMode: "excerpts";
11
+ excerptLength: number;
12
+ };
13
+
14
+ export function ArticleExcerpts({
15
+ excerptLength,
16
+ }: ArticlePropsExcerpts): ReactNode {
17
+ const { theme } = useTheme();
18
+ const { posts, isLoading, error } = useBlogPosts();
19
+ const [selected, setSelected] = useState<BlogPost | null>(null);
20
+
21
+ if (isLoading && posts.length === 0) {
22
+ return (
23
+ <View style={styles.state}>
24
+ <ActivityIndicator color={theme.colors.primary} />
25
+ <Text style={{ color: theme.colors.onBackground, opacity: 0.7 }}>
26
+ Chargement des articles…
27
+ </Text>
28
+ </View>
29
+ );
30
+ }
31
+
32
+ if (error) {
33
+ return (
34
+ <View style={styles.state}>
35
+ <Text style={{ color: theme.colors.error, textAlign: "center" }}>{error}</Text>
36
+ </View>
37
+ );
38
+ }
39
+
40
+ if (posts.length === 0) {
41
+ return (
42
+ <View style={styles.state}>
43
+ <Text style={{ color: theme.colors.onBackground, opacity: 0.7 }}>
44
+ Aucun article pour le moment.
45
+ </Text>
46
+ </View>
47
+ );
48
+ }
49
+
50
+ return (
51
+ <View style={styles.list}>
52
+ {posts.map((post) => (
53
+ <ArticleCard
54
+ key={post.id}
55
+ post={post}
56
+ excerptLength={excerptLength || 120}
57
+ onRead={() => setSelected(post)}
58
+ />
59
+ ))}
60
+ <ArticleReaderModal
61
+ visible={!!selected}
62
+ post={selected}
63
+ onClose={() => setSelected(null)}
64
+ />
65
+ </View>
66
+ );
67
+ }
68
+
69
+ const styles = StyleSheet.create({
70
+ list: {
71
+ gap: 12,
72
+ },
73
+ state: {
74
+ minHeight: 80,
75
+ alignItems: "center",
76
+ justifyContent: "center",
77
+ gap: 8,
78
+ padding: 16,
79
+ },
80
+ });
@@ -0,0 +1,114 @@
1
+ import { useState, type ReactNode } from "react";
2
+ import { ActivityIndicator, StyleSheet, Text, View } from "react-native";
3
+ import { useTheme } from "../../contexts/ThemeContext";
4
+ import { Button } from "../button/Button";
5
+ import { useBlogPosts } from "../../hooks/useBlogPosts";
6
+ import type { BlogPost } from "../../helpers/blogPost";
7
+ import { ArticleCard } from "./ArticleCard";
8
+ import { ArticleReaderModal } from "./ArticleReaderModal";
9
+
10
+ export type ArticlePropsList = {
11
+ displayMode: "list";
12
+ itemsPerPage?: number;
13
+ showPagination?: boolean;
14
+ };
15
+
16
+ export function ArticleList({
17
+ itemsPerPage = 10,
18
+ showPagination = false,
19
+ }: ArticlePropsList): ReactNode {
20
+ const { theme } = useTheme();
21
+ const { posts, isLoading, error } = useBlogPosts();
22
+ const [page, setPage] = useState(0);
23
+ const [selected, setSelected] = useState<BlogPost | null>(null);
24
+
25
+ const size = Math.max(1, itemsPerPage);
26
+ const pageCount = Math.max(1, Math.ceil(posts.length / size));
27
+ const safePage = Math.min(page, pageCount - 1);
28
+ const slice = showPagination
29
+ ? posts.slice(safePage * size, safePage * size + size)
30
+ : posts.slice(0, size);
31
+
32
+ if (isLoading && posts.length === 0) {
33
+ return (
34
+ <View style={styles.state}>
35
+ <ActivityIndicator color={theme.colors.primary} />
36
+ <Text style={{ color: theme.colors.onBackground, opacity: 0.7 }}>
37
+ Chargement des articles…
38
+ </Text>
39
+ </View>
40
+ );
41
+ }
42
+
43
+ if (error) {
44
+ return (
45
+ <View style={styles.state}>
46
+ <Text style={{ color: theme.colors.error, textAlign: "center" }}>{error}</Text>
47
+ </View>
48
+ );
49
+ }
50
+
51
+ if (posts.length === 0) {
52
+ return (
53
+ <View style={styles.state}>
54
+ <Text style={{ color: theme.colors.onBackground, opacity: 0.7 }}>
55
+ Aucun article pour le moment.
56
+ </Text>
57
+ </View>
58
+ );
59
+ }
60
+
61
+ return (
62
+ <View style={styles.list}>
63
+ {slice.map((post) => (
64
+ <ArticleCard key={post.id} post={post} onRead={() => setSelected(post)} />
65
+ ))}
66
+ {showPagination && pageCount > 1 ? (
67
+ <View style={styles.pagination}>
68
+ <Button
69
+ variant="ghost"
70
+ disabled={safePage === 0}
71
+ onPress={() => setPage((p) => Math.max(0, p - 1))}
72
+ >
73
+ Précédent
74
+ </Button>
75
+ <Text style={{ color: theme.colors.onBackground, opacity: 0.6 }}>
76
+ Page {safePage + 1} / {pageCount}
77
+ </Text>
78
+ <Button
79
+ variant="ghost"
80
+ disabled={safePage >= pageCount - 1}
81
+ onPress={() => setPage((p) => Math.min(pageCount - 1, p + 1))}
82
+ >
83
+ Suivant
84
+ </Button>
85
+ </View>
86
+ ) : null}
87
+ <ArticleReaderModal
88
+ visible={!!selected}
89
+ post={selected}
90
+ onClose={() => setSelected(null)}
91
+ />
92
+ </View>
93
+ );
94
+ }
95
+
96
+ const styles = StyleSheet.create({
97
+ list: {
98
+ gap: 12,
99
+ },
100
+ pagination: {
101
+ flexDirection: "row",
102
+ alignItems: "center",
103
+ justifyContent: "center",
104
+ gap: 8,
105
+ paddingTop: 4,
106
+ },
107
+ state: {
108
+ minHeight: 80,
109
+ alignItems: "center",
110
+ justifyContent: "center",
111
+ gap: 8,
112
+ padding: 16,
113
+ },
114
+ });