✨ Basic post preview
This commit is contained in:
		| @@ -17,6 +17,7 @@ | ||||
|     "@mui/material": "^6.3.0", | ||||
|     "@mui/material-nextjs": "^6.3.1", | ||||
|     "@mui/x-charts": "^7.23.2", | ||||
|     "@tailwindcss/typography": "^0.5.15", | ||||
|     "axios": "^1.7.9", | ||||
|     "axios-case-converter": "^1.1.1", | ||||
|     "cookies-next": "^5.0.2", | ||||
| @@ -24,6 +25,11 @@ | ||||
|     "react": "^19.0.0", | ||||
|     "react-dom": "^19.0.0", | ||||
|     "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" | ||||
|   }, | ||||
|   "devDependencies": { | ||||
|   | ||||
| @@ -41,7 +41,7 @@ function ElevationScroll(props: ElevationAppBarProps) { | ||||
|     ? React.cloneElement(props.children, { | ||||
|         elevation: trigger ? props.elevation : 0, | ||||
|         sx: trigger | ||||
|           ? { backgroundColor: props.color, ...commonStyle } | ||||
|           ? { backgroundColor: props.color, color: 'black', ...commonStyle } | ||||
|           : { backgroundColor: 'transparent', ...commonStyle }, | ||||
|       }) | ||||
|     : 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 { | ||||
|   content: [ | ||||
|     "./src/pages/**/*.{js,ts,jsx,tsx,mdx}", | ||||
|     "./src/components/**/*.{js,ts,jsx,tsx,mdx}", | ||||
|     "./src/app/**/*.{js,ts,jsx,tsx,mdx}", | ||||
|     './src/pages/**/*.{js,ts,jsx,tsx,mdx}', | ||||
|     './src/components/**/*.{js,ts,jsx,tsx,mdx}', | ||||
|     './src/app/**/*.{js,ts,jsx,tsx,mdx}', | ||||
|   ], | ||||
|   theme: { | ||||
|     extend: { | ||||
|       colors: { | ||||
|         background: "var(--background)", | ||||
|         foreground: "var(--foreground)", | ||||
|         background: 'var(--background)', | ||||
|         foreground: 'var(--foreground)', | ||||
|       }, | ||||
|     }, | ||||
|   }, | ||||
|   plugins: [], | ||||
| } satisfies Config; | ||||
|   plugins: [require('@tailwindcss/typography')], | ||||
| } satisfies Config | ||||
|   | ||||
		Reference in New Issue
	
	Block a user