Няма описание

blog.tsx 12KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429
  1. import { useEffect, useMemo, useState } from 'react';
  2. import { FlatList, Pressable, StyleSheet, View } from 'react-native';
  3. import { Image } from 'expo-image';
  4. import { ThemedText } from '@/components/themed-text';
  5. import { ThemedView } from '@/components/themed-view';
  6. import { ExternalLink } from '@/components/external-link';
  7. import { Colors, Fonts } from '@/constants/theme';
  8. import { useTranslation } from '@/localization/i18n';
  9. import { useColorScheme } from '@/hooks/use-color-scheme';
  10. const BASE_URL = 'https://simplico.net/wp-json/wp/v2';
  11. const CATEGORY_SLUG = 'blogs';
  12. const PER_PAGE = 10;
  13. type Post = {
  14. id: number;
  15. link: string;
  16. date: string;
  17. title: { rendered: string };
  18. excerpt: { rendered: string };
  19. _embedded?: {
  20. 'wp:featuredmedia'?: Array<{
  21. source_url?: string;
  22. }>;
  23. };
  24. };
  25. type Category = {
  26. id: number;
  27. name: string;
  28. link: string;
  29. };
  30. export default function BlogScreen() {
  31. const { t } = useTranslation();
  32. const theme = useColorScheme() ?? 'light';
  33. const palette = Colors[theme];
  34. const [posts, setPosts] = useState<Post[]>([]);
  35. const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading');
  36. const [categoryId, setCategoryId] = useState<number | null>(null);
  37. const [categories, setCategories] = useState<Category[]>([]);
  38. const [page, setPage] = useState(1);
  39. const [loadingMore, setLoadingMore] = useState(false);
  40. const [totalPages, setTotalPages] = useState<number | null>(null);
  41. const [loadMoreDebug, setLoadMoreDebug] = useState('');
  42. const [langFilter, setLangFilter] = useState<'en' | 'th' | 'ja' | 'zh'>('en');
  43. useEffect(() => {
  44. let isActive = true;
  45. async function loadPosts() {
  46. try {
  47. const fetchedCategoryId = await fetchCategoryId(CATEGORY_SLUG);
  48. if (!fetchedCategoryId) {
  49. setPosts([]);
  50. setCategories([]);
  51. setStatus('ready');
  52. return;
  53. }
  54. setCategoryId(fetchedCategoryId);
  55. const categoryList = await fetchChildCategories(fetchedCategoryId);
  56. setCategories(categoryList);
  57. const response = await fetch(
  58. `${BASE_URL}/posts?per_page=${PER_PAGE}&page=1&_embed=1&cat=9&orderby=modified&order=desc`
  59. );
  60. if (!response.ok) {
  61. throw new Error(`HTTP ${response.status}`);
  62. }
  63. const data = (await response.json()) as Post[];
  64. if (!isActive) return;
  65. setPosts(data);
  66. setPage(1);
  67. setTotalPages(readTotalPages(response));
  68. setStatus('ready');
  69. } catch {
  70. if (isActive) setStatus('error');
  71. }
  72. }
  73. loadPosts();
  74. return () => {
  75. isActive = false;
  76. };
  77. }, []);
  78. const filteredPosts = useMemo(() => {
  79. if (langFilter === 'en') {
  80. return posts.filter((post) => detectLangFromUrl(post.link) === 'en');
  81. }
  82. return posts.filter((post) => detectLangFromUrl(post.link) === langFilter);
  83. }, [langFilter, posts]);
  84. const statusText =
  85. status === 'loading'
  86. ? t('blog.loading')
  87. : status === 'error'
  88. ? t('blog.error')
  89. : filteredPosts.length === 0
  90. ? t('blog.empty')
  91. : '';
  92. const canLoadMore = useMemo(() => {
  93. if (status !== 'ready') return false;
  94. if (!totalPages) return false;
  95. return page < totalPages;
  96. }, [page, status, totalPages]);
  97. async function handleLoadMore() {
  98. if (!categoryId || loadingMore || !canLoadMore) return;
  99. setLoadingMore(true);
  100. setLoadMoreDebug('');
  101. try {
  102. const nextPage = page + 1;
  103. setLoadMoreDebug(
  104. `Requesting page ${nextPage}/${totalPages ?? '?'} (category ${categoryId})`
  105. );
  106. const response = await fetch(
  107. `${BASE_URL}/posts?per_page=${PER_PAGE}&page=${nextPage}&_embed=1&cat=9&orderby=modified&order=desc`
  108. );
  109. if (!response.ok) {
  110. const body = await response.json().catch(() => null);
  111. if (body?.code === 'rest_post_invalid_page_number') {
  112. setTotalPages(page);
  113. setLoadMoreDebug(`No more pages. Server says page ${nextPage} is invalid.`);
  114. return;
  115. }
  116. setLoadMoreDebug(`HTTP ${response.status}`);
  117. throw new Error(`HTTP ${response.status}`);
  118. }
  119. const data = (await response.json()) as Post[];
  120. setPosts((prev) => mergeUniquePosts(prev, data));
  121. setPage(nextPage);
  122. setTotalPages(readTotalPages(response));
  123. setLoadMoreDebug(
  124. `Loaded ${data.length} posts. Total pages: ${readTotalPages(response) ?? '?'}`
  125. );
  126. } finally {
  127. setLoadingMore(false);
  128. }
  129. }
  130. return (
  131. <FlatList
  132. data={filteredPosts}
  133. keyExtractor={(item) => String(item.id)}
  134. renderItem={({ item }) => (
  135. <ThemedView style={[styles.card, { backgroundColor: palette.card, borderColor: palette.border }]}>
  136. {getFeaturedImage(item) ? (
  137. <Image
  138. source={{ uri: getFeaturedImage(item) }}
  139. style={styles.featuredImage}
  140. contentFit="cover"
  141. />
  142. ) : null}
  143. <ExternalLink href={item.link}>
  144. <ThemedText type="subtitle">{stripTags(item.title.rendered)}</ThemedText>
  145. </ExternalLink>
  146. <ThemedText style={styles.meta}>{formatDate(item.date)}</ThemedText>
  147. <ThemedText>{truncateText(stripTags(item.excerpt.rendered), 160)}</ThemedText>
  148. <View style={styles.readMore}>
  149. <ExternalLink href={item.link}>
  150. <ThemedText type="link">Read more</ThemedText>
  151. </ExternalLink>
  152. </View>
  153. </ThemedView>
  154. )}
  155. ItemSeparatorComponent={() => <View style={styles.separator} />}
  156. ListHeaderComponent={
  157. <View>
  158. <ThemedView style={styles.hero}>
  159. <Image
  160. source={require('@/assets/images/blogpost.jpg')}
  161. style={styles.heroImage}
  162. contentFit="cover"
  163. />
  164. </ThemedView>
  165. <ThemedView style={styles.titleContainer}>
  166. <ThemedText type="title" style={{ fontFamily: Fonts.rounded }}>
  167. {t('blog.title')}
  168. </ThemedText>
  169. </ThemedView>
  170. {statusText ? (
  171. <ThemedView style={styles.section}>
  172. <ThemedText>{statusText}</ThemedText>
  173. </ThemedView>
  174. ) : null}
  175. {categories.length > 0 ? (
  176. <ThemedView style={styles.section}>
  177. <ThemedText type="subtitle">Categories</ThemedText>
  178. <View style={styles.categoryRow}>
  179. {categories.map((category) => (
  180. <ExternalLink key={category.id} href={category.link} asChild>
  181. <Pressable
  182. style={[
  183. styles.categoryBadge,
  184. { borderColor: palette.border, backgroundColor: palette.surface },
  185. ]}>
  186. <ThemedText style={styles.categoryBadgeText}>{category.name}</ThemedText>
  187. </Pressable>
  188. </ExternalLink>
  189. ))}
  190. </View>
  191. </ThemedView>
  192. ) : null}
  193. <ThemedView style={styles.section}>
  194. <ThemedText type="subtitle">{t('blog.language')}</ThemedText>
  195. <View style={styles.segmented}>
  196. {(['en', 'th', 'ja', 'zh'] as const).map((lang) => (
  197. <Pressable
  198. key={lang}
  199. onPress={() => setLangFilter(lang)}
  200. style={[
  201. styles.segmentItem,
  202. { borderColor: palette.border },
  203. lang === langFilter
  204. ? { borderColor: palette.success, backgroundColor: palette.surface }
  205. : undefined,
  206. ]}>
  207. <ThemedText
  208. style={[
  209. styles.segmentText,
  210. lang === langFilter ? [styles.segmentTextActive, { color: palette.success }] : undefined,
  211. ]}>
  212. {labelForLang(t, lang)}
  213. </ThemedText>
  214. </Pressable>
  215. ))}
  216. </View>
  217. </ThemedView>
  218. </View>
  219. }
  220. ListFooterComponent={
  221. loadingMore || loadMoreDebug ? (
  222. <View style={styles.footer}>
  223. {loadMoreDebug ? (
  224. <ThemedText style={styles.debugText}>{loadMoreDebug}</ThemedText>
  225. ) : null}
  226. {loadingMore ? <ThemedText>{t('blog.loadingMore')}</ThemedText> : null}
  227. </View>
  228. ) : (
  229. <View style={styles.footer} />
  230. )
  231. }
  232. onEndReached={() => {
  233. if (canLoadMore) {
  234. handleLoadMore();
  235. }
  236. }}
  237. onEndReachedThreshold={0.6}
  238. extraData={langFilter}
  239. />
  240. );
  241. }
  242. function readTotalPages(response: Response) {
  243. const value = response.headers.get('x-wp-totalpages');
  244. if (!value) return null;
  245. const parsed = Number(value);
  246. return Number.isFinite(parsed) ? parsed : null;
  247. }
  248. function mergeUniquePosts(existing: Post[], incoming: Post[]) {
  249. const map = new Map<number, Post>();
  250. for (const post of existing) map.set(post.id, post);
  251. for (const post of incoming) map.set(post.id, post);
  252. return Array.from(map.values());
  253. }
  254. async function fetchChildCategories(parentId: number) {
  255. const response = await fetch(`${BASE_URL}/categories?parent=${parentId}&per_page=100`);
  256. if (!response.ok) return [];
  257. return (await response.json()) as Category[];
  258. }
  259. async function fetchCategoryId(slug: string) {
  260. const response = await fetch(`${BASE_URL}/categories?slug=${encodeURIComponent(slug)}`);
  261. if (!response.ok) return null;
  262. const data = (await response.json()) as { id: number }[];
  263. return data.length > 0 ? data[0].id : null;
  264. }
  265. function stripTags(html: string) {
  266. return html.replace(/<[^>]*>/g, '').replace(/\s+/g, ' ').trim();
  267. }
  268. function truncateText(text: string, maxLength: number) {
  269. if (text.length <= maxLength) return text;
  270. return `${text.slice(0, maxLength - 1).trim()}…`;
  271. }
  272. function formatDate(value: string) {
  273. try {
  274. return new Date(value).toLocaleDateString();
  275. } catch {
  276. return value;
  277. }
  278. }
  279. function getFeaturedImage(post: Post) {
  280. return post._embedded?.['wp:featuredmedia']?.[0]?.source_url ?? '';
  281. }
  282. function labelForLang(t: (key: string) => string, lang: 'en' | 'th' | 'ja' | 'zh') {
  283. switch (lang) {
  284. case 'th':
  285. return t('blog.lang.th');
  286. case 'ja':
  287. return t('blog.lang.ja');
  288. case 'zh':
  289. return t('blog.lang.zh');
  290. default:
  291. return t('blog.lang.en');
  292. }
  293. }
  294. function detectLangFromUrl(url: string) {
  295. const lower = url.toLowerCase();
  296. let path = lower;
  297. try {
  298. const parsed = new URL(lower);
  299. path = parsed.pathname;
  300. } catch {
  301. path = lower;
  302. }
  303. if (/(^|\/)th(\/|$)/.test(path) || /-th(\/|$)/.test(path)) return 'th';
  304. if (/(^|\/)ja(\/|$)/.test(path) || /-ja(\/|$)/.test(path)) return 'ja';
  305. if (/(^|\/)zh(\/|$)/.test(path) || /-zh(\/|$)/.test(path)) return 'zh';
  306. return 'en';
  307. }
  308. const styles = StyleSheet.create({
  309. hero: {
  310. backgroundColor: '#E8E6DA',
  311. aspectRatio: 16 / 9,
  312. width: '100%',
  313. },
  314. heroImage: {
  315. width: '100%',
  316. height: '100%',
  317. },
  318. titleContainer: {
  319. flexDirection: 'row',
  320. gap: 8,
  321. paddingHorizontal: 16,
  322. paddingVertical: 12,
  323. },
  324. section: {
  325. gap: 8,
  326. marginBottom: 16,
  327. paddingHorizontal: 16,
  328. },
  329. card: {
  330. gap: 8,
  331. marginHorizontal: 16,
  332. padding: 12,
  333. borderRadius: 14,
  334. borderWidth: 1,
  335. borderColor: '#E1DACB',
  336. backgroundColor: '#FFFFFF',
  337. },
  338. featuredImage: {
  339. height: 180,
  340. width: '100%',
  341. borderRadius: 12,
  342. },
  343. meta: {
  344. opacity: 0.7,
  345. },
  346. readMore: {
  347. paddingTop: 4,
  348. },
  349. separator: {
  350. height: 12,
  351. },
  352. footer: {
  353. paddingTop: 8,
  354. paddingBottom: 24,
  355. paddingHorizontal: 16,
  356. },
  357. debugText: {
  358. fontSize: 12,
  359. opacity: 0.7,
  360. paddingBottom: 8,
  361. },
  362. categoryRow: {
  363. flexDirection: 'row',
  364. flexWrap: 'wrap',
  365. gap: 6,
  366. },
  367. categoryBadge: {
  368. borderRadius: 9999,
  369. borderWidth: 1,
  370. borderColor: '#D9D1C2',
  371. backgroundColor: '#FFFFFF',
  372. paddingHorizontal: 10,
  373. paddingVertical: 4,
  374. },
  375. categoryBadgeText: {
  376. fontSize: 12,
  377. fontWeight: '600',
  378. },
  379. segmented: {
  380. flexDirection: 'row',
  381. flexWrap: 'wrap',
  382. gap: 8,
  383. },
  384. segmentItem: {
  385. borderRadius: 999,
  386. borderWidth: 1,
  387. borderColor: '#C6C6C6',
  388. paddingHorizontal: 12,
  389. paddingVertical: 6,
  390. },
  391. segmentItemActive: {
  392. borderColor: '#1B8A5A',
  393. backgroundColor: '#E7F3EC',
  394. },
  395. segmentText: {
  396. fontSize: 14,
  397. },
  398. segmentTextActive: {
  399. fontWeight: '700',
  400. color: '#1B8A5A',
  401. },
  402. });