This repository has no description
8.0 kB
273 lines
1'use client';
2
3import { useState } from 'react';
4import {
5 ActionIcon,
6 Drawer,
7 Tooltip,
8 Stack,
9 Title,
10 Text,
11 Alert,
12 ScrollArea,
13 Box,
14 Group,
15 CloseButton,
16 Skeleton,
17 Button,
18 Anchor,
19 Container,
20} from '@mantine/core';
21import { TbBook2, TbExternalLink, TbLink, TbLinkOff } from 'react-icons/tb';
22import { MdErrorOutline } from 'react-icons/md';
23import { fetchReaderContent, type ReaderState } from '../../lib/fetchReaderContent';
24import styles from './ReaderButton.module.css';
25
26interface Props {
27 url: string;
28}
29
30const FONT_SIZE_MIN = 14;
31const FONT_SIZE_MAX = 24;
32const FONT_SIZE_STEP = 2;
33const FONT_SIZE_DEFAULT = 17;
34
35function ArticleSkeleton() {
36 return (
37 <Stack gap="sm">
38 <Skeleton height={12} width="15%" radius="sm" />
39 <Skeleton height={36} width="90%" radius="sm" />
40 <Skeleton height={36} width="70%" radius="sm" />
41 <Skeleton height={12} width="25%" radius="sm" mt={4} />
42 <Box mt="xl">
43 {[100, 95, 88, 100, 92, 78, 100, 96, 83, 60].map((w, i) => (
44 <Skeleton key={i} height={14} width={`${w}%`} radius="sm" mb={10} />
45 ))}
46 </Box>
47 <Box mt="xs">
48 {[100, 91, 100, 85, 100, 72].map((w, i) => (
49 <Skeleton key={i} height={14} width={`${w}%`} radius="sm" mb={10} />
50 ))}
51 </Box>
52 </Stack>
53 );
54}
55
56export default function ReaderButton({ url }: Props) {
57 const [opened, setOpened] = useState(false);
58 const [state, setState] = useState<ReaderState>({ status: 'idle' });
59 const [fontSize, setFontSize] = useState(FONT_SIZE_DEFAULT);
60 const [removeLinks, setRemoveLinks] = useState(false);
61
62 const displayedHtml =
63 state.status === 'success'
64 ? removeLinks
65 ? state.data.content.replace(/<a[^>]*>([\s\S]*?)<\/a>/g, '$1')
66 : state.data.content
67 : '';
68
69 function openReader() {
70 setOpened(true);
71 if (state.status === 'success' || state.status === 'loading') return;
72 fetchReaderContent(url, setState);
73 }
74
75 function handleClose() {
76 setOpened(false);
77 }
78
79 return (
80 <>
81 <Button
82 variant="light"
83 color="gray"
84 radius="xl"
85 leftSection={<TbBook2 size={18} />}
86 onClick={openReader}
87 aria-label="Open reader mode"
88 >
89 Reader
90 </Button>
91
92 <Drawer
93 opened={opened}
94 onClose={handleClose}
95 position="bottom"
96 size="full"
97 padding={0}
98 withCloseButton={false}
99 styles={{
100 content: { display: 'flex', flexDirection: 'column' },
101 body: {
102 flex: 1,
103 display: 'flex',
104 flexDirection: 'column',
105 overflow: 'hidden',
106 },
107 }}
108 >
109 {/* ── Scrollable article area ── */}
110 <ScrollArea style={{ flex: 1 }}>
111 <Container
112 size={'sm'}
113 px="xl"
114 style={{
115 paddingTop: '2.5rem',
116 paddingBottom: '3rem',
117 }}
118 >
119 {state.status === 'loading' && <ArticleSkeleton />}
120
121 {state.status === 'error' && (
122 <Stack gap="md">
123 <Alert
124 icon={<MdErrorOutline size={20} />}
125 title="Could not load reader"
126 color="red"
127 variant="light"
128 >
129 {state.message}
130 </Alert>
131 <Button
132 variant="default"
133 size="sm"
134 radius="xl"
135 onClick={() => fetchReaderContent(url, setState)}
136 >
137 Try again
138 </Button>
139 </Stack>
140 )}
141
142 {state.status === 'success' && (
143 <Stack gap="xs">
144 {state.data.siteName && (
145 <Text size="xs" c="dimmed" tt="uppercase" fw={600}>
146 {state.data.siteName}
147 </Text>
148 )}
149
150 {state.data.title && (
151 <Title
152 order={1}
153 style={{ lineHeight: 1.2, fontSize: '1.75rem' }}
154 >
155 {state.data.title}
156 </Title>
157 )}
158
159 {state.data.byline && (
160 <Text size="sm" c="dimmed" mt={2}>
161 {state.data.byline}
162 </Text>
163 )}
164
165 <Box
166 component="article"
167 dangerouslySetInnerHTML={{ __html: displayedHtml }}
168 className={styles.readerContent}
169 style={{
170 fontSize: `${fontSize}px`,
171 lineHeight: 1.8,
172 color: 'var(--mantine-color-text)',
173 marginTop: '1.5rem',
174 }}
175 />
176 </Stack>
177 )}
178 </Container>
179 </ScrollArea>
180
181 {/* ── Sticky bottom bar ── */}
182 <Box className={styles.bottomBar}>
183 <Group px="lg" py="xs" justify="space-between" align="center">
184 {/* Left: font size + link toggle */}
185 <Group gap="xs">
186 <Tooltip label="Decrease font size" withArrow position="top">
187 <ActionIcon
188 variant="light"
189 color="gray"
190 size="md"
191 radius="xl"
192 onClick={() =>
193 setFontSize((s) =>
194 Math.max(FONT_SIZE_MIN, s - FONT_SIZE_STEP),
195 )
196 }
197 disabled={fontSize <= FONT_SIZE_MIN}
198 aria-label="Decrease font size"
199 >
200 <Text size="xs" fw={700}>
201 A-
202 </Text>
203 </ActionIcon>
204 </Tooltip>
205 <Tooltip label="Increase font size" withArrow position="top">
206 <ActionIcon
207 variant="light"
208 color="gray"
209 size="md"
210 radius="xl"
211 onClick={() =>
212 setFontSize((s) =>
213 Math.min(FONT_SIZE_MAX, s + FONT_SIZE_STEP),
214 )
215 }
216 disabled={fontSize >= FONT_SIZE_MAX}
217 aria-label="Increase font size"
218 >
219 <Text size="xs" fw={700}>
220 A+
221 </Text>
222 </ActionIcon>
223 </Tooltip>
224 <Tooltip
225 label={removeLinks ? 'Show links' : 'Remove links'}
226 withArrow
227 position="top"
228 >
229 <ActionIcon
230 variant={removeLinks ? 'filled' : 'light'}
231 color={'gray'}
232 size="md"
233 radius="xl"
234 onClick={() => setRemoveLinks((v) => !v)}
235 aria-label={removeLinks ? 'Show links' : 'Remove links'}
236 >
237 {removeLinks ? <TbLinkOff size={16} /> : <TbLink size={16} />}
238 </ActionIcon>
239 </Tooltip>
240 </Group>
241
242 {/* Right: open original + close */}
243 <Group gap="xs">
244 <Anchor
245 href={url}
246 target="_blank"
247 rel="noopener noreferrer"
248 underline="never"
249 >
250 <Button
251 variant="light"
252 color="gray"
253 size="xs"
254 radius="xl"
255 rightSection={<TbExternalLink size={14} />}
256 >
257 Open original
258 </Button>
259 </Anchor>
260
261 <CloseButton
262 size="lg"
263 radius={'xl'}
264 onClick={handleClose}
265 aria-label="Close reader mode"
266 />
267 </Group>
268 </Group>
269 </Box>
270 </Drawer>
271 </>
272 );
273}