This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

semble / src / webapp / features / reader / components / ReaderButton / ReaderButton.tsx
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}