Compare commits
4 commits
43eec03edd
...
70a8cb8f9f
Author | SHA1 | Date | |
---|---|---|---|
70a8cb8f9f | |||
51bbb8ff11 | |||
fb782e8cf2 | |||
a9440d6844 |
11 changed files with 382 additions and 221 deletions
BIN
posts/img/post-index-build-script.webm
Normal file
BIN
posts/img/post-index-build-script.webm
Normal file
Binary file not shown.
BIN
posts/img/systemsobscure-forgejo-runners.png
Normal file
BIN
posts/img/systemsobscure-forgejo-runners.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 227 KiB |
|
@ -10,8 +10,10 @@ my time at the BBC. I was fortunate to have a really supportive and personable
|
|||
manager who was great to work with and who took a real interest in my
|
||||
professional development.
|
||||
|
||||

|
||||
|
||||
<div style="display:flex; flex-direction: row; justify-content: center; margin:
|
||||
1rem 0;">
|
||||
<img src="./img/bbc-leave-gruv-small.png" width="300" />
|
||||
</div>
|
||||
I worked with a variety of stakeholders on applications used internally within
|
||||
the Corporation. I was able to work across the frontend and the backend in a
|
||||
"full-stack" capacity, enhancing my understanding of AWS serverless, Docker,
|
||||
|
|
136
posts/self-hosting-this-site.md
Normal file
136
posts/self-hosting-this-site.md
Normal file
|
@ -0,0 +1,136 @@
|
|||
---
|
||||
title: "Self-hosting this site"
|
||||
slug: /self-hosting-this-site/
|
||||
date: 2025-07-21
|
||||
tags: ["self-hosting"]
|
||||
---
|
||||
|
||||
[Previously](https://systemsobscure.blog/posts/how-I-deploy-this-site) this site
|
||||
was deployed as follows. I used [Gatsby.js](https://gatsbyjs.com) to generate a
|
||||
static website from React JavaScript. When changes to the `main` branch were
|
||||
pushed to the remote GitHub repository, a GitHub action would execute, building
|
||||
the source files and transferring them to an S3 bucket. The HTML generated was
|
||||
then served using AWS Cloud Front.
|
||||
|
||||
As I am now trying to self-host as many services as I can, it was time remove
|
||||
the dependency on AWS and GitHub and instead serve the site from my VPS and
|
||||
manage deployment via my
|
||||
[self-hosted Forgejo instance](https://forgejo.systemsobscure.net/thomasabishop).
|
||||
|
||||
As part of this process I went on a bit of a side-quest to rebuild the site
|
||||
without Gatsby.
|
||||
|
||||
When I tried to update to the lastest version of Gatsby I faced several problems
|
||||
mostly due to Gatsby trying to foist server-side rendering on every project. For
|
||||
such a small blog site with next to zero traffic this is unnecessary. Moreover,
|
||||
I realised that I could also do without all the bloat that Gatsby adds via its
|
||||
plugin ecosystem. (Perhaps if frontend bundles weren't so large, SSR wouldn't be
|
||||
needed lol.)
|
||||
|
||||
So I decided to simplify the blog and build it as a [Vite](https://vite.dev)
|
||||
React application that gets its content from a JSON index that is generated via
|
||||
a pre-build script.
|
||||
|
||||
I
|
||||
[wrote a script](https://forgejo.systemsobscure.net/thomasabishop/systems-obscure/src/branch/main/scripts/generate-post-index.js)
|
||||
in JavaScript that loops through all the blog posts in Markdown format from a
|
||||
`/posts` directory at the project root. It creates a JSON array with an entry
|
||||
for each post. For example, this post would be represented as follows:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"slug": "self-hosting-this-site",
|
||||
"title": "Self-hosting this site",
|
||||
"date": "2025-07-22T00:00:00.00Z",
|
||||
"tags": ["self-hosting"],
|
||||
"html": "<p>Previously this site was..."
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
To convert the raw markdown to HTML I used
|
||||
[marked](https://github.com/markedjs/marked). I also added the following steps:
|
||||
|
||||
- convert `<code>` tags into syntax highlighted blocks using
|
||||
[shiki](https://shiki.style/)
|
||||
- compress and resize images and then transfer them to the `public/` directory
|
||||
where Vite sources static assets when deployed
|
||||
- find and replace all image `src` attributes in the resulting HTML with the
|
||||
`public/` file path
|
||||
|
||||
When I run `npm run build:posts` this generates the `post-index.json` file and
|
||||
saves it to `/public` so that the React application can read from it when
|
||||
rendering the site content.
|
||||
|
||||
<figure style="margin: 1rem 0">
|
||||
<video controls>
|
||||
<source src="./img/post-index-build-script.webm" type="video/webm">
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
<figcaption>Running the pre-build script to generate the site content.</figcaption>
|
||||
</figure>
|
||||
|
||||
I created a custom hook called `usePosts` that fetches the index and saves it to
|
||||
the session storage to avoid unnecessary network requests. You can see this
|
||||
being invoked in the
|
||||
[template component for blog posts](https://forgejo.systemsobscure.net/thomasabishop/systems-obscure/src/commit/43eec03edd2e6330362cc5605a48a91387eb49d3/src/templates/BlogTemplate.tsx).
|
||||
|
||||
Having rebuilt the site without the dependency on Gatsby, the next step was to
|
||||
deploy it.
|
||||
|
||||
I wanted to retain the "push and deploy" model that I previously achieved via
|
||||
the GitHub Action. This was easy because the syntax for Forgejo Actions is
|
||||
practically identical. You place the YAML declaration in the `.forejo/`
|
||||
directory of the project and Forejo picks it up on each push to the remote.
|
||||
|
||||
Here's the file:
|
||||
|
||||
```yaml
|
||||
name: Deploy Blog
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
container: node:18
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- run: npm install
|
||||
- run: npm run build:posts
|
||||
- run: npm run build
|
||||
- run: |
|
||||
echo "${{ secrets.SSH_FORGEJO_KEY }}" > /tmp/ssh_key
|
||||
chmod 600 /tmp/ssh_key
|
||||
ssh -i /tmp/ssh_key -o StrictHostKeyChecking=no ${{ vars.VPS_USER }} "bash -c 'rm -rf /var/www/systemsobscure.blog/*'"
|
||||
scp -i /tmp/ssh_key -o StrictHostKeyChecking=no -r dist/* ${{ vars.VPS_USER }}:/var/www/systemsobscure.blog/
|
||||
rm /tmp/ssh_key
|
||||
```
|
||||
|
||||
Before compiling the build I first run the pre-build script to generate the post
|
||||
index. Then I remove the existing source files for the site in `var/www/` over
|
||||
`ssh` and then transfer the new source files using `scp`. (While I run most
|
||||
third-party services on my VPS via Docker, I prefer to keep it simple with my
|
||||
own applications and not use containers.)
|
||||
|
||||

|
||||
|
||||
In order for the site to be served from `var/www/systemsobscure.blog`, I had to
|
||||
give my Docker instance of nginx running on the VPS access to this directory by
|
||||
[adding it to the volume mappings](https://forgejo.systemsobscure.net/thomasabishop/self-host/commit/8c380b71735f278f4309bbd14ad96cb9a29104b7)
|
||||
in the `docker-compose.`
|
||||
|
||||
Then I added an
|
||||
[nginx `.conf` file for the blog](https://forgejo.systemsobscure.net/thomasabishop/self-host/src/branch/main/proxy/nginx/conf.d/systemsobscure.conf).
|
||||
This file specifies the SSL certificate to use and sets `index.html` as the
|
||||
document root. It also adds some default caching and compression, along with
|
||||
security headers.
|
||||
|
||||
Finally I
|
||||
[updated my SSL certificate generation script](https://forgejo.systemsobscure.net/thomasabishop/self-host/commit/61cdbe43c2041d5961ef74416270794eb6fb91c6)
|
||||
to include `systemsobscure.blog`.
|
||||
|
||||
And that's it. My personal website is now self-hosted on my VPS and is
|
||||
automatically deployed via pushes to my self-hosted Git forge. No more GitHub,
|
||||
no more AWS.
|
|
@ -15,23 +15,27 @@ const processBlogImages = async () => {
|
|||
fs.mkdirSync(destDir, { recursive: true })
|
||||
}
|
||||
|
||||
// Copy all images
|
||||
const files = fs.readdirSync(srcDir)
|
||||
const imageExtensions = [".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg"]
|
||||
const imageExtensions = [
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".png",
|
||||
".gif",
|
||||
".webp",
|
||||
".svg",
|
||||
".webm",
|
||||
]
|
||||
|
||||
// Use for...of loop instead of forEach to work with async/await
|
||||
for (const file of files) {
|
||||
const ext = path.extname(file).toLowerCase()
|
||||
if (imageExtensions.includes(ext)) {
|
||||
const inputPath = path.join(srcDir, file) // Define inputPath and outputPath
|
||||
const outputPath = path.join(destDir, file)
|
||||
|
||||
if (ext === ".svg") {
|
||||
// SVGs just get copied
|
||||
if (ext === ".svg" || ext === ".webm") {
|
||||
fs.copyFileSync(inputPath, outputPath)
|
||||
console.info(`📸 Copied ${file}`)
|
||||
} else {
|
||||
// Process other images
|
||||
await sharp(inputPath)
|
||||
.resize(1200, 800, {
|
||||
fit: "inside",
|
||||
|
|
|
@ -65,7 +65,7 @@ const Menu = () => {
|
|||
const Header = () => {
|
||||
const { theme, setTheme } = useTheme()
|
||||
return (
|
||||
<header className="w-full h-12 flex items-center justify-center border-b fixed top-0 z-20 bg-background">
|
||||
<header className="w-full h-12 flex items-center justify-center border-b fixed top-0 z-20 bg-sidebar">
|
||||
<div className="w-full px-0 md:px-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" asChild>
|
||||
|
|
|
@ -13,8 +13,8 @@ import { convertDate } from "@/utils/convertDate"
|
|||
const PostListing = ({ posts, title, showAllButton }) => {
|
||||
return (
|
||||
<>
|
||||
<div className="mb-5 ">
|
||||
<h2 className="scroll-m-20 text-2xl font-semibold lg:text-2xl border-b pb-3">
|
||||
<div className="mb-5">
|
||||
<h2 className="scroll-m-20 text-[1.3rem] font-semibold border-b pb-2">
|
||||
{title}
|
||||
</h2>
|
||||
</div>
|
||||
|
@ -44,7 +44,7 @@ const PostListing = ({ posts, title, showAllButton }) => {
|
|||
))}
|
||||
</div>
|
||||
{showAllButton && (
|
||||
<Button asChild variant="" className="w-full mt-4">
|
||||
<Button asChild variant="secondary" className="w-full mt-4">
|
||||
<Link to="/posts/page/1">View all</Link>
|
||||
</Button>
|
||||
)}
|
||||
|
|
|
@ -2,109 +2,109 @@ import MainTemplate from "@/templates/MainTemplate"
|
|||
import portrait from "../images/portrait-compressed.jpg"
|
||||
|
||||
const AboutPage = () => {
|
||||
return (
|
||||
<MainTemplate>
|
||||
<div className="mb-5 ">
|
||||
<h2 className="scroll-m-20 text-2xl font-semibold lg:text-2xl border-b pb-3">
|
||||
About
|
||||
</h2>
|
||||
</div>
|
||||
return (
|
||||
<MainTemplate>
|
||||
<div className="mb-5 ">
|
||||
<h2 className="scroll-m-20 text-2xl font-semibold lg:text-2xl border-b pb-3">
|
||||
About
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<figure className="w-full flex flex-col items-center mb-6">
|
||||
<img
|
||||
alt="A portrait of the blog author"
|
||||
src={portrait}
|
||||
className="w-0 flex"
|
||||
/>
|
||||
<figcaption className="text-sm text-muted-foreground mt-3 text-center">
|
||||
Pictured with the WITCH computer at the{" "}
|
||||
<a
|
||||
href="https://www.tnmoc.org/"
|
||||
target="_blank"
|
||||
className="text-primary hover:text-primary/80"
|
||||
>
|
||||
National Museum of Computing
|
||||
</a>
|
||||
, Bletchley Park.
|
||||
</figcaption>
|
||||
</figure>
|
||||
<p className="leading-[1.5] [&:not(:first-child)]:mt-6">
|
||||
I'm a self-taught software engineer based on the south coast of England.
|
||||
This blog is my technical scrapbook. I document the details of my
|
||||
technical life so I can have a record of progress when I look back.{" "}
|
||||
</p>
|
||||
<figure className="w-full flex flex-col items-center mb-6">
|
||||
<img
|
||||
alt="A portrait of the blog author"
|
||||
src={portrait}
|
||||
className="w-0 flex"
|
||||
/>
|
||||
<figcaption className="text-sm text-muted-foreground mt-3 text-center">
|
||||
Pictured with the WITCH computer at the{" "}
|
||||
<a
|
||||
href="https://www.tnmoc.org/"
|
||||
target="_blank"
|
||||
className="text-primary hover:text-primary/80"
|
||||
>
|
||||
National Museum of Computing
|
||||
</a>
|
||||
, Bletchley Park.
|
||||
</figcaption>
|
||||
</figure>
|
||||
<p className="leading-[1.6] [&:not(:first-child)]:mt-6">
|
||||
I'm a self-taught software engineer based on the south coast of England.
|
||||
This blog is my technical scrapbook. I document the details of my
|
||||
technical life so I can have a record of progress when I look back.{" "}
|
||||
</p>
|
||||
|
||||
<p className="leading-[1.5] [&:not(:first-child)]:mt-6">
|
||||
I completed a degree in Philosophy at the University of Warwick (2009)
|
||||
and hold a Postgraduate Certificate of Education (2011).
|
||||
</p>
|
||||
<p className="leading-[1.5] [&:not(:first-child)]:mt-6">
|
||||
Currently I work for{" "}
|
||||
<a
|
||||
href="https://en.wikipedia.org/wiki/ITV_(TV_network)"
|
||||
target="_blank"
|
||||
className="underline decoration-1 hover:text-primary/80 underline-offset-2"
|
||||
>
|
||||
ITV
|
||||
</a>{" "}
|
||||
{""}
|
||||
as a backend software engineer. Before that, I worked as a full-stack
|
||||
engineer at the{" "}
|
||||
<a
|
||||
href="https://en.wikipedia.org/wiki/BBC"
|
||||
target="_blank"
|
||||
className="underline decoration-1 hover:text-primary/80 underline-offset-2"
|
||||
>
|
||||
BBC
|
||||
</a>{" "}
|
||||
and as a frontend engineer at{" "}
|
||||
<a
|
||||
href="https://www.arria.com/"
|
||||
target="_blank"
|
||||
className="underline decoration-1 hover:text-primary/80 underline-offset-2"
|
||||
>
|
||||
Arria NLG
|
||||
</a>{" "}
|
||||
and in several web developer roles. Before software I was a
|
||||
teacher.{" "}
|
||||
</p>
|
||||
<p className="leading-[1.6] [&:not(:first-child)]:mt-6">
|
||||
I completed a degree in Philosophy at the University of Warwick (2009)
|
||||
and hold a Postgraduate Certificate of Education (2011).
|
||||
</p>
|
||||
<p className="leading-[1.6] [&:not(:first-child)]:mt-6">
|
||||
Currently I work for{" "}
|
||||
<a
|
||||
href="https://en.wikipedia.org/wiki/ITV_(TV_network)"
|
||||
target="_blank"
|
||||
className="underline decoration-1 hover:text-primary/80 underline-offset-2"
|
||||
>
|
||||
ITV
|
||||
</a>{" "}
|
||||
{""}
|
||||
as a backend software engineer. Before that, I worked as a full-stack
|
||||
engineer at the{" "}
|
||||
<a
|
||||
href="https://en.wikipedia.org/wiki/BBC"
|
||||
target="_blank"
|
||||
className="underline decoration-1 hover:text-primary/80 underline-offset-2 font-medium"
|
||||
>
|
||||
BBC
|
||||
</a>{" "}
|
||||
and as a frontend engineer at{" "}
|
||||
<a
|
||||
href="https://www.arria.com/"
|
||||
target="_blank"
|
||||
className="underline decoration-1 hover:text-primary/80 underline-offset-2"
|
||||
>
|
||||
Arria NLG
|
||||
</a>{" "}
|
||||
and in several web developer roles. Before software I was a
|
||||
teacher.{" "}
|
||||
</p>
|
||||
|
||||
<p className="leading-[1.5] [&:not(:first-child)]:mt-6">
|
||||
Some things I like:
|
||||
<ul className="pt-2">
|
||||
<li className="mb-1">🐶 Staffies and other bull-breeds</li>
|
||||
<li className="mb-1">🎼 Classical music (Haydn, Mozart, JSB)</li>
|
||||
<li className="mb-1">🛸 Science fiction </li>
|
||||
</ul>
|
||||
</p>
|
||||
<p className="leading-[1.6] [&:not(:first-child)]:mt-6">
|
||||
Some things I like:
|
||||
<ul className="pt-2">
|
||||
<li className="mb-1">🐶 Staffies and other bull-breeds</li>
|
||||
<li className="mb-1">🎼 Classical music (Haydn, Mozart, JSB)</li>
|
||||
<li className="mb-1">🛸 Science fiction </li>
|
||||
</ul>
|
||||
</p>
|
||||
|
||||
<p className="leading-[1.5] [&:not(:first-child)]:mt-6">
|
||||
Some things I'm interested in:
|
||||
<ul className="pt-2">
|
||||
<li className="mb-1">🧑💻 Self-hosting and digital resiliance</li>
|
||||
<li className="mb-1">🖳 The history of computing and networks</li>
|
||||
<li className="mb-1">🇮🇪 Irish history and culture</li>
|
||||
<li className="mb-1">☸️ Buddhism</li>
|
||||
{/*
|
||||
<p className="leading-[1.6] [&:not(:first-child)]:mt-6">
|
||||
Some things I'm interested in:
|
||||
<ul className="pt-2">
|
||||
<li className="mb-1">🧑💻 Self-hosting and digital resiliance</li>
|
||||
<li className="mb-1">🖳 The history of computing and networks</li>
|
||||
<li className="mb-1">🇮🇪 Irish history and culture</li>
|
||||
<li className="mb-1">☸️ Buddhism</li>
|
||||
{/*
|
||||
|
||||
<li className="mb-1">📡 Civil communications infrastructure</li>
|
||||
*/}
|
||||
</ul>
|
||||
</p>
|
||||
</ul>
|
||||
</p>
|
||||
|
||||
<p className="leading-[1.5] [&:not(:first-child)]:mt-6">
|
||||
I self-host my own Git forge at{" "}
|
||||
<a
|
||||
href="https://forgejo.systemsobscure.net/thomasabishop"
|
||||
className="underline decoration-1 hover:text-primary/80 underline-offset-2"
|
||||
>
|
||||
forgejo.systemsobscure.net
|
||||
</a>{" "}
|
||||
rather than use Microsoft GitHub. You can view my personal projects
|
||||
there.
|
||||
</p>
|
||||
</MainTemplate>
|
||||
)
|
||||
<p className="leading-[1.6] [&:not(:first-child)]:mt-6">
|
||||
I self-host my own Git forge at{" "}
|
||||
<a
|
||||
href="https://forgejo.systemsobscure.net/thomasabishop"
|
||||
className="underline decoration-1 hover:text-primary/80 underline-offset-2"
|
||||
>
|
||||
forgejo.systemsobscure.net
|
||||
</a>{" "}
|
||||
rather than use Microsoft GitHub. You can view my personal projects
|
||||
there.
|
||||
</p>
|
||||
</MainTemplate>
|
||||
)
|
||||
}
|
||||
|
||||
export { AboutPage }
|
||||
|
|
|
@ -9,14 +9,25 @@ const HomePage = () => {
|
|||
const { posts } = usePosts()
|
||||
return (
|
||||
<MainTemplate>
|
||||
<Card className="mb-8 rounded-none">
|
||||
<div className="mb-7 border border-foreground py-7 px-6 dark:bg-sidebar">
|
||||
<h1 className="scroll-m-20 text-left text-2xl font-semibold">
|
||||
Another software engineer with a blog
|
||||
</h1>
|
||||
<p className="leading-[1.7] [&:not(:first-child)]:mt-5">
|
||||
I'm a self-taught software engineer currently working at ITV,
|
||||
previously at the BBC. This blog is a technical scrapbook and digital
|
||||
garden.
|
||||
</p>
|
||||
</div>
|
||||
{/*
|
||||
<Card className="mb-8 rounded-none">
|
||||
<CardHeader>
|
||||
<h1 className="scroll-m-20 text-left text-3xl font-semibold">
|
||||
Another software engineer with a blog
|
||||
<h1 className="scroll-m-20 text-left text-2xl font-semibold">
|
||||
Another software engineer with a blog!
|
||||
</h1>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="leading-[1.5] [&:not(:first-child)]:mt-6">
|
||||
<p className="leading-[1.7] [&:not(:first-child)]:mt-7">
|
||||
I'm a self-taught software engineer currently working at ITV,
|
||||
previously at the BBC. This blog is a technical scrapbook and
|
||||
digital garden.
|
||||
|
@ -28,6 +39,13 @@ const HomePage = () => {
|
|||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
<Button asChild>
|
||||
<Link to="/about">About</Link>
|
||||
</Button>
|
||||
|
||||
|
||||
|
||||
*/}
|
||||
|
||||
<PostListing
|
||||
title="Recent posts"
|
||||
|
|
|
@ -4,128 +4,129 @@ import MainTemplate from "@/templates/MainTemplate"
|
|||
import { useParams, useNavigate } from "react-router"
|
||||
import { convertDate } from "@/utils/convertDate"
|
||||
import {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationItem,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationItem,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
} from "@/components/ui/pagination"
|
||||
import {
|
||||
Card,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Card,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Link } from "react-router"
|
||||
import { usePosts } from "@/hooks/usePosts"
|
||||
|
||||
const PostsPage = () => {
|
||||
const { page } = useParams()
|
||||
const { page } = useParams()
|
||||
|
||||
const navigate = useNavigate()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const { posts } = usePosts()
|
||||
const postsPerPage = 10
|
||||
const { posts } = usePosts()
|
||||
const postsPerPage = 8
|
||||
|
||||
const currentPage = Number(page) || 1
|
||||
const totalPages = Math.ceil(posts.length / postsPerPage)
|
||||
const currentPage = Number(page) || 1
|
||||
const totalPages = Math.ceil(posts.length / postsPerPage)
|
||||
|
||||
useEffect(() => {
|
||||
if (currentPage < 1 || currentPage > totalPages) {
|
||||
navigate(`/posts/page/1`, { replace: true })
|
||||
}
|
||||
}, [currentPage, totalPages, navigate])
|
||||
useEffect(() => {
|
||||
// Only redirect if we have posts and the page is definitively invalid
|
||||
if (totalPages > 0 && (currentPage < 1 || currentPage > totalPages)) {
|
||||
navigate(`/posts/page/1`, { replace: true })
|
||||
}
|
||||
}, [currentPage, totalPages, navigate])
|
||||
|
||||
const currentPosts = useMemo(() => {
|
||||
const startIndex = (currentPage - 1) * postsPerPage
|
||||
const endIndex = startIndex + postsPerPage
|
||||
return posts.slice(startIndex, endIndex)
|
||||
}, [posts, currentPage, postsPerPage])
|
||||
const currentPosts = useMemo(() => {
|
||||
const startIndex = (currentPage - 1) * postsPerPage
|
||||
const endIndex = startIndex + postsPerPage
|
||||
return posts.slice(startIndex, endIndex)
|
||||
}, [posts, currentPage, postsPerPage])
|
||||
|
||||
const hasNextPage = currentPage < totalPages
|
||||
const hasPrevPage = currentPage > 1
|
||||
const hasNextPage = currentPage < totalPages
|
||||
const hasPrevPage = currentPage > 1
|
||||
|
||||
const goToNextPage = () => {
|
||||
if (hasNextPage) {
|
||||
navigate(`/posts/page/${currentPage + 1}`)
|
||||
}
|
||||
}
|
||||
const goToNextPage = () => {
|
||||
if (hasNextPage) {
|
||||
navigate(`/posts/page/${currentPage + 1}`)
|
||||
}
|
||||
}
|
||||
|
||||
const goToPrevPage = () => {
|
||||
if (hasPrevPage) {
|
||||
navigate(`/posts/page/${currentPage - 1}`)
|
||||
}
|
||||
}
|
||||
const goToPrevPage = () => {
|
||||
if (hasPrevPage) {
|
||||
navigate(`/posts/page/${currentPage - 1}`)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<MainTemplate>
|
||||
<div className="mb-5 ">
|
||||
<h2 className="scroll-m-20 text-2xl font-semibold lg:text-2xl border-b pb-3">
|
||||
All posts
|
||||
</h2>
|
||||
</div>
|
||||
<div className="min-h-[calc(100vh-200px)] flex flex-col">
|
||||
<div className="grid grid-cols-1 md:grid-cols-1 gap-3 flex-grow">
|
||||
{currentPosts.map((post) => (
|
||||
<Link
|
||||
to={`/posts/${post.slug}`}
|
||||
key={post.slug}
|
||||
className="block no-underline"
|
||||
>
|
||||
<Card
|
||||
key={post.slug}
|
||||
className="flex flex-col h-full hover:bg-primary/5 py-4 px-0 rounded-none"
|
||||
>
|
||||
<CardHeader>
|
||||
<CardTitle className="leading-snug font-semibold">
|
||||
{post.title}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-sm text-muted-foreground">
|
||||
<div className="flex justify-between gap-2">
|
||||
<span className="text-sm">{convertDate(post.date)}</span>
|
||||
<div className="hidden md:block">
|
||||
{post.tags.map((tag, i) => (
|
||||
<Badge
|
||||
className="ml-2 cursor-pointer"
|
||||
key={i}
|
||||
variant="secondary"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
navigate(`/tags/${tag}`)
|
||||
}}
|
||||
>
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<Pagination className="mt-4">
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<PaginationPrevious
|
||||
className={`select-none ${hasPrevPage ? "cursor-pointer" : "cursor-not-allowed opacity-50"}`}
|
||||
onClick={goToPrevPage}
|
||||
/>
|
||||
</PaginationItem>
|
||||
<PaginationItem>
|
||||
<PaginationNext
|
||||
className={`select-none ${hasNextPage ? "cursor-pointer" : "cursor-not-allowed opacity-50"}`}
|
||||
onClick={goToNextPage}
|
||||
/>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
</div>
|
||||
</MainTemplate>
|
||||
)
|
||||
return (
|
||||
<MainTemplate>
|
||||
<div className="mb-5 ">
|
||||
<h2 className="scroll-m-20 text-2xl font-semibold lg:text-2xl border-b pb-3">
|
||||
All posts
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<div className="grid grid-cols-1 md:grid-cols-1 gap-3 flex-grow">
|
||||
{currentPosts.map((post) => (
|
||||
<Link
|
||||
to={`/posts/${post.slug}`}
|
||||
key={post.slug}
|
||||
className="block no-underline"
|
||||
>
|
||||
<Card
|
||||
key={post.slug}
|
||||
className="flex flex-col h-full hover:bg-primary/5 py-4 px-0 rounded-none"
|
||||
>
|
||||
<CardHeader>
|
||||
<CardTitle className="leading-snug font-semibold">
|
||||
{post.title}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-sm text-muted-foreground">
|
||||
<div className="flex justify-between gap-2">
|
||||
<span className="text-sm">{convertDate(post.date)}</span>
|
||||
<div className="hidden md:block">
|
||||
{post.tags.map((tag, i) => (
|
||||
<Badge
|
||||
className="ml-2 cursor-pointer"
|
||||
key={i}
|
||||
variant="secondary"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
navigate(`/tags/${tag}`)
|
||||
}}
|
||||
>
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<Pagination className="mt-4">
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<PaginationPrevious
|
||||
className={`select-none ${hasPrevPage ? "cursor-pointer" : "cursor-not-allowed opacity-50"}`}
|
||||
onClick={goToPrevPage}
|
||||
/>
|
||||
</PaginationItem>
|
||||
<PaginationItem>
|
||||
<PaginationNext
|
||||
className={`select-none ${hasNextPage ? "cursor-pointer" : "cursor-not-allowed opacity-50"}`}
|
||||
onClick={goToNextPage}
|
||||
/>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
</div>
|
||||
</MainTemplate>
|
||||
)
|
||||
}
|
||||
|
||||
export { PostsPage }
|
||||
|
|
|
@ -37,10 +37,10 @@ const BlogTemplate = () => {
|
|||
</div>
|
||||
<div
|
||||
className="
|
||||
[&>h2]:text-xl [&>h2]:border-b [&>h2]:pb-2 [&>h2]:font-semibold [&>h2]:first:mt-0 [&>h2:not(:first-child)]:mt-8
|
||||
[&>h2]:text-xl [&>h2]:font-semibold [&>h2]:first:mt-0 [&>h2:not(:first-child)]:mt-8
|
||||
[&>h3]:text-xl [&>h3]:sm:text-1xl [&>h3]:font-semibold [&>h3:not(:first-child)]:mt-5
|
||||
[&>h4]:text-lg [&>h4]:sm:text-xl [&>h4]:font-semibold [&>h4:not(:first-child)]:mt-4
|
||||
[&>p]:leading-7 [&>p:not(:first-child)]:mt-6
|
||||
[&>p]:leading-7 [&>p:not(:first-child)]:mt-4
|
||||
[&>p+:is(h1,h2,h3,h4,h5,h6)]:mt-6
|
||||
[&>blockquote]:mt-4 [&>blockquote]:border-l-2 [&>blockquote]:pl-6 [&>blockquote]:text-muted-foreground [&>blockquote]:text-sm
|
||||
[&>ul]:my-4 [&>ul]:ml-6 [&>ul]:list-disc [&>ul>li]:mt-2
|
||||
|
@ -57,10 +57,10 @@ const BlogTemplate = () => {
|
|||
[&>p+pre]:mt-6
|
||||
[&>pre+p]:mt-6
|
||||
[&>ul+pre]:mt-6
|
||||
[&>li]:leading-[1.5]
|
||||
[&>li]:leading-[1.6]
|
||||
[&_li_code]:relative [&_li_code]:rounded [&_li_code]:bg-muted [&_li_code]:px-[0.3rem] [&_li_code]:py-[0.2rem] [&_li_code]:font-mono [&_li_code]:text-sm [&_li_code]:font-normal
|
||||
[&>p]:mt-6 [&>p]:leading-[1.5]
|
||||
[&_a]:underline [&_a]:underline-offset-4 [&_a]:hover:text-muted-foreground
|
||||
[&>p]:mt-6 [&>p]:leading-[1.6]
|
||||
[&_a]:underline [&_a]:underline-offset-4 [&_a]:hover:text-muted-foreground [&_a]:font-medium
|
||||
[&>figure]:w-full [&>figure]:flex [&>figure]:flex-col [&>figure]:items-center [&>figure]:justify-center [&>figure]:mb-6 [&>figure]:mx-auto
|
||||
[&>figure>img]:w-full
|
||||
[&>figure>figcaption]:text-sm [&>figure>figcaption]:text-muted-foreground [&>figure>figcaption]:mt-3 [&>figure>figcaption]:text-center
|
||||
|
|
Loading…
Add table
Reference in a new issue