Product details

This commit is contained in:
LittleSheep 2025-01-11 16:53:16 +08:00
parent c9a424ff53
commit 3e9a784ab8
9 changed files with 222 additions and 8 deletions

View File

@ -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',
},
}

BIN
bun.lockb

Binary file not shown.

View File

@ -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",

View File

@ -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',

View File

@ -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>

View File

@ -34,7 +34,7 @@ export function MaAppBar(): JSX.Element {
</List>
</Drawer>
<AppBar position="static">
<AppBar position="sticky">
<Toolbar>
<IconButton
size="large"

View File

@ -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 }}

View 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,
'![alt](https://api.sn.solsynth.dev/cgi/uc/attachments/$1)',
)
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>
</>
)
}

View File

@ -4,5 +4,5 @@ module.exports = {
theme: {
extend: {},
},
plugins: [],
plugins: [require('@tailwindcss/typography')],
}