✨ Product details
This commit is contained in:
parent
c9a424ff53
commit
3e9a784ab8
@ -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')],
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user