├── public ├── favicon.ico ├── images │ ├── test1.jpg │ ├── test2.jpg │ └── test3.jpg ├── vercel.svg ├── window.svg ├── file.svg ├── globe.svg └── next.svg ├── src ├── pages │ ├── fonts │ │ ├── GeistVF.woff │ │ └── GeistMonoVF.woff │ ├── _app.tsx │ ├── _document.tsx │ ├── api │ │ └── hello.ts │ ├── page │ │ └── [page].tsx │ ├── [slug].tsx │ ├── index.tsx │ └── posts │ │ └── [slug].tsx ├── lib │ └── markdown.ts ├── components │ └── TableOfContents.tsx ├── layouts │ └── Layout.tsx └── styles │ └── globals.css ├── content ├── pages │ └── about.md ├── hello-world.md ├── markdown_advanced_test.md └── markdown_test.md ├── .eslintrc.json ├── postcss.config.mjs ├── tsconfig.json ├── .gitignore ├── LICENSE ├── package.json ├── README.md └── tailwind.config.ts /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sondt99/Tech-Blog/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/images/test1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sondt99/Tech-Blog/HEAD/public/images/test1.jpg -------------------------------------------------------------------------------- /public/images/test2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sondt99/Tech-Blog/HEAD/public/images/test2.jpg -------------------------------------------------------------------------------- /public/images/test3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sondt99/Tech-Blog/HEAD/public/images/test3.jpg -------------------------------------------------------------------------------- /src/pages/fonts/GeistVF.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sondt99/Tech-Blog/HEAD/src/pages/fonts/GeistVF.woff -------------------------------------------------------------------------------- /src/pages/fonts/GeistMonoVF.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sondt99/Tech-Blog/HEAD/src/pages/fonts/GeistMonoVF.woff -------------------------------------------------------------------------------- /content/pages/about.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "About Me" 3 | lastUpdated: "2024-03-15" 4 | --- 5 | This is my about page content. -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "next/core-web-vitals" 4 | ], 5 | "parserOptions": { 6 | "project": "./tsconfig.json" 7 | } 8 | } -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('postcss-load-config').Config} */ 2 | const config = { 3 | plugins: { 4 | tailwindcss: {}, 5 | }, 6 | }; 7 | 8 | export default config; -------------------------------------------------------------------------------- /src/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import "@/styles/globals.css"; 2 | import type { AppProps } from "next/app"; 3 | 4 | export default function App({ Component, pageProps }: AppProps) { 5 | return ; 6 | } 7 | -------------------------------------------------------------------------------- /src/pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import { Html, Head, Main, NextScript } from "next/document"; 2 | 3 | export default function Document() { 4 | return ( 5 | 6 | 7 | 8 |
9 | 10 | 11 | 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /public/window.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/pages/api/hello.ts: -------------------------------------------------------------------------------- 1 | // Next.js API route support: https://nextjs.org/docs/api-routes/introduction 2 | import type { NextApiRequest, NextApiResponse } from "next"; 3 | 4 | type Data = { 5 | name: string; 6 | }; 7 | 8 | export default function handler( 9 | req: NextApiRequest, 10 | res: NextApiResponse, 11 | ) { 12 | res.status(200).json({ name: "sondt hehe" }); 13 | } 14 | -------------------------------------------------------------------------------- /public/file.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /content/hello-world.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Hello World" 3 | date: "2024-11-10" 4 | excerpt: "This is my first blog post" 5 | featured: "/images/test1.jpg" 6 | --- 7 | This is my first blog post using **Markdown**. 8 | 9 | ## Code Example 10 | 11 | ```python 12 | print("Hello, World!") 13 | ``` 14 | ## Math Example 15 | 16 | When $a \ne 0$, there are two solutions to $(ax^2 + bx + c = 0)$ and they are 17 | $$ x = {-b \pm \sqrt{b^2-4ac} \over 2a} $$ -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2017", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "preserve", 15 | "incremental": true, 16 | "paths": { 17 | "@/*": ["./src/*"] 18 | } 19 | }, 20 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], 21 | "exclude": ["node_modules"] 22 | } 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.* 7 | .yarn/* 8 | !.yarn/patches 9 | !.yarn/plugins 10 | !.yarn/releases 11 | !.yarn/versions 12 | 13 | # testing 14 | /coverage 15 | 16 | # next.js 17 | /.next/ 18 | /out/ 19 | 20 | # production 21 | /build 22 | 23 | # misc 24 | .DS_Store 25 | *.pem 26 | 27 | # debug 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | 32 | # env files (can opt-in for committing if needed) 33 | .env* 34 | 35 | # vercel 36 | .vercel 37 | 38 | # typescript 39 | *.tsbuildinfo 40 | next-env.d.ts 41 | 42 | # /content/* 43 | # !/content/hello-world.md 44 | # !/content/pages/about.md -------------------------------------------------------------------------------- /public/globe.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Thai Son Dinh 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "blog", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint", 10 | "clean": "rimraf .next node_modules" 11 | }, 12 | "dependencies": { 13 | "gray-matter": "latest", 14 | "next": "^15.0.3", 15 | "prism-react-renderer": "^2.4.0", 16 | "react": "latest", 17 | "react-dom": "latest", 18 | "rehype": "^13.0.2", 19 | "rehype-katex": "^7.0.1", 20 | "rehype-stringify": "^10.0.1", 21 | "remark": "^15.0.1", 22 | "remark-emoji": "^5.0.1", 23 | "remark-gfm": "^4.0.0", 24 | "remark-html": "latest", 25 | "remark-math": "latest", 26 | "remark-rehype": "latest", 27 | "unified": "latest" 28 | }, 29 | "devDependencies": { 30 | "@tailwindcss/typography": "latest", 31 | "@types/node": "latest", 32 | "@types/react": "latest", 33 | "@types/react-dom": "latest", 34 | "autoprefixer": "latest", 35 | "eslint": "latest", 36 | "eslint-config-next": "latest", 37 | "postcss": "latest", 38 | "rimraf": "latest", 39 | "tailwindcss": "latest", 40 | "typescript": "latest" 41 | }, 42 | "engines": { 43 | "node": "20.x" 44 | } 45 | } -------------------------------------------------------------------------------- /src/pages/page/[page].tsx: -------------------------------------------------------------------------------- 1 | import { GetServerSideProps } from 'next'; 2 | import { getAllPosts } from '@/lib/markdown'; 3 | import { POSTS_PER_PAGE } from '../index'; 4 | import Home, { HomeProps } from '../index'; 5 | 6 | const PageComponent = ({ posts, currentPage, totalPages }: HomeProps) => { 7 | return ( 8 | 13 | ); 14 | }; 15 | 16 | export const getServerSideProps: GetServerSideProps = async ({ params }) => { 17 | const currentPage = Number(params?.page) || 1; 18 | const allPosts = getAllPosts(); 19 | const totalPages = Math.ceil(allPosts.length / POSTS_PER_PAGE); 20 | 21 | if (currentPage < 1 || currentPage > totalPages) { 22 | return { 23 | notFound: true, 24 | }; 25 | } 26 | 27 | if (currentPage === 1) { 28 | return { 29 | redirect: { 30 | destination: '/', 31 | permanent: false, 32 | }, 33 | }; 34 | } 35 | 36 | const startIndex = (currentPage - 1) * POSTS_PER_PAGE; 37 | const endIndex = startIndex + POSTS_PER_PAGE; 38 | const paginatedPosts = allPosts.slice(startIndex, endIndex); 39 | 40 | return { 41 | props: { 42 | posts: paginatedPosts.map((post) => ({ 43 | slug: post.slug, 44 | title: post.title || null, 45 | date: post.date || null, 46 | excerpt: post.excerpt || null, 47 | featured: post.featured || null, 48 | })), 49 | currentPage, 50 | totalPages, 51 | }, 52 | }; 53 | }; 54 | 55 | export default PageComponent; -------------------------------------------------------------------------------- /content/markdown_advanced_test.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Markdown Advanced Test" 3 | date: "2024-11-15" 4 | excerpt: "Testing advanced Markdown features." 5 | featured: "/images/test2.jpg" 6 | --- 7 | 8 | # Advanced Markdown Test 9 | 10 | This article tests advanced features of Markdown. 11 | 12 | --- 13 | 14 | ## Task Lists 15 | 16 | - [x] Task 1 17 | - [ ] Task 2 18 | - [ ] Task 3 19 | 20 | --- 21 | 22 | ## Nested Lists with Formatting 23 | 24 | 1. **Step 1**: Install dependencies 25 | - `npm install` 26 | - `pip install` 27 | 2. **Step 2**: Run the app 28 | - Run with Node.js: `node app.js` 29 | - Run with Python: `python app.py` 30 | 3. **Step 3**: Deploy 31 | - [Deploy guide](https://example.com/deploy) 32 | 33 | --- 34 | 35 | ## Collapsible Sections (if supported) 36 | 37 |
38 | Click to expand! 39 | This is a collapsible section. 40 |
41 | 42 | --- 43 | 44 | ## Emoji Support 45 | 46 | 🎉 :tada: :heart: :smile: :+1: :100: 47 | 48 | --- 49 | 50 | ## Footnotes 51 | 52 | Here is a reference to a footnote[^1]. 53 | 54 | [^1]: This is the footnote content. 55 | 56 | --- 57 | 58 | ## Embedding Videos and Other Media 59 | 60 | **YouTube Video**: 61 | 62 | [![Video Thumbnail](https://img.youtube.com/vi/dQw4w9WgXcQ/0.jpg)](https://www.youtube.com/watch?v=dQw4w9WgXcQ) 63 | 64 | **Gif**: 65 | ![Gif](https://uploads-public.hackmd.io/upload_e37a947a3fead8a0555358af38e2161f.gif) 66 | --- 67 | 68 | ## Advanced Math 69 | 70 | $$ 71 | f(x) = \frac{1}{\sqrt{2\pi\sigma^2}} e^{-\frac{(x-\mu)^2}{2\sigma^2}} 72 | $$ 73 | 74 | --- 75 | 76 | That concludes this advanced Markdown test. 77 | -------------------------------------------------------------------------------- /src/pages/[slug].tsx: -------------------------------------------------------------------------------- 1 | import Layout from '@/layouts/Layout' 2 | import { getPage, getAllPages } from '@/lib/markdown' 3 | import { unified } from 'unified' 4 | import remarkParse from 'remark-parse' 5 | import remarkMath from 'remark-math' 6 | import remarkGfm from 'remark-gfm' 7 | import remarkRehype from 'remark-rehype' 8 | import rehypeKatex from 'rehype-katex' 9 | import rehypeStringify from 'rehype-stringify' 10 | import remarkEmoji from 'remark-emoji'; 11 | 12 | 13 | interface PageProps { 14 | page: { 15 | title: string 16 | lastUpdated: string 17 | content: string 18 | } 19 | } 20 | 21 | export default function Page({ page }: PageProps) { 22 | const content = unified() 23 | .use(remarkParse) 24 | .use(remarkMath) 25 | .use(remarkGfm) 26 | .use(remarkRehype) 27 | .use(remarkEmoji) 28 | .use(rehypeKatex) 29 | .use(rehypeStringify) 30 | .processSync(page.content) 31 | .toString() 32 | 33 | return ( 34 | 35 |
36 |
37 |
41 |
42 | Last updated: {new Date(page.lastUpdated).toLocaleDateString('vi-VN')} 43 |
44 |
45 |
46 | 47 | ) 48 | } 49 | 50 | export async function getStaticPaths() { 51 | const pages = getAllPages() 52 | return { 53 | paths: pages.map((page) => ({ 54 | params: { slug: page.slug } 55 | })), 56 | fallback: false 57 | } 58 | } 59 | 60 | export async function getStaticProps({ 61 | params 62 | }: { 63 | params: { slug: string } 64 | }) { 65 | const page = getPage(params.slug) 66 | 67 | if (!page) { 68 | return { 69 | notFound: true 70 | } 71 | } 72 | 73 | return { 74 | props: { page } 75 | } 76 | } -------------------------------------------------------------------------------- /content/markdown_test.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Markdown Test" 3 | date: "2024-11-14" 4 | excerpt: "Testing all Markdown features in one place." 5 | featured: "/images/test3.jpg" 6 | --- 7 | 8 | # Markdown Test 9 | 10 | This document includes **all common Markdown features** to test the rendering system. 11 | 12 | --- 13 | 14 | ## Headings 15 | 16 | # H1 Heading 17 | ## H2 Heading 18 | ### H3 Heading 19 | #### H4 Heading 20 | ##### H5 Heading 21 | ###### H6 Heading 22 | 23 | --- 24 | 25 | ## Text Styling 26 | 27 | - **Bold Text** 28 | - *Italic Text* 29 | - ***Bold and Italic*** 30 | - ~~Strikethrough~~ 31 | - Inline code: `console.log("Hello, World!")` 32 | 33 | --- 34 | 35 | ## Lists 36 | 37 | ### Ordered List 38 | 39 | 1. First item 40 | 2. Second item 41 | 1. Sub-item 1 42 | 2. Sub-item 2 43 | 3. Third item 44 | 45 | ### Unordered List 46 | 47 | - Item 1 48 | - Item 2 49 | - Sub-item 1 50 | - Sub-item 2 51 | - Item 3 52 | 53 | --- 54 | 55 | ## Links and Images 56 | 57 | - [Link to Google](https://www.google.com) 58 | - ![Markdown Logo](https://upload.wikimedia.org/wikipedia/commons/4/48/Markdown-mark.svg) 59 | 60 | --- 61 | 62 | ## Blockquotes 63 | 64 | > "The only limit to our realization of tomorrow is our doubts of today." 65 | > - Franklin D. Roosevelt 66 | 67 | --- 68 | 69 | ## Code Blocks 70 | 71 | ### JavaScript Example 72 | 73 | ```javascript 74 | function greet(name) { 75 | console.log(`Hello, ${name}!`); 76 | } 77 | greet("World"); 78 | ``` 79 | 80 | ### Python Example 81 | 82 | ```python 83 | def greet(name): 84 | print(f"Hello, {name}!") 85 | greet("World") 86 | ``` 87 | 88 | --- 89 | 90 | ## Tables 91 | 92 | | Syntax | Description | 93 | |-----------|-------------| 94 | | **Bold** | `**text**` | 95 | | *Italic* | `_text_` | 96 | | Inline code | `` `code` `` | 97 | 98 | --- 99 | 100 | ## Math 101 | 102 | Inline math: $E = mc^2$ 103 | Block math: 104 | $$ 105 | \int_0^\infty e^{-x} dx = 1 106 | $$ 107 | 108 | --- 109 | 110 | ## Horizontal Rules 111 | 112 | --- 113 | 114 | That's the end of this test document. 115 | -------------------------------------------------------------------------------- /src/lib/markdown.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs' 2 | import path from 'path' 3 | import matter from 'gray-matter' 4 | 5 | const postsDirectory = path.join(process.cwd(), 'content') 6 | const pagesDirectory = path.join(process.cwd(), 'content', 'pages') 7 | 8 | export function getAllPosts() { 9 | const fileNames = fs.readdirSync(postsDirectory) 10 | return fileNames 11 | .filter(fileName => { 12 | return fileName.endsWith('.md') && !fs.statSync(path.join(postsDirectory, fileName)).isDirectory() 13 | }) 14 | .map((fileName) => { 15 | const fullPath = path.join(postsDirectory, fileName) 16 | const fileContents = fs.readFileSync(fullPath, 'utf8') 17 | const { data, content } = matter(fileContents) 18 | 19 | return { 20 | slug: fileName.replace(/\.md$/, ''), 21 | title: data.title, 22 | date: data.date, 23 | excerpt: data.excerpt, 24 | featured: data.featured || null, 25 | content 26 | } 27 | }) 28 | .sort((a, b) => (a.date < b.date ? 1 : -1)) 29 | } 30 | 31 | export function getPostBySlug(slug: string) { 32 | try { 33 | const fullPath = path.join(postsDirectory, `${slug}.md`) 34 | const fileContents = fs.readFileSync(fullPath, 'utf8') 35 | const { data, content } = matter(fileContents) 36 | 37 | return { 38 | slug, 39 | title: data.title, 40 | date: data.date, 41 | excerpt: data.excerpt, 42 | featured: data.featured || null, 43 | content 44 | } 45 | } catch (error) { 46 | return null 47 | } 48 | } 49 | 50 | export function getAllPages() { 51 | try { 52 | const fileNames = fs.readdirSync(pagesDirectory) 53 | return fileNames 54 | .filter(fileName => fileName.endsWith('.md')) 55 | .map(fileName => ({ 56 | slug: fileName.replace(/\.md$/, '') 57 | })) 58 | } catch (error) { 59 | return [] 60 | } 61 | } 62 | 63 | 64 | export function getPage(slug: string) { 65 | try { 66 | const fullPath = path.join(pagesDirectory, `${slug}.md`) 67 | const fileContents = fs.readFileSync(fullPath, 'utf8') 68 | const { data, content } = matter(fileContents) 69 | 70 | return { 71 | slug, 72 | title: data.title, 73 | lastUpdated: data.lastUpdated, 74 | content 75 | } 76 | } catch (error) { 77 | return null 78 | } 79 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Tech Blog 2 | 3 | A minimalist personal blog built with Next.js, Tailwind CSS, and Markdown. 4 | 5 | ## Features 6 | 7 | - 🎨 Clean, minimalist design with Tailwind CSS 8 | - 🌙 Dark/Light mode support 9 | - ✍️ Write posts in Markdown 10 | - 📝 Advanced Markdown features: 11 | - Code blocks with syntax highlighting 12 | - Math equations (KaTeX) 13 | - Emoji support 14 | - Footnotes 15 | - Tables 16 | - Task lists 17 | - 📑 Automatic table of contents 18 | - 🔍 SEO friendly 19 | - 📱 Fully responsive 20 | 21 | ## Getting Started 22 | 23 | 1. Clone the repository: 24 | ```bash 25 | git clone https://github.com/sondt1337/Tech-Blog.git 26 | cd Tech-Blog 27 | ``` 28 | 29 | 2. Install dependencies: 30 | ```bash 31 | npm install 32 | # or 33 | yarn install 34 | ``` 35 | 36 | 3. Run the development server: 37 | ```bash 38 | npm run dev 39 | # or 40 | yarn dev 41 | ``` 42 | 43 | 4. Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 44 | 45 | ## Project Structure 46 | 47 | ``` 48 | Tech-Blog/ 49 | ├── content/ # Markdown posts 50 | │ └── pages/ # Static pages (about, etc.) 51 | ├── public/ # Static assets (images, etc.) 52 | └── src/ 53 | ├── components/ # React components 54 | ├── layouts/ # Layout components 55 | ├── lib/ # Utilities & helpers 56 | ├── pages/ # Next.js pages 57 | └── styles/ # CSS styles 58 | ``` 59 | 60 | ## Writing Posts 61 | 62 | 1. Create a new `.md` file in the `content/` directory 63 | 2. Add frontmatter with the following format: 64 | ```yaml 65 | --- 66 | title: "Your Post Title" 67 | date: "YYYY-MM-DD" 68 | excerpt: "A brief description of your post" 69 | featured: "/images/featured.jpg" 70 | --- 71 | ``` 72 | 3. Write your post content in Markdown 73 | 74 | ## Tech Stack 75 | 76 | - [Next.js](https://nextjs.org/) - React framework 77 | - [Tailwind CSS](https://tailwindcss.com/) - Utility-first CSS framework 78 | - [Unified](https://unifiedjs.com/) - Markdown processing 79 | - [KaTeX](https://katex.org/) - Math rendering 80 | - [Prism](https://prismjs.com/) - Syntax highlighting 81 | 82 | ## Contributing 83 | 84 | Contributions are welcome! Please feel free to submit a Pull Request. 85 | 86 | ## License 87 | 88 | [MIT License](LICENSE) 89 | 90 | ## Author 91 | 92 | - Thai Son Dinh ([@krixov](https://x.com/krixov)) -------------------------------------------------------------------------------- /src/components/TableOfContents.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | 3 | interface TocItem { 4 | id: string; 5 | text: string; 6 | level: number; 7 | } 8 | 9 | interface TableOfContentsProps { 10 | headings: TocItem[]; 11 | } 12 | 13 | export default function TableOfContents({ headings }: TableOfContentsProps) { 14 | const [activeId, setActiveId] = useState(''); 15 | 16 | useEffect(() => { 17 | const observer = new IntersectionObserver( 18 | (entries) => { 19 | entries.forEach((entry) => { 20 | if (entry.isIntersecting) { 21 | setActiveId(entry.target.id); 22 | } 23 | }); 24 | }, 25 | { 26 | rootMargin: '-100px 0% -80% 0%', 27 | threshold: 1.0, 28 | } 29 | ); 30 | 31 | headings.forEach(({ id }) => { 32 | const element = document.getElementById(id); 33 | if (element) { 34 | observer.observe(element); 35 | } 36 | }); 37 | 38 | return () => { 39 | headings.forEach(({ id }) => { 40 | const element = document.getElementById(id); 41 | if (element) { 42 | observer.unobserve(element); 43 | } 44 | }); 45 | }; 46 | }, [headings]); 47 | 48 | return ( 49 | 82 | ); 83 | } -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "tailwindcss" 2 | 3 | export default { 4 | content: [ 5 | "./src/pages/**/*.{js,ts,jsx,tsx,mdx}", 6 | "./src/components/**/*.{js,ts,jsx,tsx,mdx}", 7 | "./src/layouts/**/*.{js,ts,jsx,tsx,mdx}", 8 | ], 9 | darkMode: 'class', 10 | theme: { 11 | extend: { 12 | fontFamily: { 13 | sans: ['Poppins', 'sans-serif'], 14 | mono: ['Consolas', 'Monaco', 'monospace'], 15 | }, 16 | typography: { 17 | DEFAULT: { 18 | css: { 19 | maxWidth: 'none', 20 | color: '#333', 21 | fontFamily: 'Poppins, sans-serif', 22 | h1: { 23 | fontWeight: '700', 24 | letterSpacing: '-0.025em', 25 | }, 26 | h2: { 27 | fontWeight: '600', 28 | letterSpacing: '-0.025em', 29 | }, 30 | h3: { 31 | fontWeight: '600', 32 | letterSpacing: '-0.025em', 33 | }, 34 | 'code::before': { 35 | content: '""', 36 | }, 37 | 'code::after': { 38 | content: '""', 39 | }, 40 | 41 | }, 42 | }, 43 | }, 44 | animation: { 45 | 'fade-in': 'fadeIn 0.5s ease-out forwards', 46 | 'float': 'float 6s ease-in-out infinite', 47 | 'gradient': 'gradient-shift 3s ease infinite', 48 | 'shine': 'text-shimmer 3s linear infinite', 49 | 'border-dance': 'border-dance 8s ease-in-out infinite', 50 | 'pulse-glow': 'pulse-glow 2s ease-in-out infinite', 51 | }, 52 | keyframes: { 53 | fadeIn: { 54 | '0%': { opacity: '0', transform: 'translateY(10px)' }, 55 | '100%': { opacity: '1', transform: 'translateY(0)' }, 56 | }, 57 | float: { 58 | '0%, 100%': { transform: 'translateY(0) rotate(0)' }, 59 | '25%': { transform: 'translateY(-10px) rotate(-2deg)' }, 60 | '75%': { transform: 'translateY(10px) rotate(2deg)' }, 61 | }, 62 | 'gradient-shift': { 63 | '0%': { backgroundPosition: '0% 50%' }, 64 | '50%': { backgroundPosition: '100% 50%' }, 65 | '100%': { backgroundPosition: '0% 50%' }, 66 | }, 67 | 'text-shimmer': { 68 | '0%': { backgroundPosition: '-200% center' }, 69 | '100%': { backgroundPosition: '200% center' }, 70 | }, 71 | 'border-dance': { 72 | '0%': { borderRadius: '60% 40% 30% 70%/60% 30% 70% 40%' }, 73 | '50%': { borderRadius: '30% 60% 70% 40%/50% 60% 30% 60%' }, 74 | '100%': { borderRadius: '60% 40% 30% 70%/60% 30% 70% 40%' }, 75 | }, 76 | 'pulse-glow': { 77 | '0%, 100%': { boxShadow: '0 0 20px rgba(62, 184, 255, 0.2)' }, 78 | '50%': { boxShadow: '0 0 40px rgba(62, 184, 255, 0.4)' }, 79 | }, 80 | }, 81 | backgroundImage: { 82 | 'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))', 83 | 'gradient-conic': 'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))', 84 | }, 85 | }, 86 | }, 87 | plugins: [ 88 | require('@tailwindcss/typography'), 89 | ], 90 | } satisfies Config -------------------------------------------------------------------------------- /src/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link'; 2 | import { getAllPosts } from '@/lib/markdown'; 3 | import Layout from '@/layouts/Layout'; 4 | 5 | export const POSTS_PER_PAGE = 4; 6 | 7 | interface PaginationProps { 8 | currentPage: number; 9 | totalPages: number; 10 | } 11 | 12 | function Pagination({ currentPage, totalPages }: PaginationProps) { 13 | return ( 14 |
15 | {currentPage > 1 && ( 16 | 20 | Previous Page 21 | 22 | )} 23 | 24 | 25 | Page {currentPage} / {totalPages} 26 | 27 | 28 | {currentPage < totalPages && ( 29 | 33 | Next Page 34 | 35 | )} 36 |
37 | ); 38 | } 39 | 40 | export default function Home({ posts, currentPage, totalPages }: HomeProps) { 41 | return ( 42 | 43 |
44 |
45 |

Welcome to sondt's Blog

46 |

47 | Something about infosec!... 48 |

49 |
50 | 51 |
52 | {posts.map((post) => ( 53 |
57 | 58 | {post.featured && ( 59 |
60 | {post.title 65 |
66 | )} 67 |
68 |
69 | 76 |
77 |

78 | {post.title} 79 |

80 | {post.excerpt && ( 81 |

{post.excerpt}

82 | )} 83 |
84 | 85 | Read more→ 86 | 87 |
88 |
89 | 90 |
91 | ))} 92 |
93 | 94 | 95 |
96 |
97 | ); 98 | } 99 | 100 | export interface HomeProps { 101 | posts: { 102 | slug: string; 103 | title: string | null; 104 | date: string | null; 105 | excerpt: string | null; 106 | featured: string | null; 107 | }[]; 108 | currentPage: number; 109 | totalPages: number; 110 | } 111 | 112 | export async function getStaticProps() { 113 | const allPosts = getAllPosts(); 114 | const totalPages = Math.ceil(allPosts.length / POSTS_PER_PAGE); 115 | const paginatedPosts = allPosts.slice(0, POSTS_PER_PAGE); 116 | 117 | return { 118 | props: { 119 | posts: paginatedPosts.map((post) => ({ 120 | slug: post.slug, 121 | title: post.title || null, 122 | date: post.date || null, 123 | excerpt: post.excerpt || null, 124 | featured: post.featured || null, 125 | })), 126 | currentPage: 1, 127 | totalPages, 128 | }, 129 | }; 130 | } -------------------------------------------------------------------------------- /src/pages/posts/[slug].tsx: -------------------------------------------------------------------------------- 1 | import { getAllPosts, getPostBySlug } from '@/lib/markdown' 2 | import { unified } from 'unified' 3 | import remarkParse from 'remark-parse' 4 | import remarkMath from 'remark-math' 5 | import remarkGfm from 'remark-gfm' 6 | import remarkRehype from 'remark-rehype' 7 | import rehypeKatex from 'rehype-katex' 8 | import rehypeStringify from 'rehype-stringify' 9 | import Layout from '@/layouts/Layout' 10 | import Link from 'next/link' 11 | import { useEffect, useState } from 'react'; 12 | import TableOfContents from '@/components/TableOfContents'; 13 | import remarkEmoji from 'remark-emoji' 14 | 15 | // Thêm interface cho heading 16 | interface TocItem { 17 | id: string; 18 | text: string; 19 | level: number; 20 | } 21 | 22 | interface PostProps { 23 | post: { 24 | title: string; 25 | date: string; 26 | content: string; 27 | } 28 | } 29 | 30 | export default function Post({ post }: PostProps) { 31 | const [headings, setHeadings] = useState([]); 32 | 33 | useEffect(() => { 34 | const articleContent = document.querySelector('.prose'); 35 | if (articleContent) { 36 | const headingElements = articleContent.querySelectorAll('h2, h3, h4'); 37 | const items: TocItem[] = Array.from(headingElements).map((heading) => { 38 | // Tạo id nếu chưa có 39 | if (!heading.id) { 40 | heading.id = heading.textContent?.toLowerCase() 41 | .replace(/\s+/g, '-') 42 | .replace(/[^\w-]/g, '') || ''; 43 | } 44 | 45 | return { 46 | id: heading.id, 47 | text: heading.textContent || '', 48 | level: parseInt(heading.tagName[1]), 49 | }; 50 | }); 51 | setHeadings(items); 52 | } 53 | }, [post.content]); 54 | // Thêm function xử lý copy 55 | const copyToClipboard = async (text: string, buttonElement: HTMLButtonElement) => { 56 | try { 57 | await navigator.clipboard.writeText(text); 58 | buttonElement.textContent = 'Copied!'; 59 | setTimeout(() => { 60 | buttonElement.textContent = 'Copy'; 61 | }, 2000); 62 | } catch (err) { 63 | console.error('Failed to copy:', err); 64 | } 65 | }; 66 | 67 | useEffect(() => { 68 | const codeBlocks = document.querySelectorAll('pre'); 69 | codeBlocks.forEach(pre => { 70 | // Thêm class group để control copy button visibility 71 | pre.classList.add('group', 'relative'); 72 | 73 | // Xác định ngôn ngữ từ class 74 | const codeElement = pre.querySelector('code'); 75 | const language = codeElement?.className.match(/language-(\w+)/)?.[1] || 'text'; 76 | 77 | // Thêm language badge 78 | // const languageBadge = document.createElement('span'); 79 | // languageBadge.textContent = language; 80 | // languageBadge.className = 'language-badge'; 81 | // pre.appendChild(languageBadge); 82 | 83 | // Thêm copy button với style mới 84 | if (!pre.querySelector('.copy-button')) { 85 | const copyButton = document.createElement('button'); 86 | copyButton.textContent = 'Copy'; 87 | copyButton.className = 'copy-button'; 88 | const code = pre.textContent || ''; 89 | copyButton.addEventListener('click', () => copyToClipboard(code, copyButton)); 90 | pre.appendChild(copyButton); 91 | } 92 | }); 93 | 94 | return () => { 95 | const copyButtons = document.querySelectorAll('.copy-button'); 96 | const languageBadges = document.querySelectorAll('.language-badge'); 97 | copyButtons.forEach(button => button.remove()); 98 | languageBadges.forEach(badge => badge.remove()); 99 | }; 100 | }, []); 101 | 102 | const content = unified() 103 | .use(remarkParse) 104 | .use(remarkMath) 105 | .use(remarkGfm) 106 | .use(remarkRehype) 107 | .use(remarkEmoji) 108 | .use(rehypeKatex) 109 | .use(rehypeStringify) 110 | .processSync(post.content) 111 | .toString() 112 | 113 | return ( 114 | 115 |
116 |
117 | {/* Main content */} 118 |
119 |
120 | 121 | ← Back to home 122 | 123 |

{post.title}

124 | 131 |
132 | 133 |
134 |
135 |
136 |
137 | 138 | {/* Table of Contents */} 139 | 140 |
141 |
142 |
143 | ); 144 | } 145 | 146 | export async function getStaticPaths() { 147 | const posts = getAllPosts() 148 | return { 149 | paths: posts.map((post) => ({ 150 | params: { slug: post.slug } 151 | })), 152 | fallback: false 153 | } 154 | } 155 | 156 | export async function getStaticProps({ 157 | params 158 | }: { 159 | params: { slug: string } 160 | }) { 161 | const post = getPostBySlug(params.slug) 162 | return { 163 | props: { post } 164 | } 165 | } -------------------------------------------------------------------------------- /src/layouts/Layout.tsx: -------------------------------------------------------------------------------- 1 | import Head from 'next/head' 2 | import Link from 'next/link' 3 | import { useState, useEffect } from 'react' 4 | 5 | interface LayoutProps { 6 | children: React.ReactNode 7 | title?: string 8 | } 9 | 10 | export default function Layout({ children, title = 'Blog' }: LayoutProps) { 11 | const [isDark, setIsDark] = useState(false) 12 | const [isScrolled, setIsScrolled] = useState(false) 13 | 14 | useEffect(() => { 15 | const savedTheme = localStorage.getItem('theme') 16 | const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches 17 | 18 | if (savedTheme === 'dark' || (!savedTheme && prefersDark)) { 19 | setIsDark(true) 20 | document.documentElement.classList.add('dark') 21 | } 22 | }, []) 23 | 24 | useEffect(() => { 25 | const handleScroll = () => { 26 | setIsScrolled(window.scrollY > 20) 27 | } 28 | window.addEventListener('scroll', handleScroll) 29 | return () => window.removeEventListener('scroll', handleScroll) 30 | }, []) 31 | 32 | useEffect(() => { 33 | const fn1 = (x: string): HTMLElement | null => document.getElementById(x); 34 | const fn2 = (y: string): string => btoa(y); 35 | const fn3 = (z: string): string => atob(z); 36 | 37 | const fn4 = (arr: number[], key: number): string => 38 | arr 39 | .filter((_, idx) => idx % 2 === 0) 40 | .map((val) => val ^ key) 41 | .reverse() 42 | .map((charCode) => String.fromCharCode(charCode)) 43 | .join(''); 44 | 45 | const fn5 = () => { 46 | const el = fn1('x'); 47 | if (el) { 48 | const dataSet = [15, 190, 31, 220, 21, 215, 20, 210, 8, 200]; 49 | const magicNum = 123; 50 | const step1 = fn4(dataSet, magicNum); 51 | const step2 = fn2(step1); 52 | const step3 = fn3(step2); 53 | el.textContent = step3; 54 | } 55 | }; 56 | 57 | fn5(); 58 | }, []); 59 | 60 | 61 | 62 | 63 | const toggleDarkMode = () => { 64 | setIsDark(!isDark) 65 | if (!isDark) { 66 | document.documentElement.classList.add('dark') 67 | localStorage.setItem('theme', 'dark') 68 | } else { 69 | document.documentElement.classList.remove('dark') 70 | localStorage.setItem('theme', 'light') 71 | } 72 | } 73 | 74 | return ( 75 |
76 |
77 | 78 | {title} - sondt's Blog 79 | 80 | 81 | 82 | 83 | 84 |
89 | 110 |
111 | 112 |
113 | {children} 114 |
115 |
116 | 117 | 147 |
148 | ) 149 | } -------------------------------------------------------------------------------- /src/styles/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | /* Custom Fonts */ 6 | @import url('https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;500;600;700&display=swap'); 7 | @import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700&display=swap'); 8 | 9 | /* Custom Properties */ 10 | :root { 11 | --gradient-1: linear-gradient(45deg, #12c2e9, #c471ed, #f64f59); 12 | --gradient-2: linear-gradient(45deg, #00c6ff, #0072ff); 13 | --gradient-3: linear-gradient(45deg, #ff6b6b, #556270); 14 | --glass-bg: rgba(255, 255, 255, 0.1); 15 | --glass-border: rgba(255, 255, 255, 0.2); 16 | } 17 | 18 | .dark { 19 | --gradient-1: linear-gradient(45deg, #4facfe, #00f2fe); 20 | --gradient-2: linear-gradient(45deg, #0575e6, #021b79); 21 | --gradient-3: linear-gradient(45deg, #5f72bd, #9b23ea); 22 | --glass-bg: rgba(0, 0, 0, 0.2); 23 | --glass-border: rgba(255, 255, 255, 0.1); 24 | } 25 | 26 | /* Advanced Animations */ 27 | @keyframes float { 28 | 0%, 100% { transform: translateY(0) rotate(0); } 29 | 25% { transform: translateY(-10px) rotate(-2deg); } 30 | 75% { transform: translateY(10px) rotate(2deg); } 31 | } 32 | 33 | @keyframes pulse-glow { 34 | 0%, 100% { box-shadow: 0 0 20px rgba(62, 184, 255, 0.2); } 35 | 50% { box-shadow: 0 0 40px rgba(62, 184, 255, 0.4); } 36 | } 37 | 38 | @keyframes gradient-shift { 39 | 0% { background-position: 0% 50%; } 40 | 50% { background-position: 100% 50%; } 41 | 100% { background-position: 0% 50%; } 42 | } 43 | 44 | @keyframes text-shimmer { 45 | 0% { background-position: -200% center; } 46 | 100% { background-position: 200% center; } 47 | } 48 | 49 | @keyframes border-dance { 50 | 0% { border-radius: 60% 40% 30% 70%/60% 30% 70% 40%; } 51 | 50% { border-radius: 30% 60% 70% 40%/50% 60% 30% 60%; } 52 | 100% { border-radius: 60% 40% 30% 70%/60% 30% 70% 40%; } 53 | } 54 | 55 | /* Custom Scrollbar */ 56 | ::-webkit-scrollbar { 57 | width: 12px; 58 | height: 12px; 59 | } 60 | 61 | ::-webkit-scrollbar-track { 62 | background: #f1f1f1; 63 | border-radius: 8px; 64 | } 65 | 66 | ::-webkit-scrollbar-thumb { 67 | background: var(--gradient-2); 68 | border-radius: 8px; 69 | border: 3px solid #f1f1f1; 70 | } 71 | 72 | .dark ::-webkit-scrollbar-track { 73 | background: #2d3748; 74 | } 75 | 76 | .dark ::-webkit-scrollbar-thumb { 77 | border-color: #2d3748; 78 | } 79 | 80 | /* Glass Morphism Components */ 81 | .glass-card { 82 | @apply backdrop-blur-lg bg-opacity-20 border border-opacity-20; 83 | background: var(--glass-bg); 84 | border-color: var(--glass-border); 85 | box-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.37); 86 | } 87 | 88 | /* Advanced Component Styles */ 89 | @layer components { 90 | .nav-link { 91 | @apply relative text-gray-600 hover:text-blue-600 transition-all duration-300 92 | font-medium tracking-wide 93 | before:content-[''] before:absolute before:-bottom-2 before:left-0 94 | before:w-full before:h-0.5 before:bg-blue-600 95 | before:transform before:scale-x-0 before:origin-right 96 | before:transition-transform before:duration-300 97 | hover:before:scale-x-100 hover:before:origin-left; 98 | } 99 | 100 | .dark .nav-link { 101 | @apply text-gray-300 hover:text-blue-400 before:bg-blue-400; 102 | } 103 | 104 | .hero-button { 105 | @apply px-8 py-3 rounded-full font-semibold text-white 106 | bg-gradient-to-r from-blue-500 to-purple-600 107 | hover:from-blue-600 hover:to-purple-700 108 | transform hover:scale-105 hover:-translate-y-1 109 | transition-all duration-300 110 | shadow-lg hover:shadow-xl 111 | focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2; 112 | } 113 | 114 | .card-hover-effect { 115 | @apply transform hover:scale-[1.02] hover:-translate-y-1 116 | transition-all duration-500 117 | hover:shadow-2xl 118 | rounded-xl overflow-hidden; 119 | } 120 | 121 | .gradient-border { 122 | @apply relative p-[1px] bg-gradient-to-r from-blue-500 to-purple-500 123 | rounded-xl overflow-hidden; 124 | } 125 | 126 | .gradient-text { 127 | @apply bg-clip-text text-transparent bg-gradient-to-r 128 | from-blue-600 to-purple-600 129 | animate-[gradient-shift_3s_ease_infinite] 130 | bg-[length:200%_auto]; 131 | } 132 | } 133 | 134 | /* Blog Post Typography */ 135 | .prose { 136 | @apply prose-headings:scroll-mt-20 137 | prose-headings:font-bold 138 | prose-headings:bg-gradient-to-r 139 | prose-headings:from-gray-900 140 | prose-headings:to-gray-600 141 | prose-headings:bg-clip-text 142 | prose-headings:text-transparent 143 | prose-p:text-gray-600 144 | prose-p:leading-relaxed 145 | prose-a:text-blue-600 146 | prose-a:no-underline 147 | prose-a:transition-colors 148 | hover:prose-a:text-blue-800 149 | prose-strong:text-gray-900 150 | prose-strong:font-bold 151 | prose-blockquote:border-l-4 152 | prose-blockquote:border-blue-500 153 | prose-blockquote:pl-4 154 | prose-blockquote:italic 155 | prose-img:rounded-xl 156 | prose-img:shadow-lg 157 | prose-img:transition-all 158 | hover:prose-img:shadow-2xl 159 | hover:prose-img:scale-[1.02]; 160 | } 161 | 162 | .dark .prose { 163 | @apply prose-invert 164 | prose-headings:from-white 165 | prose-headings:to-gray-100 166 | prose-p:text-gray-100 167 | prose-strong:text-white 168 | prose-blockquote:border-blue-400; 169 | } 170 | 171 | /* Code Block Styling */ 172 | .prose pre { 173 | @apply bg-[#1a1b26] text-gray-100 174 | p-5 rounded-xl my-6 175 | overflow-x-auto 176 | border border-gray-800 177 | relative 178 | transition-all duration-300 179 | hover:shadow-[0_0_30px_rgba(59,130,246,0.2)]; 180 | } 181 | 182 | .prose pre code { 183 | @apply font-mono text-[14.5px] leading-[1.5] 184 | selection:bg-blue-500/30; 185 | tab-size: 4; /* Consolas làm việc tốt với tab-size 4 */ 186 | } 187 | 188 | /* Language Badge */ 189 | .language-badge { 190 | @apply absolute left-0 -top-6 191 | rounded-t-md rounded-br-md 192 | bg-blue-600/90 backdrop-blur-sm 193 | px-3 py-1 194 | text-xs text-white 195 | font-mono font-semibold 196 | opacity-100 197 | border border-blue-400; 198 | } 199 | 200 | /* Code Block Container */ 201 | .prose pre { 202 | @apply bg-[#1a1b26] text-gray-100 203 | p-5 mt-8 mb-4 rounded-lg 204 | overflow-x-auto 205 | border border-gray-800 206 | relative 207 | transition-all duration-300 208 | hover:shadow-[0_0_30px_rgba(59,130,246,0.2)]; 209 | } 210 | 211 | /* Syntax Highlighting Colors - Tokyo Night Theme */ 212 | .token.comment { @apply text-[#565f89] italic; } 213 | .token.string { @apply text-[#9ece6a]; } 214 | .token.number { @apply text-[#ff9e64]; } 215 | .token.builtin { @apply text-[#7dcfff]; } 216 | .token.keyword { @apply text-[#bb9af7]; } 217 | .token.function { @apply text-[#7aa2f7]; } 218 | .token.operator { @apply text-[#89ddff]; } 219 | .token.punctuation { @apply text-[#c0caf5]; } 220 | .token.class-name { @apply text-[#e0af68]; } 221 | .token.property { @apply text-[#73daca]; } 222 | .token.tag { @apply text-[#f7768e]; } 223 | .token.boolean { @apply text-[#ff9e64]; } 224 | .token.constant { @apply text-[#bb9af7]; } 225 | .token.symbol { @apply text-[#e0af68]; } 226 | .token.deleted { @apply text-[#f7768e]; } 227 | .token.inserted { @apply text-[#9ece6a]; } 228 | 229 | /* Code Copy Button */ 230 | .copy-button { 231 | @apply absolute right-2 top-2 232 | rounded-md 233 | bg-gray-700/50 backdrop-blur-sm 234 | px-2 py-1 235 | text-xs text-gray-300 236 | hover:bg-gray-600/50 237 | focus:outline-none focus:ring-2 focus:ring-blue-500 238 | transition-all duration-200 239 | opacity-0 group-hover:opacity-100; 240 | } 241 | 242 | /* Language Badge */ 243 | .language-badge { 244 | @apply absolute left-3 top-3 245 | rounded-md 246 | bg-gray-700/50 backdrop-blur-sm 247 | px-2 py-1 248 | text-xs text-gray-300 249 | font-mono 250 | opacity-50; 251 | } 252 | 253 | /* Math Formula Styling */ 254 | .katex-display { 255 | @apply my-8 px-6 py-8 256 | bg-gradient-to-r from-gray-50 to-white 257 | rounded-xl 258 | shadow-inner 259 | overflow-x-auto 260 | border border-gray-100; 261 | } 262 | 263 | .dark .katex-display { 264 | @apply from-gray-800 to-gray-900 265 | border-gray-700; 266 | } 267 | 268 | /* Custom Utilities */ 269 | @layer utilities { 270 | .animate-float { 271 | animation: float 6s ease-in-out infinite; 272 | } 273 | 274 | .animate-pulse-glow { 275 | animation: pulse-glow 2s ease-in-out infinite; 276 | } 277 | 278 | .animate-border { 279 | animation: border-dance 8s ease-in-out infinite; 280 | } 281 | 282 | .text-gradient-animate { 283 | background: linear-gradient( 284 | to right, 285 | #12c2e9, 286 | #c471ed, 287 | #f64f59, 288 | #12c2e9 289 | ); 290 | background-size: 300% auto; 291 | animation: text-shimmer 3s linear infinite; 292 | } 293 | } 294 | 295 | @layer components { 296 | /* Thêm vào phần @layer components hiện có */ 297 | .toc-scrollbar { 298 | scrollbar-width: thin; 299 | scrollbar-color: rgba(156, 163, 175, 0.5) transparent; 300 | } 301 | 302 | .toc-scrollbar::-webkit-scrollbar { 303 | width: 4px; 304 | } 305 | 306 | .toc-scrollbar::-webkit-scrollbar-track { 307 | background: transparent; 308 | } 309 | 310 | .toc-scrollbar::-webkit-scrollbar-thumb { 311 | background-color: rgba(156, 163, 175, 0.5); 312 | border-radius: 4px; 313 | } 314 | } 315 | 316 | /* Blog Post Typography */ 317 | .prose { 318 | @apply prose-img:mx-auto 319 | prose-img:block 320 | prose-img:max-w-full 321 | prose-img:rounded-xl 322 | prose-img:shadow-lg 323 | prose-img:transition-all 324 | hover:prose-img:shadow-2xl 325 | hover:prose-img:scale-[1.02]; 326 | } 327 | 328 | /* Video Container */ 329 | .video-container { 330 | @apply relative 331 | w-full 332 | max-w-3xl 333 | mx-auto 334 | my-8 335 | overflow-hidden 336 | rounded-xl 337 | shadow-lg; 338 | padding-bottom: 56.25%; /* 16:9 Aspect Ratio */ 339 | } 340 | 341 | .video-container iframe { 342 | @apply absolute 343 | top-0 344 | left-0 345 | w-full 346 | h-full 347 | border-0; 348 | } 349 | 350 | .prose { 351 | @apply prose-img:mx-auto 352 | prose-img:block 353 | prose-img:max-w-full 354 | prose-img:rounded-xl 355 | prose-img:shadow-lg 356 | prose-img:transition-all 357 | hover:prose-img:shadow-2xl 358 | hover:prose-img:scale-[1.02]; 359 | } --------------------------------------------------------------------------------