mirror of
				https://github.com/theoludwig/theoludwig.git
				synced 2025-10-14 20:23:25 +02:00 
			
		
		
		
	fix: loader improvements
This commit is contained in:
		
							
								
								
									
										11
									
								
								app/blog/[slug]/loading.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								app/blog/[slug]/loading.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,11 @@ | ||||
| import { Loader } from '@/components/design/Loader' | ||||
|  | ||||
| const Loading = (): JSX.Element => { | ||||
|   return ( | ||||
|     <main className='flex flex-col flex-1 items-center justify-center'> | ||||
|       <Loader /> | ||||
|     </main> | ||||
|   ) | ||||
| } | ||||
|  | ||||
| export default Loading | ||||
| @@ -1,46 +1,10 @@ | ||||
| import type { Metadata } from 'next' | ||||
| import { notFound } from 'next/navigation' | ||||
| import { cookies } from 'next/headers' | ||||
| import Link from 'next/link' | ||||
| import Image from 'next/image' | ||||
| import { MDXRemote } from 'next-mdx-remote/rsc' | ||||
| import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' | ||||
| import { faLink } from '@fortawesome/free-solid-svg-icons' | ||||
| import date from 'date-and-time' | ||||
| import { nodeTypes } from '@mdx-js/mdx' | ||||
| import rehypeRaw from 'rehype-raw' | ||||
| import remarkGfm from 'remark-gfm' | ||||
| import rehypeSlug from 'rehype-slug' | ||||
| import remarkMath from 'remark-math' | ||||
| import rehypeKatex from 'rehype-katex' | ||||
| import { getHighlighter } from 'shiki' | ||||
|  | ||||
| import 'katex/dist/katex.min.css' | ||||
|  | ||||
| import { remarkSyntaxHighlightingPlugin } from '@/blog/remarkSyntaxHighlightingPlugin' | ||||
| import { getBlogPostBySlug } from '@/blog/blog' | ||||
| import { BlogPostComments } from '@/blog/BlogPostComments' | ||||
| import { getTheme } from '@/theme/theme.server' | ||||
|  | ||||
| const Heading = ( | ||||
|   props: React.DetailedHTMLProps< | ||||
|     React.HTMLAttributes<HTMLHeadingElement>, | ||||
|     HTMLHeadingElement | ||||
|   > | ||||
| ): JSX.Element => { | ||||
|   const { children, id = '' } = props | ||||
|   return ( | ||||
|     <h2 {...props} className='group'> | ||||
|       <Link | ||||
|         href={`#${id}`} | ||||
|         className='invisible !text-black group-hover:visible dark:!text-white' | ||||
|       > | ||||
|         <FontAwesomeIcon className='mr-2 inline h-4 w-4' icon={faLink} /> | ||||
|       </Link> | ||||
|       {children} | ||||
|     </h2> | ||||
|   ) | ||||
| } | ||||
| import { BlogPost } from '@/blog/BlogPost' | ||||
|  | ||||
| interface BlogPostPageProps { | ||||
|   params: { | ||||
| @@ -52,7 +16,7 @@ export const generateMetadata = async ( | ||||
|   props: BlogPostPageProps | ||||
| ): Promise<Metadata> => { | ||||
|   const blogPost = await getBlogPostBySlug(props.params.slug) | ||||
|   if (blogPost == null || !blogPost.frontmatter.isPublished) { | ||||
|   if (blogPost == null) { | ||||
|     return notFound() | ||||
|   } | ||||
|   const title = `${blogPost.frontmatter.title} | Théo LUDWIG` | ||||
| @@ -74,85 +38,7 @@ export const generateMetadata = async ( | ||||
| const BlogPostPage = async (props: BlogPostPageProps): Promise<JSX.Element> => { | ||||
|   const { params } = props | ||||
|  | ||||
|   const blogPost = await getBlogPostBySlug(params.slug) | ||||
|   if (blogPost == null || !blogPost.frontmatter.isPublished) { | ||||
|     return notFound() | ||||
|   } | ||||
|  | ||||
|   const cookiesStore = cookies() | ||||
|   const theme = getTheme() | ||||
|  | ||||
|   const highlighter = await getHighlighter({ | ||||
|     theme: `${theme}-plus` | ||||
|   }) | ||||
|  | ||||
|   return ( | ||||
|     <main className='break-wrap-words flex flex-1 flex-col flex-wrap items-center'> | ||||
|       <div className='my-10 flex flex-col items-center text-center'> | ||||
|         <h1 className='text-3xl font-semibold'>{blogPost.frontmatter.title}</h1> | ||||
|         <p className='mt-2' data-cy='blog-post-date'> | ||||
|           {date.format( | ||||
|             new Date(blogPost.frontmatter.publishedOn), | ||||
|             'DD/MM/YYYY' | ||||
|           )} | ||||
|         </p> | ||||
|       </div> | ||||
|       <div className='prose mb-10'> | ||||
|         <div className='px-8'> | ||||
|           <MDXRemote | ||||
|             source={blogPost.content} | ||||
|             options={{ | ||||
|               mdxOptions: { | ||||
|                 remarkPlugins: [ | ||||
|                   remarkGfm, | ||||
|                   [remarkSyntaxHighlightingPlugin, { highlighter }], | ||||
|                   remarkMath | ||||
|                 ], | ||||
|                 rehypePlugins: [ | ||||
|                   rehypeSlug, | ||||
|                   [rehypeRaw, { passThrough: nodeTypes }], | ||||
|                   rehypeKatex | ||||
|                 ] | ||||
|               } | ||||
|             }} | ||||
|             components={{ | ||||
|               h1: Heading, | ||||
|               h2: Heading, | ||||
|               h3: Heading, | ||||
|               h4: Heading, | ||||
|               h5: Heading, | ||||
|               h6: Heading, | ||||
|               img: (properties) => { | ||||
|                 const { src = '', alt = 'Blog Image' } = properties | ||||
|                 const source = src.replace('../../public/', '/') | ||||
|                 return ( | ||||
|                   <span className='flex flex-col items-center justify-center'> | ||||
|                     <Image | ||||
|                       src={source} | ||||
|                       alt={alt} | ||||
|                       width={1000} | ||||
|                       height={1000} | ||||
|                       className='h-auto w-auto' | ||||
|                     /> | ||||
|                   </span> | ||||
|                 ) | ||||
|               }, | ||||
|               a: (props) => { | ||||
|                 const { href = '' } = props | ||||
|                 if (href.startsWith('#')) { | ||||
|                   return <a {...props} /> | ||||
|                 } | ||||
|                 return ( | ||||
|                   <a target='_blank' rel='noopener noreferrer' {...props} /> | ||||
|                 ) | ||||
|               } | ||||
|             }} | ||||
|           /> | ||||
|           <BlogPostComments cookiesStore={cookiesStore.toString()} /> | ||||
|         </div> | ||||
|       </div> | ||||
|     </main> | ||||
|   ) | ||||
|   return <BlogPost slug={params.slug} /> | ||||
| } | ||||
|  | ||||
| export default BlogPostPage | ||||
|   | ||||
							
								
								
									
										11
									
								
								app/blog/loading.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								app/blog/loading.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,11 @@ | ||||
| import { Loader } from '@/components/design/Loader' | ||||
|  | ||||
| const Loading = (): JSX.Element => { | ||||
|   return ( | ||||
|     <main className='flex flex-col flex-1 items-center justify-center'> | ||||
|       <Loader /> | ||||
|     </main> | ||||
|   ) | ||||
| } | ||||
|  | ||||
| export default Loading | ||||
| @@ -2,7 +2,7 @@ import { Suspense } from 'react' | ||||
| import type { Metadata } from 'next' | ||||
|  | ||||
| import { BlogPosts } from '@/blog/BlogPosts' | ||||
| import { Loader } from '@/components/Loader/Loader' | ||||
| import { Loader } from '@/components/design/Loader' | ||||
|  | ||||
| const title = 'Blog | Théo LUDWIG' | ||||
| const description = | ||||
|   | ||||
| @@ -1,4 +1,4 @@ | ||||
| import { Loader } from '@/components/Loader/Loader' | ||||
| import { Loader } from '@/components/design/Loader' | ||||
|  | ||||
| const Loading = (): JSX.Element => { | ||||
|   return ( | ||||
|   | ||||
							
								
								
									
										35
									
								
								blog/BlogPost.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								blog/BlogPost.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,35 @@ | ||||
| import { notFound } from 'next/navigation' | ||||
| import date from 'date-and-time' | ||||
|  | ||||
| import 'katex/dist/katex.min.css' | ||||
|  | ||||
| import { getBlogPostBySlug } from '@/blog/blog' | ||||
| import { BlogPostContent } from '@/blog/BlogPostContent' | ||||
|  | ||||
| export interface BlogPostProps { | ||||
|   slug: string | ||||
| } | ||||
|  | ||||
| export const BlogPost = async (props: BlogPostProps): Promise<JSX.Element> => { | ||||
|   const { slug } = props | ||||
|  | ||||
|   const blogPost = await getBlogPostBySlug(slug) | ||||
|   if (blogPost == null) { | ||||
|     return notFound() | ||||
|   } | ||||
|  | ||||
|   return ( | ||||
|     <main className='break-wrap-words flex flex-1 flex-col flex-wrap items-center justify-center'> | ||||
|       <div className='my-10 flex flex-col items-center text-center'> | ||||
|         <h1 className='text-3xl font-semibold'>{blogPost.frontmatter.title}</h1> | ||||
|         <p className='mt-2' data-cy='blog-post-date'> | ||||
|           {date.format( | ||||
|             new Date(blogPost.frontmatter.publishedOn), | ||||
|             'DD/MM/YYYY' | ||||
|           )} | ||||
|         </p> | ||||
|       </div> | ||||
|       <BlogPostContent content={blogPost.content} /> | ||||
|     </main> | ||||
|   ) | ||||
| } | ||||
							
								
								
									
										111
									
								
								blog/BlogPostContent.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										111
									
								
								blog/BlogPostContent.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,111 @@ | ||||
| import Image from 'next/image' | ||||
| import Link from 'next/link' | ||||
| import { cookies } from 'next/headers' | ||||
| import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' | ||||
| import { faLink } from '@fortawesome/free-solid-svg-icons' | ||||
| import { MDXRemote } from 'next-mdx-remote/rsc' | ||||
| import { nodeTypes } from '@mdx-js/mdx' | ||||
| import rehypeRaw from 'rehype-raw' | ||||
| import remarkGfm from 'remark-gfm' | ||||
| import rehypeSlug from 'rehype-slug' | ||||
| import remarkMath from 'remark-math' | ||||
| import rehypeKatex from 'rehype-katex' | ||||
| import { getHighlighter } from 'shiki' | ||||
|  | ||||
| import 'katex/dist/katex.min.css' | ||||
|  | ||||
| import { getTheme } from '@/theme/theme.server' | ||||
| import { remarkSyntaxHighlightingPlugin } from '@/blog/remarkSyntaxHighlightingPlugin' | ||||
| import { BlogPostComments } from '@/blog/BlogPostComments' | ||||
|  | ||||
| const Heading = ( | ||||
|   props: React.DetailedHTMLProps< | ||||
|     React.HTMLAttributes<HTMLHeadingElement>, | ||||
|     HTMLHeadingElement | ||||
|   > | ||||
| ): JSX.Element => { | ||||
|   const { children, id = '' } = props | ||||
|   return ( | ||||
|     <h2 {...props} className='group'> | ||||
|       <Link | ||||
|         href={`#${id}`} | ||||
|         className='invisible !text-black group-hover:visible dark:!text-white' | ||||
|       > | ||||
|         <FontAwesomeIcon className='mr-2 inline h-4 w-4' icon={faLink} /> | ||||
|       </Link> | ||||
|       {children} | ||||
|     </h2> | ||||
|   ) | ||||
| } | ||||
|  | ||||
| export interface BlogPostContentProps { | ||||
|   content: string | ||||
| } | ||||
|  | ||||
| export const BlogPostContent = async ( | ||||
|   props: BlogPostContentProps | ||||
| ): Promise<JSX.Element> => { | ||||
|   const { content } = props | ||||
|  | ||||
|   const cookiesStore = cookies() | ||||
|   const theme = getTheme() | ||||
|  | ||||
|   const highlighter = await getHighlighter({ | ||||
|     theme: `${theme}-plus` | ||||
|   }) | ||||
|  | ||||
|   return ( | ||||
|     <div className='prose mb-10'> | ||||
|       <div className='px-8'> | ||||
|         <MDXRemote | ||||
|           source={content} | ||||
|           options={{ | ||||
|             mdxOptions: { | ||||
|               remarkPlugins: [ | ||||
|                 remarkGfm, | ||||
|                 [remarkSyntaxHighlightingPlugin, { highlighter }], | ||||
|                 remarkMath | ||||
|               ], | ||||
|               rehypePlugins: [ | ||||
|                 rehypeSlug, | ||||
|                 [rehypeRaw, { passThrough: nodeTypes }], | ||||
|                 rehypeKatex | ||||
|               ] | ||||
|             } | ||||
|           }} | ||||
|           components={{ | ||||
|             h1: Heading, | ||||
|             h2: Heading, | ||||
|             h3: Heading, | ||||
|             h4: Heading, | ||||
|             h5: Heading, | ||||
|             h6: Heading, | ||||
|             img: (properties) => { | ||||
|               const { src = '', alt = 'Blog Image' } = properties | ||||
|               const source = src.replace('../../public/', '/') | ||||
|               return ( | ||||
|                 <span className='flex flex-col items-center justify-center'> | ||||
|                   <Image | ||||
|                     src={source} | ||||
|                     alt={alt} | ||||
|                     width={1000} | ||||
|                     height={1000} | ||||
|                     className='h-auto w-auto' | ||||
|                   /> | ||||
|                 </span> | ||||
|               ) | ||||
|             }, | ||||
|             a: (props) => { | ||||
|               const { href = '' } = props | ||||
|               if (href.startsWith('#')) { | ||||
|                 return <a {...props} /> | ||||
|               } | ||||
|               return <a target='_blank' rel='noopener noreferrer' {...props} /> | ||||
|             } | ||||
|           }} | ||||
|         /> | ||||
|         <BlogPostComments cookiesStore={cookiesStore.toString()} /> | ||||
|       </div> | ||||
|     </div> | ||||
|   ) | ||||
| } | ||||
							
								
								
									
										32
									
								
								blog/blog.ts
									
									
									
									
									
								
							
							
						
						
									
										32
									
								
								blog/blog.ts
									
									
									
									
									
								
							| @@ -4,7 +4,7 @@ import path from 'node:path' | ||||
| import { cache } from 'react' | ||||
| import matter from 'gray-matter' | ||||
|  | ||||
| export const POSTS_PATH = path.join(process.cwd(), 'blog', 'posts') | ||||
| export const BLOG_POSTS_PATH = path.join(process.cwd(), 'blog', 'posts') | ||||
|  | ||||
| export interface FrontMatter { | ||||
|   title: string | ||||
| @@ -13,21 +13,21 @@ export interface FrontMatter { | ||||
|   publishedOn: string | ||||
| } | ||||
|  | ||||
| export interface Post { | ||||
| export interface BlogPost { | ||||
|   frontmatter: FrontMatter | ||||
|   slug: string | ||||
|   content: string | ||||
| } | ||||
|  | ||||
| export const getBlogPosts = cache(async (): Promise<Post[]> => { | ||||
|   const posts = await fs.promises.readdir(POSTS_PATH) | ||||
|   const postsWithTime = await Promise.all( | ||||
|     posts.map(async (postFilename) => { | ||||
|       const [slug, extension] = postFilename.split('.') | ||||
| export const getBlogPosts = cache(async (): Promise<BlogPost[]> => { | ||||
|   const blogPosts = await fs.promises.readdir(BLOG_POSTS_PATH) | ||||
|   const blogPostsWithTime = await Promise.all( | ||||
|     blogPosts.map(async (blogPostFilename) => { | ||||
|       const [slug, extension] = blogPostFilename.split('.') | ||||
|       if (slug == null || extension == null) { | ||||
|         throw new Error('Invalid postFilename.') | ||||
|         throw new Error('Invalid blog post filename.') | ||||
|       } | ||||
|       const blogPostPath = path.join(POSTS_PATH, `${slug}.${extension}`) | ||||
|       const blogPostPath = path.join(BLOG_POSTS_PATH, `${slug}.${extension}`) | ||||
|       const blogPostContent = await fs.promises.readFile(blogPostPath, { | ||||
|         encoding: 'utf8' | ||||
|       }) | ||||
| @@ -44,22 +44,22 @@ export const getBlogPosts = cache(async (): Promise<Post[]> => { | ||||
|       } | ||||
|     }) | ||||
|   ) | ||||
|   const postsWithTimeSorted = postsWithTime | ||||
|   const blogPostsSortedByPublicationDate = blogPostsWithTime | ||||
|     .filter((post) => { | ||||
|       return post.frontmatter.isPublished | ||||
|     }) | ||||
|     .sort((a, b) => { | ||||
|       return b.time - a.time | ||||
|     }) | ||||
|   return postsWithTimeSorted | ||||
|   return blogPostsSortedByPublicationDate | ||||
| }) | ||||
|  | ||||
| export const getBlogPostBySlug = cache( | ||||
|   async (slug: string): Promise<Post | undefined> => { | ||||
|     const posts = await getBlogPosts() | ||||
|     const post = posts.find((post) => { | ||||
|       return post.slug === slug | ||||
|   async (slug: string): Promise<BlogPost | undefined> => { | ||||
|     const blogPosts = await getBlogPosts() | ||||
|     const blogPost = blogPosts.find((blogPost) => { | ||||
|       return blogPost.slug === slug && blogPost.frontmatter.isPublished | ||||
|     }) | ||||
|     return post | ||||
|     return blogPost | ||||
|   } | ||||
| ) | ||||
|   | ||||
| @@ -1,39 +0,0 @@ | ||||
| @keyframes progressSpinnerRotate { | ||||
|   100% { | ||||
|     transform: rotate(360deg); | ||||
|   } | ||||
| } | ||||
| @keyframes progressSpinnerDash { | ||||
|   0% { | ||||
|     stroke-dasharray: 1, 200; | ||||
|     stroke-dashoffset: 0; | ||||
|   } | ||||
|   50% { | ||||
|     stroke-dasharray: 89, 200; | ||||
|     stroke-dashoffset: -35px; | ||||
|   } | ||||
|   100% { | ||||
|     stroke-dasharray: 89, 200; | ||||
|     stroke-dashoffset: -124px; | ||||
|   } | ||||
| } | ||||
|  | ||||
| .progressSpinnerSvg { | ||||
|   animation: progressSpinnerRotate 2s linear infinite; | ||||
|   height: 100%; | ||||
|   transform-origin: center center; | ||||
|   width: 100%; | ||||
|   position: absolute; | ||||
|   top: 0; | ||||
|   bottom: 0; | ||||
|   left: 0; | ||||
|   right: 0; | ||||
|   margin: auto; | ||||
| } | ||||
| .progressSpinnerCircle { | ||||
|   stroke-dasharray: 89, 200; | ||||
|   stroke-dashoffset: 0; | ||||
|   stroke: #ffd800; | ||||
|   animation: progressSpinnerDash 1.5s ease-in-out infinite; | ||||
|   stroke-linecap: round; | ||||
| } | ||||
| @@ -1,33 +0,0 @@ | ||||
| import styles from './Loader.module.css' | ||||
|  | ||||
| export interface LoaderProps { | ||||
|   width?: number | ||||
|   height?: number | ||||
|   className?: string | ||||
| } | ||||
|  | ||||
| export const Loader = (props: LoaderProps): JSX.Element => { | ||||
|   const { width = 50, height = 50, className } = props | ||||
|  | ||||
|   return ( | ||||
|     <div className={className}> | ||||
|       <div | ||||
|         data-cy='progress-spinner' | ||||
|         className='relative mx-auto my-0 before:block before:pt-[100%] before:content-none' | ||||
|         style={{ width: `${width}px`, height: `${height}px` }} | ||||
|       > | ||||
|         <svg className={styles['progressSpinnerSvg']} viewBox='25 25 50 50'> | ||||
|           <circle | ||||
|             className={styles['progressSpinnerCircle']} | ||||
|             cx='50' | ||||
|             cy='50' | ||||
|             r='20' | ||||
|             fill='none' | ||||
|             strokeWidth='2' | ||||
|             strokeMiterlimit='10' | ||||
|           /> | ||||
|         </svg> | ||||
|       </div> | ||||
|     </div> | ||||
|   ) | ||||
| } | ||||
							
								
								
									
										28
									
								
								components/design/Loader.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								components/design/Loader.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,28 @@ | ||||
| import classNames from 'clsx' | ||||
|  | ||||
| export interface LoaderProps { | ||||
|   width?: number | ||||
|   height?: number | ||||
|   className?: string | ||||
| } | ||||
|  | ||||
| export const Loader = (props: LoaderProps): JSX.Element => { | ||||
|   const { width = 50, height = 50, className } = props | ||||
|  | ||||
|   return ( | ||||
|     <div | ||||
|       style={{ | ||||
|         width, | ||||
|         height | ||||
|       }} | ||||
|       className={classNames( | ||||
|         'animate-spin inline-block border-[3px] border-current border-t-transparent text-yellow dark:text-yellow-dark rounded-full', | ||||
|         className | ||||
|       )} | ||||
|       role='status' | ||||
|       aria-label='loading' | ||||
|     > | ||||
|       <span className='sr-only'>Loading...</span> | ||||
|     </div> | ||||
|   ) | ||||
| } | ||||
		Reference in New Issue
	
	Block a user