✨ Product details
This commit is contained in:
		| @@ -4,6 +4,9 @@ module.exports = { | ||||
|     'plugin:react/recommended', | ||||
|     'plugin:react/jsx-runtime', | ||||
|     '@electron-toolkit/eslint-config-ts/recommended', | ||||
|     '@electron-toolkit/eslint-config-prettier' | ||||
|   ] | ||||
|     '@electron-toolkit/eslint-config-prettier', | ||||
|   ], | ||||
|   rules: { | ||||
|     '@typescript-eslint/no-explicit-any': 'off', | ||||
|   }, | ||||
| } | ||||
|   | ||||
| @@ -28,9 +28,16 @@ | ||||
|     "@fontsource/roboto": "^5.1.1", | ||||
|     "@mui/icons-material": "^6.3.1", | ||||
|     "@mui/material": "^6.3.1", | ||||
|     "@tailwindcss/typography": "^0.5.16", | ||||
|     "electron-updater": "^6.3.9", | ||||
|     "react-router": "^7.1.1", | ||||
|     "solar-js-sdk": "^0.1.2" | ||||
|     "rehype-sanitize": "^6.0.0", | ||||
|     "rehype-stringify": "^10.0.1", | ||||
|     "remark-gfm": "^4.0.0", | ||||
|     "remark-parse": "^11.0.0", | ||||
|     "remark-rehype": "^11.1.1", | ||||
|     "solar-js-sdk": "^0.1.2", | ||||
|     "unified": "^11.0.5" | ||||
|   }, | ||||
|   "devDependencies": { | ||||
|     "@electron-toolkit/eslint-config-prettier": "^2.0.0", | ||||
|   | ||||
| @@ -8,8 +8,8 @@ function createWindow(): void { | ||||
|   const mainWindow = new BrowserWindow({ | ||||
|     width: 1280, | ||||
|     height: 720, | ||||
|     minWidth: 480, | ||||
|     minHeight: 640, | ||||
|     minWidth: 800, | ||||
|     minHeight: 600, | ||||
|     show: false, | ||||
|     autoHideMenuBar: true, | ||||
|     title: 'MatrixTerminal', | ||||
|   | ||||
| @@ -6,6 +6,8 @@ import Landing from '@renderer/pages/Landing' | ||||
| import { useUserStore } from 'solar-js-sdk' | ||||
| import { useEffect } from 'react' | ||||
|  | ||||
| import ProductDetails from './pages/products/Details' | ||||
|  | ||||
| function App(): JSX.Element { | ||||
|   // const ipcHandle = (): void => window.electron.ipcRenderer.send('ping') | ||||
|  | ||||
| @@ -40,6 +42,7 @@ function App(): JSX.Element { | ||||
|  | ||||
|         <Routes> | ||||
|           <Route path="/" element={<Landing />} /> | ||||
|           <Route path="/products/:id" element={<ProductDetails />} /> | ||||
|         </Routes> | ||||
|       </BrowserRouter> | ||||
|     </ThemeProvider> | ||||
|   | ||||
| @@ -34,7 +34,7 @@ export function MaAppBar(): JSX.Element { | ||||
|         </List> | ||||
|       </Drawer> | ||||
|  | ||||
|       <AppBar position="static"> | ||||
|       <AppBar position="sticky"> | ||||
|         <Toolbar> | ||||
|           <IconButton | ||||
|             size="large" | ||||
|   | ||||
| @@ -10,9 +10,12 @@ import { | ||||
|   Avatar, | ||||
| } from '@mui/material' | ||||
| import { useEffect, useState } from 'react' | ||||
| import { useNavigate } from 'react-router' | ||||
| import { MaProduct, getAttachmentUrl, sni } from 'solar-js-sdk' | ||||
|  | ||||
| export default function Landing(): JSX.Element { | ||||
|   const navigate = useNavigate() | ||||
|  | ||||
|   const [products, setProducts] = useState<MaProduct[]>([]) | ||||
|  | ||||
|   async function fetchProducts(): Promise<void> { | ||||
| @@ -40,7 +43,7 @@ export default function Landing(): JSX.Element { | ||||
|         {products.map((p) => ( | ||||
|           <Grid size={1} key={p.id}> | ||||
|             <Card> | ||||
|               <CardActionArea> | ||||
|               <CardActionArea onClick={() => navigate('/products/' + p.id)}> | ||||
|                 {p.previews && ( | ||||
|                   <CardMedia | ||||
|                     sx={{ aspectRatio: 16 / 5 }} | ||||
|   | ||||
							
								
								
									
										198
									
								
								src/renderer/src/pages/products/Details.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										198
									
								
								src/renderer/src/pages/products/Details.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,198 @@ | ||||
| import { | ||||
|   Avatar, | ||||
|   Box, | ||||
|   Button, | ||||
|   Card, | ||||
|   CardContent, | ||||
|   Container, | ||||
|   Dialog, | ||||
|   DialogContent, | ||||
|   DialogTitle, | ||||
|   FormControl, | ||||
|   Grid2 as Grid, | ||||
|   InputLabel, | ||||
|   MenuItem, | ||||
|   Select, | ||||
|   Typography, | ||||
| } from '@mui/material' | ||||
| import { useEffect, useState } from 'react' | ||||
| import { useNavigate, useParams } from 'react-router' | ||||
| import { MaProduct, MaRelease, sni } from 'solar-js-sdk' | ||||
| import { unified } from 'unified' | ||||
| import rehypeSanitize from 'rehype-sanitize' | ||||
| import rehypeStringify from 'rehype-stringify' | ||||
| import remarkGfm from 'remark-gfm' | ||||
| import remarkParse from 'remark-parse' | ||||
| import remarkRehype from 'remark-rehype' | ||||
|  | ||||
| import DownloadIcon from '@mui/icons-material/Download' | ||||
| import ArrowBackwardIcon from '@mui/icons-material/ArrowBack' | ||||
|  | ||||
| export default function ProductDetails(): JSX.Element { | ||||
|   const { id } = useParams() | ||||
|   const navigate = useNavigate() | ||||
|  | ||||
|   const [product, setProduct] = useState<MaProduct>() | ||||
|   const [content, setContent] = useState<string>() | ||||
|  | ||||
|   async function fetchProduct(): Promise<void> { | ||||
|     const { data } = await sni.get<MaProduct>('/cgi/ma/products/' + id) | ||||
|     setProduct(data) | ||||
|   } | ||||
|  | ||||
|   async function parseContent(content: string): Promise<string> { | ||||
|     content = content.replace( | ||||
|       /!\[.*?\]\(solink:\/\/attachments\/([\w-]+)\)/g, | ||||
|       '', | ||||
|     ) | ||||
|  | ||||
|     const out = await unified() | ||||
|       .use(remarkParse) | ||||
|       .use(remarkRehype) | ||||
|       .use(remarkGfm) | ||||
|       .use(rehypeSanitize) | ||||
|       .use(rehypeStringify) | ||||
|       .process(content) | ||||
|  | ||||
|     return String(out) | ||||
|   } | ||||
|  | ||||
|   const [releases, setReleases] = useState<MaRelease[]>() | ||||
|   const [selectedRelease, setSelectedRelease] = useState<MaRelease>() | ||||
|   const [selectedReleaseContent, setSelectedReleaseContent] = useState<string>() | ||||
|  | ||||
|   const [showInstaller, setShowInstaller] = useState(false) | ||||
|  | ||||
|   async function fetchReleases(): Promise<void> { | ||||
|     const { data: resp } = await sni.get<{ data: MaRelease[] }>('/cgi/ma/products/' + id + '/releases', { | ||||
|       params: { | ||||
|         take: 10, | ||||
|       }, | ||||
|     }) | ||||
|     setReleases(resp.data) | ||||
|   } | ||||
|  | ||||
|   useEffect(() => { | ||||
|     fetchProduct().then(() => Promise.all([fetchReleases()])) | ||||
|   }, []) | ||||
|  | ||||
|   useEffect(() => { | ||||
|     if (product?.meta?.introduction == null) return | ||||
|     parseContent(product.meta.introduction).then((out) => setContent(out)) | ||||
|   }, [product?.meta?.introduction]) | ||||
|  | ||||
|   useEffect(() => { | ||||
|     if (selectedRelease == null) return | ||||
|     parseContent(selectedRelease.meta.content).then((out) => setSelectedReleaseContent(out)) | ||||
|   }, [selectedRelease]) | ||||
|  | ||||
|   return ( | ||||
|     <> | ||||
|       {product?.previews && ( | ||||
|         <img | ||||
|           src={product.previews[0]} | ||||
|           style={{ aspectRatio: 16 / 5, width: '100%', objectFit: 'cover' }} | ||||
|           className="border-b border-1 pb" | ||||
|         /> | ||||
|       )} | ||||
|  | ||||
|       <Container sx={{ py: 4 }}> | ||||
|         <Grid container spacing={2}> | ||||
|           <Grid size={8} sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}> | ||||
|             <Box> | ||||
|               <Button size="small" startIcon={<ArrowBackwardIcon />} onClick={() => navigate('/')} sx={{ mb: 1 }}> | ||||
|                 Back | ||||
|               </Button> | ||||
|             </Box> | ||||
|  | ||||
|             <Box> | ||||
|               {product?.icon && ( | ||||
|                 <Avatar | ||||
|                   variant="rounded" | ||||
|                   src={product.icon} | ||||
|                   sx={{ width: 64, height: 64, border: 1, borderColor: 'divider', borderRadius: 4, mb: 1, mx: '-4px' }} | ||||
|                 /> | ||||
|               )} | ||||
|               <Typography variant="h5" component="h1" maxWidth="sm"> | ||||
|                 {product?.name} | ||||
|               </Typography> | ||||
|               <Typography variant="body1" component="p" maxWidth="sm"> | ||||
|                 {product?.description} | ||||
|               </Typography> | ||||
|             </Box> | ||||
|  | ||||
|             {content && ( | ||||
|               <Box | ||||
|                 component="article" | ||||
|                 className="prose prose-lg dark:prose-invert" | ||||
|                 dangerouslySetInnerHTML={{ __html: content ?? '' }} | ||||
|               /> | ||||
|             )} | ||||
|           </Grid> | ||||
|  | ||||
|           <Grid size={4}> | ||||
|             <Card variant="outlined"> | ||||
|               <CardContent> | ||||
|                 <Typography variant="h6" component="h2" gutterBottom> | ||||
|                   Install | ||||
|                 </Typography> | ||||
|  | ||||
|                 <FormControl fullWidth variant="filled"> | ||||
|                   <InputLabel id="releases-select">Releases</InputLabel> | ||||
|                   <Select | ||||
|                     labelId="releases-select" | ||||
|                     label="Releases" | ||||
|                     value={selectedRelease?.id} | ||||
|                     onChange={(evt) => setSelectedRelease(releases?.find((r) => r.id === evt.target.value))} | ||||
|                   > | ||||
|                     {releases?.map((r) => ( | ||||
|                       <MenuItem key={r.id} value={r.id}> | ||||
|                         {r.meta.title} | ||||
|                         <span className="opacity-50 font-mono text-xs ms-1.5 mt-0.5">v{r.version}</span> | ||||
|                       </MenuItem> | ||||
|                     ))} | ||||
|                   </Select> | ||||
|                 </FormControl> | ||||
|  | ||||
|                 <Button | ||||
|                   variant="contained" | ||||
|                   disableElevation | ||||
|                   fullWidth | ||||
|                   sx={{ mt: 2 }} | ||||
|                   startIcon={<DownloadIcon />} | ||||
|                   onClick={() => { | ||||
|                     if (selectedRelease == null) return | ||||
|                     setShowInstaller(true) | ||||
|                   }} | ||||
|                 > | ||||
|                   Install | ||||
|                 </Button> | ||||
|               </CardContent> | ||||
|             </Card> | ||||
|           </Grid> | ||||
|         </Grid> | ||||
|       </Container> | ||||
|  | ||||
|       <Dialog open={showInstaller} onClose={() => setShowInstaller(false)} maxWidth="sm" fullWidth> | ||||
|         <DialogTitle>Install v{selectedRelease?.version}</DialogTitle> | ||||
|         <DialogContent> | ||||
|           <Typography variant="subtitle1" fontWeight="bold" component="h2"> | ||||
|             {selectedRelease?.meta.title} | ||||
|           </Typography> | ||||
|           <Typography variant="body1" component="p" gutterBottom> | ||||
|             {selectedRelease?.meta.description} | ||||
|           </Typography> | ||||
|  | ||||
|           <Card variant="outlined" sx={{ my: 2, maxHeight: '360px', overflowY: 'auto' }}> | ||||
|             <Box | ||||
|               component="article" | ||||
|               className="prose prose-md dark:prose-invert" | ||||
|               sx={{ p: 2 }} | ||||
|               dangerouslySetInnerHTML={{ __html: selectedReleaseContent ?? '' }} | ||||
|             /> | ||||
|           </Card> | ||||
|         </DialogContent> | ||||
|       </Dialog> | ||||
|     </> | ||||
|   ) | ||||
| } | ||||
| @@ -4,5 +4,5 @@ module.exports = { | ||||
|   theme: { | ||||
|     extend: {}, | ||||
|   }, | ||||
|   plugins: [], | ||||
|   plugins: [require('@tailwindcss/typography')], | ||||
| } | ||||
|   | ||||
		Reference in New Issue
	
	Block a user