✨ Product details
This commit is contained in:
parent
c9a424ff53
commit
3e9a784ab8
@ -4,6 +4,9 @@ module.exports = {
|
|||||||
'plugin:react/recommended',
|
'plugin:react/recommended',
|
||||||
'plugin:react/jsx-runtime',
|
'plugin:react/jsx-runtime',
|
||||||
'@electron-toolkit/eslint-config-ts/recommended',
|
'@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",
|
"@fontsource/roboto": "^5.1.1",
|
||||||
"@mui/icons-material": "^6.3.1",
|
"@mui/icons-material": "^6.3.1",
|
||||||
"@mui/material": "^6.3.1",
|
"@mui/material": "^6.3.1",
|
||||||
|
"@tailwindcss/typography": "^0.5.16",
|
||||||
"electron-updater": "^6.3.9",
|
"electron-updater": "^6.3.9",
|
||||||
"react-router": "^7.1.1",
|
"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": {
|
"devDependencies": {
|
||||||
"@electron-toolkit/eslint-config-prettier": "^2.0.0",
|
"@electron-toolkit/eslint-config-prettier": "^2.0.0",
|
||||||
|
@ -8,8 +8,8 @@ function createWindow(): void {
|
|||||||
const mainWindow = new BrowserWindow({
|
const mainWindow = new BrowserWindow({
|
||||||
width: 1280,
|
width: 1280,
|
||||||
height: 720,
|
height: 720,
|
||||||
minWidth: 480,
|
minWidth: 800,
|
||||||
minHeight: 640,
|
minHeight: 600,
|
||||||
show: false,
|
show: false,
|
||||||
autoHideMenuBar: true,
|
autoHideMenuBar: true,
|
||||||
title: 'MatrixTerminal',
|
title: 'MatrixTerminal',
|
||||||
|
@ -6,6 +6,8 @@ import Landing from '@renderer/pages/Landing'
|
|||||||
import { useUserStore } from 'solar-js-sdk'
|
import { useUserStore } from 'solar-js-sdk'
|
||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
|
|
||||||
|
import ProductDetails from './pages/products/Details'
|
||||||
|
|
||||||
function App(): JSX.Element {
|
function App(): JSX.Element {
|
||||||
// const ipcHandle = (): void => window.electron.ipcRenderer.send('ping')
|
// const ipcHandle = (): void => window.electron.ipcRenderer.send('ping')
|
||||||
|
|
||||||
@ -40,6 +42,7 @@ function App(): JSX.Element {
|
|||||||
|
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Landing />} />
|
<Route path="/" element={<Landing />} />
|
||||||
|
<Route path="/products/:id" element={<ProductDetails />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
@ -34,7 +34,7 @@ export function MaAppBar(): JSX.Element {
|
|||||||
</List>
|
</List>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
|
|
||||||
<AppBar position="static">
|
<AppBar position="sticky">
|
||||||
<Toolbar>
|
<Toolbar>
|
||||||
<IconButton
|
<IconButton
|
||||||
size="large"
|
size="large"
|
||||||
|
@ -10,9 +10,12 @@ import {
|
|||||||
Avatar,
|
Avatar,
|
||||||
} from '@mui/material'
|
} from '@mui/material'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router'
|
||||||
import { MaProduct, getAttachmentUrl, sni } from 'solar-js-sdk'
|
import { MaProduct, getAttachmentUrl, sni } from 'solar-js-sdk'
|
||||||
|
|
||||||
export default function Landing(): JSX.Element {
|
export default function Landing(): JSX.Element {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
const [products, setProducts] = useState<MaProduct[]>([])
|
const [products, setProducts] = useState<MaProduct[]>([])
|
||||||
|
|
||||||
async function fetchProducts(): Promise<void> {
|
async function fetchProducts(): Promise<void> {
|
||||||
@ -40,7 +43,7 @@ export default function Landing(): JSX.Element {
|
|||||||
{products.map((p) => (
|
{products.map((p) => (
|
||||||
<Grid size={1} key={p.id}>
|
<Grid size={1} key={p.id}>
|
||||||
<Card>
|
<Card>
|
||||||
<CardActionArea>
|
<CardActionArea onClick={() => navigate('/products/' + p.id)}>
|
||||||
{p.previews && (
|
{p.previews && (
|
||||||
<CardMedia
|
<CardMedia
|
||||||
sx={{ aspectRatio: 16 / 5 }}
|
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: {
|
theme: {
|
||||||
extend: {},
|
extend: {},
|
||||||
},
|
},
|
||||||
plugins: [],
|
plugins: [require('@tailwindcss/typography')],
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user