✨ Basic post preview
This commit is contained in:
		| @@ -17,6 +17,7 @@ | |||||||
|     "@mui/material": "^6.3.0", |     "@mui/material": "^6.3.0", | ||||||
|     "@mui/material-nextjs": "^6.3.1", |     "@mui/material-nextjs": "^6.3.1", | ||||||
|     "@mui/x-charts": "^7.23.2", |     "@mui/x-charts": "^7.23.2", | ||||||
|  |     "@tailwindcss/typography": "^0.5.15", | ||||||
|     "axios": "^1.7.9", |     "axios": "^1.7.9", | ||||||
|     "axios-case-converter": "^1.1.1", |     "axios-case-converter": "^1.1.1", | ||||||
|     "cookies-next": "^5.0.2", |     "cookies-next": "^5.0.2", | ||||||
| @@ -24,6 +25,11 @@ | |||||||
|     "react": "^19.0.0", |     "react": "^19.0.0", | ||||||
|     "react-dom": "^19.0.0", |     "react-dom": "^19.0.0", | ||||||
|     "react-hook-form": "^7.54.2", |     "react-hook-form": "^7.54.2", | ||||||
|  |     "rehype-sanitize": "^6.0.0", | ||||||
|  |     "rehype-stringify": "^10.0.1", | ||||||
|  |     "remark-parse": "^11.0.0", | ||||||
|  |     "remark-rehype": "^11.1.1", | ||||||
|  |     "unified": "^11.0.5", | ||||||
|     "zustand": "^5.0.2" |     "zustand": "^5.0.2" | ||||||
|   }, |   }, | ||||||
|   "devDependencies": { |   "devDependencies": { | ||||||
|   | |||||||
| @@ -41,7 +41,7 @@ function ElevationScroll(props: ElevationAppBarProps) { | |||||||
|     ? React.cloneElement(props.children, { |     ? React.cloneElement(props.children, { | ||||||
|         elevation: trigger ? props.elevation : 0, |         elevation: trigger ? props.elevation : 0, | ||||||
|         sx: trigger |         sx: trigger | ||||||
|           ? { backgroundColor: props.color, ...commonStyle } |           ? { backgroundColor: props.color, color: 'black', ...commonStyle } | ||||||
|           : { backgroundColor: 'transparent', ...commonStyle }, |           : { backgroundColor: 'transparent', ...commonStyle }, | ||||||
|       }) |       }) | ||||||
|     : null |     : null | ||||||
|   | |||||||
							
								
								
									
										106
									
								
								src/pages/posts/[...id].tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										106
									
								
								src/pages/posts/[...id].tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,106 @@ | |||||||
|  | import { getAttachmentUrl, sni } from '@/services/network' | ||||||
|  | import { SnPost } from '@/services/post' | ||||||
|  | import { Alert, AlertTitle, Avatar, Box, Collapse, Container, IconButton, Link, Typography } from '@mui/material' | ||||||
|  | import { GetServerSideProps, InferGetServerSidePropsType } from 'next' | ||||||
|  | import { useEffect, useMemo, useState } from 'react' | ||||||
|  | import { unified } from 'unified' | ||||||
|  | import Image from 'next/image' | ||||||
|  | import rehypeSanitize from 'rehype-sanitize' | ||||||
|  | import rehypeStringify from 'rehype-stringify' | ||||||
|  | import remarkParse from 'remark-parse' | ||||||
|  | import remarkRehype from 'remark-rehype' | ||||||
|  |  | ||||||
|  | import CloseIcon from '@mui/icons-material/Close' | ||||||
|  |  | ||||||
|  | export const getServerSideProps = (async (context) => { | ||||||
|  |   const id = context.params!.id as string[] | ||||||
|  |   try { | ||||||
|  |     const { data: post } = await sni.get<SnPost>('/cgi/co/posts/' + id.join(':')) | ||||||
|  |     if (post.body.content) { | ||||||
|  |       const out = await unified() | ||||||
|  |         .use(remarkParse) | ||||||
|  |         .use(remarkRehype) | ||||||
|  |         .use(rehypeSanitize) | ||||||
|  |         .use(rehypeStringify) | ||||||
|  |         .process(post.body.content) | ||||||
|  |       post.body.content = String(out) | ||||||
|  |     } | ||||||
|  |     return { props: { post } } | ||||||
|  |   } catch (err) { | ||||||
|  |     return { | ||||||
|  |       notFound: true, | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | }) satisfies GetServerSideProps<{ post: SnPost }> | ||||||
|  |  | ||||||
|  | export default function Post({ post }: InferGetServerSidePropsType<typeof getServerSideProps>) { | ||||||
|  |   const link = useMemo(() => `https://sn.solsynth.dev/posts/${post.id}`, [post]) | ||||||
|  |  | ||||||
|  |   const [openAppHint, setOpenAppHint] = useState<boolean>() | ||||||
|  |  | ||||||
|  |   useEffect(() => { | ||||||
|  |     if (!localStorage.getItem('sol_hide_app_hint')) { | ||||||
|  |       setOpenAppHint(true) | ||||||
|  |     } | ||||||
|  |   }, []) | ||||||
|  |  | ||||||
|  |   useEffect(() => { | ||||||
|  |     if (openAppHint === false) { | ||||||
|  |       localStorage.setItem('sol_hide_app_hint', 'yes') | ||||||
|  |     } | ||||||
|  |   }, [openAppHint]) | ||||||
|  |  | ||||||
|  |   return ( | ||||||
|  |     <> | ||||||
|  |       <Collapse in={openAppHint}> | ||||||
|  |         <Alert | ||||||
|  |           variant="filled" | ||||||
|  |           severity="info" | ||||||
|  |           sx={{ borderRadius: 0, px: 3 }} | ||||||
|  |           action={ | ||||||
|  |             <IconButton | ||||||
|  |               aria-label="close" | ||||||
|  |               color="inherit" | ||||||
|  |               size="small" | ||||||
|  |               onClick={() => { | ||||||
|  |                 setOpenAppHint(false) | ||||||
|  |               }} | ||||||
|  |             > | ||||||
|  |               <CloseIcon fontSize="inherit" /> | ||||||
|  |             </IconButton> | ||||||
|  |           } | ||||||
|  |         > | ||||||
|  |           <AlertTitle gutterBottom={false}>Open in Solian</AlertTitle> | ||||||
|  |           All feature supported, cross-platform, the official app of Solar Network.{' '} | ||||||
|  |           <Link href={link} color="#ffffff"> | ||||||
|  |             Launch | ||||||
|  |           </Link> | ||||||
|  |         </Alert> | ||||||
|  |       </Collapse> | ||||||
|  |  | ||||||
|  |       {post.body.thumbnail && ( | ||||||
|  |         <Box sx={{ aspectRatio: 16 / 9, position: 'relative', borderBottom: 1, borderTop: 1, borderColor: 'divider' }}> | ||||||
|  |           <Image src={getAttachmentUrl(post.body.thumbnail)} alt="post thumbnail" fill /> | ||||||
|  |         </Box> | ||||||
|  |       )} | ||||||
|  |  | ||||||
|  |       <Container sx={{ mt: 3, pb: 5 }} maxWidth="md"> | ||||||
|  |         <Box sx={{ display: 'flex', justifyContent: 'space-between' }}> | ||||||
|  |           <Box sx={{ display: 'flex', gap: 2 }}> | ||||||
|  |             <Avatar src={getAttachmentUrl(post.publisher.avatar)} /> | ||||||
|  |             <Box sx={{ display: 'flex', flexDirection: 'column' }}> | ||||||
|  |               <Typography fontWeight="bold">{post.publisher.nick}</Typography> | ||||||
|  |               <Typography fontFamily="monospace" fontSize={13} lineHeight={1.2}> | ||||||
|  |                 @{post.publisher.name} | ||||||
|  |               </Typography> | ||||||
|  |             </Box> | ||||||
|  |           </Box> | ||||||
|  |         </Box> | ||||||
|  |  | ||||||
|  |         <Box sx={{ mt: 2.5, maxWidth: 'unset' }} component="article" className="prose prose-lg"> | ||||||
|  |           {post.body.content && <div dangerouslySetInnerHTML={{ __html: post.body.content }} />} | ||||||
|  |         </Box> | ||||||
|  |       </Container> | ||||||
|  |     </> | ||||||
|  |   ) | ||||||
|  | } | ||||||
							
								
								
									
										48
									
								
								src/services/attachment.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										48
									
								
								src/services/attachment.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,48 @@ | |||||||
|  | import { sni } from './network' | ||||||
|  |  | ||||||
|  | export interface SnAttachment { | ||||||
|  |   id: number | ||||||
|  |   createdAt: Date | ||||||
|  |   updatedAt: Date | ||||||
|  |   deletedAt?: Date | null | ||||||
|  |   rid: string | ||||||
|  |   uuid: string | ||||||
|  |   size: number | ||||||
|  |   name: string | ||||||
|  |   alt: string | ||||||
|  |   mimetype: string | ||||||
|  |   hash: string | ||||||
|  |   destination: number | ||||||
|  |   refCount: number | ||||||
|  |   contentRating: number | ||||||
|  |   qualityRating: number | ||||||
|  |   cleanedAt?: Date | null | ||||||
|  |   isAnalyzed: boolean | ||||||
|  |   isSelfRef: boolean | ||||||
|  |   isIndexable: boolean | ||||||
|  |   ref?: SnAttachment | null | ||||||
|  |   refId?: number | null | ||||||
|  |   poolId?: number | null | ||||||
|  |   accountId: number | ||||||
|  |   thumbnailId?: number | null | ||||||
|  |   thumbnail?: SnAttachment | null | ||||||
|  |   compressedId?: number | null | ||||||
|  |   compressed?: SnAttachment | null | ||||||
|  |   usermeta: Record<string, any> | ||||||
|  |   metadata: Record<string, any> | ||||||
|  | } | ||||||
|  |  | ||||||
|  | async function getAttachment(id: string | number): Promise<SnAttachment> { | ||||||
|  |   const resp = await sni.get<SnAttachment>('/cgi/uc/attachments/' + id + '/meta') | ||||||
|  |   return resp.data | ||||||
|  | } | ||||||
|  |  | ||||||
|  | async function listAttachment(id: string[]): Promise<SnAttachment[]> { | ||||||
|  |   const resp = await sni.get<{ data: SnAttachment[] }>('/cgi/uc/attachments', { | ||||||
|  |     params: { | ||||||
|  |       id: id.join(','), | ||||||
|  |       take: id.length, | ||||||
|  |     }, | ||||||
|  |   }) | ||||||
|  |   return resp.data.data | ||||||
|  | } | ||||||
							
								
								
									
										85
									
								
								src/services/post.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										85
									
								
								src/services/post.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,85 @@ | |||||||
|  | export interface SnPost { | ||||||
|  |   id: number | ||||||
|  |   createdAt: Date | ||||||
|  |   updatedAt: Date | ||||||
|  |   deletedAt?: Date | null | ||||||
|  |   type: string | ||||||
|  |   body: SnPostBody & Record<string, any> | ||||||
|  |   language: string | ||||||
|  |   alias?: string | null | ||||||
|  |   aliasPrefix?: string | null | ||||||
|  |   tags: SnPostTag[] | ||||||
|  |   categories: SnPostCategory[] | ||||||
|  |   replies?: SnPost[] | null | ||||||
|  |   replyId?: number | null | ||||||
|  |   repostId?: number | null | ||||||
|  |   replyTo?: SnPost | null | ||||||
|  |   repostTo?: SnPost | null | ||||||
|  |   visibleUsersList?: number[] | null | ||||||
|  |   invisibleUsersList?: number[] | null | ||||||
|  |   visibility: number | ||||||
|  |   editedAt?: Date | null | ||||||
|  |   pinnedAt?: Date | null | ||||||
|  |   lockedAt?: Date | null | ||||||
|  |   isDraft: boolean | ||||||
|  |   publishedAt?: Date | null | ||||||
|  |   publishedUntil?: Date | null | ||||||
|  |   totalUpvote: number | ||||||
|  |   totalDownvote: number | ||||||
|  |   publisherId: number | ||||||
|  |   publisher: SnPublisher | ||||||
|  |   metric: SnMetric | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export interface SnPostTag { | ||||||
|  |   id: number | ||||||
|  |   createdAt: Date | ||||||
|  |   updatedAt: Date | ||||||
|  |   deletedAt?: Date | ||||||
|  |   alias: string | ||||||
|  |   name: string | ||||||
|  |   description: string | ||||||
|  |   posts?: SnPost[] | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export interface SnPostCategory { | ||||||
|  |   id: number | ||||||
|  |   createdAt: Date | ||||||
|  |   updatedAt: Date | ||||||
|  |   deletedAt?: Date | ||||||
|  |   alias: string | ||||||
|  |   name: string | ||||||
|  |   description: string | ||||||
|  |   posts?: SnPost[] | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export interface SnPostBody { | ||||||
|  |   attachments: string[] | ||||||
|  |   content: string | ||||||
|  |   location?: string | ||||||
|  |   thumbnail?: string | ||||||
|  |   title?: string | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export interface SnMetric { | ||||||
|  |   replyCount: number | ||||||
|  |   reactionCount: number | ||||||
|  |   reactionList: Record<string, number> | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export interface SnPublisher { | ||||||
|  |   id: number | ||||||
|  |   createdAt: Date | ||||||
|  |   updatedAt: Date | ||||||
|  |   deletedAt?: Date | null | ||||||
|  |   type: number | ||||||
|  |   name: string | ||||||
|  |   nick: string | ||||||
|  |   description: string | ||||||
|  |   avatar: string | ||||||
|  |   banner: string | ||||||
|  |   totalUpvote: number | ||||||
|  |   totalDownvote: number | ||||||
|  |   realmId?: number | null | ||||||
|  |   accountId: number | ||||||
|  | } | ||||||
| @@ -1,18 +1,18 @@ | |||||||
| import type { Config } from "tailwindcss"; | import type { Config } from 'tailwindcss' | ||||||
|  |  | ||||||
| export default { | export default { | ||||||
|   content: [ |   content: [ | ||||||
|     "./src/pages/**/*.{js,ts,jsx,tsx,mdx}", |     './src/pages/**/*.{js,ts,jsx,tsx,mdx}', | ||||||
|     "./src/components/**/*.{js,ts,jsx,tsx,mdx}", |     './src/components/**/*.{js,ts,jsx,tsx,mdx}', | ||||||
|     "./src/app/**/*.{js,ts,jsx,tsx,mdx}", |     './src/app/**/*.{js,ts,jsx,tsx,mdx}', | ||||||
|   ], |   ], | ||||||
|   theme: { |   theme: { | ||||||
|     extend: { |     extend: { | ||||||
|       colors: { |       colors: { | ||||||
|         background: "var(--background)", |         background: 'var(--background)', | ||||||
|         foreground: "var(--foreground)", |         foreground: 'var(--foreground)', | ||||||
|       }, |       }, | ||||||
|     }, |     }, | ||||||
|   }, |   }, | ||||||
|   plugins: [], |   plugins: [require('@tailwindcss/typography')], | ||||||
| } satisfies Config; | } satisfies Config | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user