blog: Bump to 0.0.1 version.
Browse files- .gitattributes +1 -1
- Dockerfile +1 -1
- package.json +17 -17
- src/client/App.tsx +14 -8
- src/client/components/blog/PostCard.tsx +36 -5
- src/client/components/blog/PostContent.tsx +103 -18
- src/client/components/blog/PostList.tsx +7 -2
- src/client/components/layout/Footer.tsx +11 -3
- src/client/components/layout/Header.tsx +142 -16
- src/client/components/layout/Layout.tsx +14 -2
- src/client/contexts/PageContext.tsx +45 -0
- src/client/contexts/ThemeContext.tsx +105 -0
- src/client/hooks/usePosts.ts +2 -2
- src/client/pages/HomePage.tsx +149 -27
- src/client/pages/PostPage.tsx +37 -20
- src/client/styles/global.css +62 -29
- src/server/routes/apiRoutes.ts +2 -1
- src/shared/config.ts +5 -2
.gitattributes
CHANGED
|
@@ -1 +1 @@
|
|
| 1 |
-
public/assets/images/profile.png filter=lfs diff=lfs merge=lfs -text
|
|
|
|
| 1 |
+
public/assets/images/profile.png filter=lfs diff=lfs merge=lfs -text
|
Dockerfile
CHANGED
|
@@ -3,7 +3,7 @@
|
|
| 3 |
# SPDX-License-Identifier: Apache-2.0
|
| 4 |
#
|
| 5 |
|
| 6 |
-
FROM node:
|
| 7 |
|
| 8 |
WORKDIR /app
|
| 9 |
|
|
|
|
| 3 |
# SPDX-License-Identifier: Apache-2.0
|
| 4 |
#
|
| 5 |
|
| 6 |
+
FROM node:22-alpine
|
| 7 |
|
| 8 |
WORKDIR /app
|
| 9 |
|
package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
{
|
| 2 |
"name": "personal-blog",
|
| 3 |
-
"version": "0.0.
|
| 4 |
"private": true,
|
| 5 |
"author": "Hadad Darajat",
|
| 6 |
"type": "module",
|
|
@@ -15,29 +15,29 @@
|
|
| 15 |
"preview": "npm run build && npm run start"
|
| 16 |
},
|
| 17 |
"dependencies": {
|
| 18 |
-
"chokidar": "^
|
| 19 |
"cors": "^2.8.5",
|
| 20 |
-
"express": "^4.21.
|
| 21 |
"front-matter": "^4.0.2",
|
| 22 |
-
"highlight.js": "^11.
|
| 23 |
-
"lucide-react": "^0.
|
| 24 |
-
"marked": "^
|
| 25 |
-
"marked-highlight": "^2.
|
| 26 |
"react": "^18.3.1",
|
| 27 |
"react-dom": "^18.3.1",
|
| 28 |
"react-helmet-async": "^2.0.5",
|
| 29 |
-
"react-router-dom": "^
|
| 30 |
},
|
| 31 |
"devDependencies": {
|
| 32 |
"@types/cors": "^2.8.17",
|
| 33 |
-
"@types/express": "^
|
| 34 |
-
"@types/node": "^22.
|
| 35 |
-
"@types/react": "^18.3.
|
| 36 |
-
"@types/react-dom": "^18.3.
|
| 37 |
-
"@vitejs/plugin-react": "^4.3.
|
| 38 |
-
"concurrently": "^9.
|
| 39 |
-
"tsx": "^4.19.
|
| 40 |
-
"typescript": "^5.
|
| 41 |
-
"vite": "^
|
| 42 |
}
|
| 43 |
}
|
|
|
|
| 1 |
{
|
| 2 |
"name": "personal-blog",
|
| 3 |
+
"version": "0.0.1",
|
| 4 |
"private": true,
|
| 5 |
"author": "Hadad Darajat",
|
| 6 |
"type": "module",
|
|
|
|
| 15 |
"preview": "npm run build && npm run start"
|
| 16 |
},
|
| 17 |
"dependencies": {
|
| 18 |
+
"chokidar": "^4.0.3",
|
| 19 |
"cors": "^2.8.5",
|
| 20 |
+
"express": "^4.21.2",
|
| 21 |
"front-matter": "^4.0.2",
|
| 22 |
+
"highlight.js": "^11.11.1",
|
| 23 |
+
"lucide-react": "^0.469.0",
|
| 24 |
+
"marked": "^15.0.6",
|
| 25 |
+
"marked-highlight": "^2.2.1",
|
| 26 |
"react": "^18.3.1",
|
| 27 |
"react-dom": "^18.3.1",
|
| 28 |
"react-helmet-async": "^2.0.5",
|
| 29 |
+
"react-router-dom": "^7.1.1"
|
| 30 |
},
|
| 31 |
"devDependencies": {
|
| 32 |
"@types/cors": "^2.8.17",
|
| 33 |
+
"@types/express": "^5.0.0",
|
| 34 |
+
"@types/node": "^22.10.5",
|
| 35 |
+
"@types/react": "^18.3.18",
|
| 36 |
+
"@types/react-dom": "^18.3.5",
|
| 37 |
+
"@vitejs/plugin-react": "^4.3.4",
|
| 38 |
+
"concurrently": "^9.1.2",
|
| 39 |
+
"tsx": "^4.19.2",
|
| 40 |
+
"typescript": "^5.7.2",
|
| 41 |
+
"vite": "^6.0.7"
|
| 42 |
}
|
| 43 |
}
|
src/client/App.tsx
CHANGED
|
@@ -4,19 +4,25 @@
|
|
| 4 |
//
|
| 5 |
|
| 6 |
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
|
|
|
|
|
|
| 7 |
import { Layout } from "./components/layout/Layout";
|
| 8 |
import { HomePage } from "./pages/HomePage";
|
| 9 |
import { PostPage } from "./pages/PostPage";
|
| 10 |
|
| 11 |
export const App = (): JSX.Element => {
|
| 12 |
return (
|
| 13 |
-
<
|
| 14 |
-
<
|
| 15 |
-
<
|
| 16 |
-
<
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
);
|
| 22 |
};
|
|
|
|
| 4 |
//
|
| 5 |
|
| 6 |
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
| 7 |
+
import { ThemeProvider } from "./contexts/ThemeContext";
|
| 8 |
+
import { PageProvider } from "./contexts/PageContext";
|
| 9 |
import { Layout } from "./components/layout/Layout";
|
| 10 |
import { HomePage } from "./pages/HomePage";
|
| 11 |
import { PostPage } from "./pages/PostPage";
|
| 12 |
|
| 13 |
export const App = (): JSX.Element => {
|
| 14 |
return (
|
| 15 |
+
<ThemeProvider>
|
| 16 |
+
<PageProvider>
|
| 17 |
+
<BrowserRouter>
|
| 18 |
+
<Layout>
|
| 19 |
+
<Routes>
|
| 20 |
+
<Route path="/" element={<HomePage />} />
|
| 21 |
+
<Route path="/post/:slug" element={<PostPage />} />
|
| 22 |
+
</Routes>
|
| 23 |
+
</Layout>
|
| 24 |
+
</BrowserRouter>
|
| 25 |
+
</PageProvider>
|
| 26 |
+
</ThemeProvider>
|
| 27 |
);
|
| 28 |
};
|
src/client/components/blog/PostCard.tsx
CHANGED
|
@@ -4,13 +4,14 @@
|
|
| 4 |
//
|
| 5 |
|
| 6 |
import { Link } from "react-router-dom";
|
| 7 |
-
import { Calendar, User, ArrowRight } from "lucide-react";
|
| 8 |
import { siteConfig } from "@shared/config";
|
| 9 |
import type { PostSummary } from "@shared/types";
|
| 10 |
import type { CSSProperties } from "react";
|
| 11 |
|
| 12 |
interface PostCardProps {
|
| 13 |
post: PostSummary;
|
|
|
|
| 14 |
}
|
| 15 |
|
| 16 |
const cardStyle: CSSProperties = {
|
|
@@ -20,16 +21,40 @@ const cardStyle: CSSProperties = {
|
|
| 20 |
padding: "var(--spacing-lg)",
|
| 21 |
border: "1px solid var(--color-border)",
|
| 22 |
textDecoration: "none",
|
| 23 |
-
transition:
|
| 24 |
-
|
| 25 |
};
|
| 26 |
|
| 27 |
const titleStyle: CSSProperties = {
|
| 28 |
fontSize: "var(--font-size-xl)",
|
| 29 |
fontWeight: 600,
|
| 30 |
color: "var(--color-text-primary)",
|
|
|
|
| 31 |
marginBottom: "var(--spacing-sm)",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
lineHeight: 1.3,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
};
|
| 34 |
|
| 35 |
const descriptionStyle: CSSProperties = {
|
|
@@ -72,10 +97,16 @@ const formatDate = (dateString: string): string => {
|
|
| 72 |
return date.toLocaleDateString(siteConfig.locale, siteConfig.dateFormat);
|
| 73 |
};
|
| 74 |
|
| 75 |
-
export const PostCard = ({ post }: PostCardProps): JSX.Element => {
|
| 76 |
return (
|
| 77 |
<Link to={`/post/${post.slug}`} style={cardStyle}>
|
| 78 |
-
<h2 style={titleStyle}>{post.frontMatter.title}</h2>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
<p style={descriptionStyle}>{post.frontMatter.description}</p>
|
| 80 |
<div style={metaContainerStyle}>
|
| 81 |
<span style={metaItemStyle}>
|
|
|
|
| 4 |
//
|
| 5 |
|
| 6 |
import { Link } from "react-router-dom";
|
| 7 |
+
import { Calendar, User, ArrowRight, Sparkles } from "lucide-react";
|
| 8 |
import { siteConfig } from "@shared/config";
|
| 9 |
import type { PostSummary } from "@shared/types";
|
| 10 |
import type { CSSProperties } from "react";
|
| 11 |
|
| 12 |
interface PostCardProps {
|
| 13 |
post: PostSummary;
|
| 14 |
+
isLatest?: boolean;
|
| 15 |
}
|
| 16 |
|
| 17 |
const cardStyle: CSSProperties = {
|
|
|
|
| 21 |
padding: "var(--spacing-lg)",
|
| 22 |
border: "1px solid var(--color-border)",
|
| 23 |
textDecoration: "none",
|
| 24 |
+
transition: "transform var(--transition-fast), box-shadow var(--transition-fast)",
|
| 25 |
+
position: "relative",
|
| 26 |
};
|
| 27 |
|
| 28 |
const titleStyle: CSSProperties = {
|
| 29 |
fontSize: "var(--font-size-xl)",
|
| 30 |
fontWeight: 600,
|
| 31 |
color: "var(--color-text-primary)",
|
| 32 |
+
lineHeight: 1.3,
|
| 33 |
marginBottom: "var(--spacing-sm)",
|
| 34 |
+
};
|
| 35 |
+
|
| 36 |
+
const titleWithBadgeStyle: CSSProperties = {
|
| 37 |
+
fontSize: "var(--font-size-xl)",
|
| 38 |
+
fontWeight: 600,
|
| 39 |
+
color: "var(--color-text-primary)",
|
| 40 |
lineHeight: 1.3,
|
| 41 |
+
marginBottom: 0,
|
| 42 |
+
};
|
| 43 |
+
|
| 44 |
+
const latestBadgeStyle: CSSProperties = {
|
| 45 |
+
display: "inline-flex",
|
| 46 |
+
alignItems: "center",
|
| 47 |
+
gap: "var(--spacing-xs)",
|
| 48 |
+
backgroundColor: "var(--color-accent)",
|
| 49 |
+
color: "#ffffff",
|
| 50 |
+
padding: "2px var(--spacing-sm)",
|
| 51 |
+
borderRadius: "var(--border-radius-sm)",
|
| 52 |
+
fontSize: "var(--font-size-xs)",
|
| 53 |
+
fontWeight: 600,
|
| 54 |
+
marginTop: "var(--spacing-xs)",
|
| 55 |
+
marginBottom: "var(--spacing-sm)",
|
| 56 |
+
textTransform: "uppercase",
|
| 57 |
+
letterSpacing: "0.5px",
|
| 58 |
};
|
| 59 |
|
| 60 |
const descriptionStyle: CSSProperties = {
|
|
|
|
| 97 |
return date.toLocaleDateString(siteConfig.locale, siteConfig.dateFormat);
|
| 98 |
};
|
| 99 |
|
| 100 |
+
export const PostCard = ({ post, isLatest = false }: PostCardProps): JSX.Element => {
|
| 101 |
return (
|
| 102 |
<Link to={`/post/${post.slug}`} style={cardStyle}>
|
| 103 |
+
<h2 style={isLatest ? titleWithBadgeStyle : titleStyle}>{post.frontMatter.title}</h2>
|
| 104 |
+
{isLatest && (
|
| 105 |
+
<div style={latestBadgeStyle}>
|
| 106 |
+
<Sparkles size={10} />
|
| 107 |
+
{siteConfig.messages.latestArticles}
|
| 108 |
+
</div>
|
| 109 |
+
)}
|
| 110 |
<p style={descriptionStyle}>{post.frontMatter.description}</p>
|
| 111 |
<div style={metaContainerStyle}>
|
| 112 |
<span style={metaItemStyle}>
|
src/client/components/blog/PostContent.tsx
CHANGED
|
@@ -3,8 +3,7 @@
|
|
| 3 |
// SPDX-License-Identifier: Apache-2.0
|
| 4 |
//
|
| 5 |
|
| 6 |
-
import {
|
| 7 |
-
import { Calendar, User, ArrowLeft, Tag } from "lucide-react";
|
| 8 |
import { siteConfig } from "@shared/config";
|
| 9 |
import type { Post } from "@shared/types";
|
| 10 |
import type { CSSProperties } from "react";
|
|
@@ -14,18 +13,9 @@ interface PostContentProps {
|
|
| 14 |
}
|
| 15 |
|
| 16 |
const articleStyle: CSSProperties = {
|
| 17 |
-
maxWidth: "var(--max-width-
|
| 18 |
margin: "0 auto",
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
const backLinkStyle: CSSProperties = {
|
| 22 |
-
display: "inline-flex",
|
| 23 |
-
alignItems: "center",
|
| 24 |
-
gap: "var(--spacing-xs)",
|
| 25 |
-
color: "var(--color-accent)",
|
| 26 |
-
fontSize: "var(--font-size-sm)",
|
| 27 |
-
marginBottom: "var(--spacing-lg)",
|
| 28 |
-
textDecoration: "none",
|
| 29 |
};
|
| 30 |
|
| 31 |
const headerStyle: CSSProperties = {
|
|
@@ -79,19 +69,81 @@ const contentStyle: CSSProperties = {
|
|
| 79 |
color: "var(--color-text-primary)",
|
| 80 |
};
|
| 81 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
const formatDate = (dateString: string): string => {
|
| 83 |
const date = new Date(dateString);
|
| 84 |
return date.toLocaleDateString(siteConfig.locale, siteConfig.dateFormat);
|
| 85 |
};
|
| 86 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
export const PostContent = ({ post }: PostContentProps): JSX.Element => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
return (
|
| 89 |
<article style={articleStyle}>
|
| 90 |
-
<Link to="/" style={backLinkStyle}>
|
| 91 |
-
<ArrowLeft size={16} />
|
| 92 |
-
{siteConfig.messages.backToHome}
|
| 93 |
-
</Link>
|
| 94 |
-
|
| 95 |
<header style={headerStyle}>
|
| 96 |
<h1 style={titleStyle}>{post.frontMatter.title}</h1>
|
| 97 |
|
|
@@ -126,6 +178,39 @@ export const PostContent = ({ post }: PostContentProps): JSX.Element => {
|
|
| 126 |
style={contentStyle}
|
| 127 |
dangerouslySetInnerHTML={{ __html: post.content }}
|
| 128 |
/>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
</article>
|
| 130 |
);
|
| 131 |
};
|
|
|
|
| 3 |
// SPDX-License-Identifier: Apache-2.0
|
| 4 |
//
|
| 5 |
|
| 6 |
+
import { Calendar, User, Tag, Linkedin, Facebook, X } from "lucide-react";
|
|
|
|
| 7 |
import { siteConfig } from "@shared/config";
|
| 8 |
import type { Post } from "@shared/types";
|
| 9 |
import type { CSSProperties } from "react";
|
|
|
|
| 13 |
}
|
| 14 |
|
| 15 |
const articleStyle: CSSProperties = {
|
| 16 |
+
maxWidth: "var(--max-width-container)",
|
| 17 |
margin: "0 auto",
|
| 18 |
+
width: "100%",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
};
|
| 20 |
|
| 21 |
const headerStyle: CSSProperties = {
|
|
|
|
| 69 |
color: "var(--color-text-primary)",
|
| 70 |
};
|
| 71 |
|
| 72 |
+
const shareContainerStyle: CSSProperties = {
|
| 73 |
+
marginTop: "var(--spacing-xl)",
|
| 74 |
+
paddingTop: "var(--spacing-md)",
|
| 75 |
+
};
|
| 76 |
+
|
| 77 |
+
const shareTitleStyle: CSSProperties = {
|
| 78 |
+
fontSize: "var(--font-size-sm)",
|
| 79 |
+
fontWeight: 500,
|
| 80 |
+
color: "var(--color-text-muted)",
|
| 81 |
+
marginBottom: "var(--spacing-sm)",
|
| 82 |
+
};
|
| 83 |
+
|
| 84 |
+
const shareButtonsStyle: CSSProperties = {
|
| 85 |
+
display: "flex",
|
| 86 |
+
gap: "var(--spacing-sm)",
|
| 87 |
+
flexWrap: "wrap",
|
| 88 |
+
};
|
| 89 |
+
|
| 90 |
+
const shareButtonBaseStyle: CSSProperties = {
|
| 91 |
+
display: "inline-flex",
|
| 92 |
+
alignItems: "center",
|
| 93 |
+
justifyContent: "center",
|
| 94 |
+
width: "24px",
|
| 95 |
+
height: "24px",
|
| 96 |
+
borderRadius: "var(--border-radius-sm)",
|
| 97 |
+
border: "none",
|
| 98 |
+
textDecoration: "none",
|
| 99 |
+
cursor: "pointer",
|
| 100 |
+
transition: "opacity var(--transition-fast)",
|
| 101 |
+
};
|
| 102 |
+
|
| 103 |
+
const linkedinButtonStyle: CSSProperties = {
|
| 104 |
+
...shareButtonBaseStyle,
|
| 105 |
+
backgroundColor: "#0077b5",
|
| 106 |
+
color: "#ffffff",
|
| 107 |
+
};
|
| 108 |
+
|
| 109 |
+
const facebookButtonStyle: CSSProperties = {
|
| 110 |
+
...shareButtonBaseStyle,
|
| 111 |
+
backgroundColor: "#1877f2",
|
| 112 |
+
color: "#ffffff",
|
| 113 |
+
};
|
| 114 |
+
|
| 115 |
+
const XButtonStyle: CSSProperties = {
|
| 116 |
+
...shareButtonBaseStyle,
|
| 117 |
+
backgroundColor: "#000000",
|
| 118 |
+
color: "#ffffff",
|
| 119 |
+
};
|
| 120 |
+
|
| 121 |
const formatDate = (dateString: string): string => {
|
| 122 |
const date = new Date(dateString);
|
| 123 |
return date.toLocaleDateString(siteConfig.locale, siteConfig.dateFormat);
|
| 124 |
};
|
| 125 |
|
| 126 |
+
const getShareUrl = (): string => {
|
| 127 |
+
if (typeof window !== "undefined") {
|
| 128 |
+
return encodeURIComponent(window.location.href);
|
| 129 |
+
}
|
| 130 |
+
return "";
|
| 131 |
+
};
|
| 132 |
+
|
| 133 |
+
const getShareTitle = (title: string): string => {
|
| 134 |
+
return encodeURIComponent(title);
|
| 135 |
+
};
|
| 136 |
+
|
| 137 |
export const PostContent = ({ post }: PostContentProps): JSX.Element => {
|
| 138 |
+
const shareUrl = getShareUrl();
|
| 139 |
+
const shareTitle = getShareTitle(post.frontMatter.title);
|
| 140 |
+
|
| 141 |
+
const linkedinShareUrl = `https://www.linkedin.com/sharing/share-offsite/?url=${shareUrl}`;
|
| 142 |
+
const facebookShareUrl = `https://www.facebook.com/sharer/sharer.php?u=${shareUrl}`;
|
| 143 |
+
const XShareUrl = `https://x.com/intent/tweet?url=${shareUrl}&text=${shareTitle}`;
|
| 144 |
+
|
| 145 |
return (
|
| 146 |
<article style={articleStyle}>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 147 |
<header style={headerStyle}>
|
| 148 |
<h1 style={titleStyle}>{post.frontMatter.title}</h1>
|
| 149 |
|
|
|
|
| 178 |
style={contentStyle}
|
| 179 |
dangerouslySetInnerHTML={{ __html: post.content }}
|
| 180 |
/>
|
| 181 |
+
|
| 182 |
+
<div style={shareContainerStyle}>
|
| 183 |
+
<p style={shareTitleStyle}>{siteConfig.messages.shareArticle}</p>
|
| 184 |
+
<div style={shareButtonsStyle}>
|
| 185 |
+
<a
|
| 186 |
+
href={linkedinShareUrl}
|
| 187 |
+
target="_blank"
|
| 188 |
+
rel="noopener noreferrer"
|
| 189 |
+
style={linkedinButtonStyle}
|
| 190 |
+
title="Share on LinkedIn"
|
| 191 |
+
>
|
| 192 |
+
<Linkedin size={16} />
|
| 193 |
+
</a>
|
| 194 |
+
<a
|
| 195 |
+
href={facebookShareUrl}
|
| 196 |
+
target="_blank"
|
| 197 |
+
rel="noopener noreferrer"
|
| 198 |
+
style={facebookButtonStyle}
|
| 199 |
+
title="Share on Facebook"
|
| 200 |
+
>
|
| 201 |
+
<Facebook size={16} />
|
| 202 |
+
</a>
|
| 203 |
+
<a
|
| 204 |
+
href={XShareUrl}
|
| 205 |
+
target="_blank"
|
| 206 |
+
rel="noopener noreferrer"
|
| 207 |
+
style={XButtonStyle}
|
| 208 |
+
title="Share on X"
|
| 209 |
+
>
|
| 210 |
+
<X size={16} />
|
| 211 |
+
</a>
|
| 212 |
+
</div>
|
| 213 |
+
</div>
|
| 214 |
</article>
|
| 215 |
);
|
| 216 |
};
|
src/client/components/blog/PostList.tsx
CHANGED
|
@@ -10,6 +10,7 @@ import type { CSSProperties } from "react";
|
|
| 10 |
|
| 11 |
interface PostListProps {
|
| 12 |
posts: PostSummary[];
|
|
|
|
| 13 |
}
|
| 14 |
|
| 15 |
const listStyle: CSSProperties = {
|
|
@@ -25,7 +26,7 @@ const emptyStyle: CSSProperties = {
|
|
| 25 |
fontSize: "var(--font-size-lg)",
|
| 26 |
};
|
| 27 |
|
| 28 |
-
export const PostList = ({ posts }: PostListProps): JSX.Element => {
|
| 29 |
if (posts.length === 0) {
|
| 30 |
return <div style={emptyStyle}>{siteConfig.messages.noPosts}</div>;
|
| 31 |
}
|
|
@@ -34,7 +35,11 @@ export const PostList = ({ posts }: PostListProps): JSX.Element => {
|
|
| 34 |
<div style={listStyle}>
|
| 35 |
{posts.map(
|
| 36 |
(post: PostSummary): JSX.Element => (
|
| 37 |
-
<PostCard
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
)
|
| 39 |
)}
|
| 40 |
</div>
|
|
|
|
| 10 |
|
| 11 |
interface PostListProps {
|
| 12 |
posts: PostSummary[];
|
| 13 |
+
latestSlug?: string;
|
| 14 |
}
|
| 15 |
|
| 16 |
const listStyle: CSSProperties = {
|
|
|
|
| 26 |
fontSize: "var(--font-size-lg)",
|
| 27 |
};
|
| 28 |
|
| 29 |
+
export const PostList = ({ posts, latestSlug }: PostListProps): JSX.Element => {
|
| 30 |
if (posts.length === 0) {
|
| 31 |
return <div style={emptyStyle}>{siteConfig.messages.noPosts}</div>;
|
| 32 |
}
|
|
|
|
| 35 |
<div style={listStyle}>
|
| 36 |
{posts.map(
|
| 37 |
(post: PostSummary): JSX.Element => (
|
| 38 |
+
<PostCard
|
| 39 |
+
key={post.slug}
|
| 40 |
+
post={post}
|
| 41 |
+
isLatest={post.slug === latestSlug}
|
| 42 |
+
/>
|
| 43 |
)
|
| 44 |
)}
|
| 45 |
</div>
|
src/client/components/layout/Footer.tsx
CHANGED
|
@@ -7,10 +7,14 @@ import { siteConfig } from "@shared/config";
|
|
| 7 |
import type { CSSProperties } from "react";
|
| 8 |
|
| 9 |
const footerStyle: CSSProperties = {
|
| 10 |
-
|
|
|
|
| 11 |
borderTop: "1px solid var(--color-border)",
|
| 12 |
-
padding: "var(--spacing-
|
| 13 |
-
backgroundColor: "var(--color-
|
|
|
|
|
|
|
|
|
|
| 14 |
};
|
| 15 |
|
| 16 |
const footerContainerStyle: CSSProperties = {
|
|
@@ -18,6 +22,10 @@ const footerContainerStyle: CSSProperties = {
|
|
| 18 |
margin: "0 auto",
|
| 19 |
padding: "0 var(--spacing-md)",
|
| 20 |
textAlign: "center",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
};
|
| 22 |
|
| 23 |
const footerTextStyle: CSSProperties = {
|
|
|
|
| 7 |
import type { CSSProperties } from "react";
|
| 8 |
|
| 9 |
const footerStyle: CSSProperties = {
|
| 10 |
+
flexShrink: 0,
|
| 11 |
+
zIndex: 50,
|
| 12 |
borderTop: "1px solid var(--color-border)",
|
| 13 |
+
padding: "var(--spacing-sm) 0",
|
| 14 |
+
backgroundColor: "var(--color-background)",
|
| 15 |
+
height: "var(--footer-height)",
|
| 16 |
+
overflowY: "hidden",
|
| 17 |
+
scrollbarGutter: "stable",
|
| 18 |
};
|
| 19 |
|
| 20 |
const footerContainerStyle: CSSProperties = {
|
|
|
|
| 22 |
margin: "0 auto",
|
| 23 |
padding: "0 var(--spacing-md)",
|
| 24 |
textAlign: "center",
|
| 25 |
+
display: "flex",
|
| 26 |
+
alignItems: "center",
|
| 27 |
+
justifyContent: "center",
|
| 28 |
+
height: "100%",
|
| 29 |
};
|
| 30 |
|
| 31 |
const footerTextStyle: CSSProperties = {
|
src/client/components/layout/Header.tsx
CHANGED
|
@@ -5,33 +5,58 @@
|
|
| 5 |
|
| 6 |
import { Link } from "react-router-dom";
|
| 7 |
import { siteConfig } from "@shared/config";
|
|
|
|
|
|
|
|
|
|
| 8 |
import type { CSSProperties } from "react";
|
| 9 |
|
| 10 |
const headerStyle: CSSProperties = {
|
| 11 |
-
|
| 12 |
-
top: 0,
|
| 13 |
zIndex: 100,
|
| 14 |
backgroundColor: "var(--color-background)",
|
| 15 |
borderBottom: "1px solid var(--color-border)",
|
| 16 |
-
|
|
|
|
|
|
|
| 17 |
};
|
| 18 |
|
| 19 |
const headerContainerStyle: CSSProperties = {
|
| 20 |
maxWidth: "var(--max-width-container)",
|
| 21 |
margin: "0 auto",
|
| 22 |
-
padding: "
|
| 23 |
display: "flex",
|
| 24 |
alignItems: "center",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
justifyContent: "center",
|
|
|
|
|
|
|
| 26 |
};
|
| 27 |
|
| 28 |
-
const
|
| 29 |
display: "flex",
|
| 30 |
alignItems: "center",
|
| 31 |
gap: "var(--spacing-sm)",
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
|
|
|
|
|
|
|
|
|
| 35 |
textDecoration: "none",
|
| 36 |
};
|
| 37 |
|
|
@@ -42,18 +67,119 @@ const profileImageStyle: CSSProperties = {
|
|
| 42 |
objectFit: "cover",
|
| 43 |
};
|
| 44 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
export const Header = (): JSX.Element => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
return (
|
| 47 |
<header style={headerStyle}>
|
| 48 |
<div style={headerContainerStyle}>
|
| 49 |
-
<
|
| 50 |
-
<
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
</div>
|
| 58 |
</header>
|
| 59 |
);
|
|
|
|
| 5 |
|
| 6 |
import { Link } from "react-router-dom";
|
| 7 |
import { siteConfig } from "@shared/config";
|
| 8 |
+
import { useTheme } from "../../contexts/ThemeContext";
|
| 9 |
+
import { usePageContext } from "../../contexts/PageContext";
|
| 10 |
+
import { Sun, Moon, MonitorCog, Home, ChevronRight } from "lucide-react";
|
| 11 |
import type { CSSProperties } from "react";
|
| 12 |
|
| 13 |
const headerStyle: CSSProperties = {
|
| 14 |
+
flexShrink: 0,
|
|
|
|
| 15 |
zIndex: 100,
|
| 16 |
backgroundColor: "var(--color-background)",
|
| 17 |
borderBottom: "1px solid var(--color-border)",
|
| 18 |
+
height: "var(--header-height)",
|
| 19 |
+
overflowY: "hidden",
|
| 20 |
+
scrollbarGutter: "stable",
|
| 21 |
};
|
| 22 |
|
| 23 |
const headerContainerStyle: CSSProperties = {
|
| 24 |
maxWidth: "var(--max-width-container)",
|
| 25 |
margin: "0 auto",
|
| 26 |
+
padding: "var(--spacing-md)",
|
| 27 |
display: "flex",
|
| 28 |
alignItems: "center",
|
| 29 |
+
justifyContent: "space-between",
|
| 30 |
+
gap: "var(--spacing-md)",
|
| 31 |
+
height: "100%",
|
| 32 |
+
};
|
| 33 |
+
|
| 34 |
+
const leftSectionStyle: CSSProperties = {
|
| 35 |
+
display: "flex",
|
| 36 |
+
alignItems: "center",
|
| 37 |
+
flexShrink: 0,
|
| 38 |
+
};
|
| 39 |
+
|
| 40 |
+
const centerSectionStyle: CSSProperties = {
|
| 41 |
+
display: "flex",
|
| 42 |
+
alignItems: "center",
|
| 43 |
+
gap: "var(--spacing-xs)",
|
| 44 |
+
flex: 1,
|
| 45 |
justifyContent: "center",
|
| 46 |
+
minWidth: 0,
|
| 47 |
+
overflow: "hidden",
|
| 48 |
};
|
| 49 |
|
| 50 |
+
const rightSectionStyle: CSSProperties = {
|
| 51 |
display: "flex",
|
| 52 |
alignItems: "center",
|
| 53 |
gap: "var(--spacing-sm)",
|
| 54 |
+
flexShrink: 0,
|
| 55 |
+
};
|
| 56 |
+
|
| 57 |
+
const profileLinkStyle: CSSProperties = {
|
| 58 |
+
display: "flex",
|
| 59 |
+
alignItems: "center",
|
| 60 |
textDecoration: "none",
|
| 61 |
};
|
| 62 |
|
|
|
|
| 67 |
objectFit: "cover",
|
| 68 |
};
|
| 69 |
|
| 70 |
+
const blogTitleStyle: CSSProperties = {
|
| 71 |
+
fontSize: "var(--font-size-lg)",
|
| 72 |
+
fontWeight: 600,
|
| 73 |
+
color: "var(--color-text-primary)",
|
| 74 |
+
textDecoration: "none",
|
| 75 |
+
whiteSpace: "nowrap",
|
| 76 |
+
};
|
| 77 |
+
|
| 78 |
+
const breadcrumbContainerStyle: CSSProperties = {
|
| 79 |
+
display: "flex",
|
| 80 |
+
alignItems: "center",
|
| 81 |
+
gap: "var(--spacing-xs)",
|
| 82 |
+
minWidth: 0,
|
| 83 |
+
overflow: "hidden",
|
| 84 |
+
};
|
| 85 |
+
|
| 86 |
+
const chevronStyle: CSSProperties = {
|
| 87 |
+
color: "var(--color-text-muted)",
|
| 88 |
+
flexShrink: 0,
|
| 89 |
+
};
|
| 90 |
+
|
| 91 |
+
const postTitleStyle: CSSProperties = {
|
| 92 |
+
fontSize: "var(--font-size-base)",
|
| 93 |
+
fontWeight: 500,
|
| 94 |
+
color: "var(--color-text-secondary)",
|
| 95 |
+
whiteSpace: "nowrap",
|
| 96 |
+
overflow: "hidden",
|
| 97 |
+
textOverflow: "ellipsis",
|
| 98 |
+
maxWidth: "200px",
|
| 99 |
+
};
|
| 100 |
+
|
| 101 |
+
const iconButtonStyle: CSSProperties = {
|
| 102 |
+
display: "flex",
|
| 103 |
+
alignItems: "center",
|
| 104 |
+
justifyContent: "center",
|
| 105 |
+
width: "36px",
|
| 106 |
+
height: "36px",
|
| 107 |
+
borderRadius: "var(--border-radius-md)",
|
| 108 |
+
border: "1px solid var(--color-border)",
|
| 109 |
+
backgroundColor: "var(--color-surface)",
|
| 110 |
+
color: "var(--color-text-primary)",
|
| 111 |
+
cursor: "pointer",
|
| 112 |
+
transition: "background-color var(--transition-fast), border-color var(--transition-fast)",
|
| 113 |
+
textDecoration: "none",
|
| 114 |
+
};
|
| 115 |
+
|
| 116 |
+
const ThemeIcon = ({ theme }: { theme: "system" | "light" | "dark" }): JSX.Element => {
|
| 117 |
+
if (theme === "light") {
|
| 118 |
+
return <Sun size={18} />;
|
| 119 |
+
}
|
| 120 |
+
if (theme === "dark") {
|
| 121 |
+
return <Moon size={18} />;
|
| 122 |
+
}
|
| 123 |
+
return <MonitorCog size={18} />;
|
| 124 |
+
};
|
| 125 |
+
|
| 126 |
export const Header = (): JSX.Element => {
|
| 127 |
+
const { theme, setTheme } = useTheme();
|
| 128 |
+
const { currentPostTitle } = usePageContext();
|
| 129 |
+
|
| 130 |
+
const cycleTheme = (): void => {
|
| 131 |
+
const themes: Array<"system" | "light" | "dark"> = ["system", "light", "dark"];
|
| 132 |
+
const currentIndex = themes.indexOf(theme);
|
| 133 |
+
const nextIndex = (currentIndex + 1) % themes.length;
|
| 134 |
+
setTheme(themes[nextIndex]);
|
| 135 |
+
};
|
| 136 |
+
|
| 137 |
+
const isOnPostPage = currentPostTitle !== null;
|
| 138 |
+
|
| 139 |
return (
|
| 140 |
<header style={headerStyle}>
|
| 141 |
<div style={headerContainerStyle}>
|
| 142 |
+
<div style={leftSectionStyle}>
|
| 143 |
+
<Link to="/" style={profileLinkStyle}>
|
| 144 |
+
<img
|
| 145 |
+
src="/assets/images/profile.png"
|
| 146 |
+
alt={siteConfig.name}
|
| 147 |
+
style={profileImageStyle}
|
| 148 |
+
/>
|
| 149 |
+
</Link>
|
| 150 |
+
</div>
|
| 151 |
+
|
| 152 |
+
<div style={centerSectionStyle}>
|
| 153 |
+
<div style={breadcrumbContainerStyle}>
|
| 154 |
+
<Link to="/" style={blogTitleStyle}>
|
| 155 |
+
{siteConfig.description}
|
| 156 |
+
</Link>
|
| 157 |
+
{isOnPostPage && (
|
| 158 |
+
<>
|
| 159 |
+
<ChevronRight size={16} style={chevronStyle} />
|
| 160 |
+
<span style={postTitleStyle} title={currentPostTitle}>
|
| 161 |
+
{currentPostTitle}
|
| 162 |
+
</span>
|
| 163 |
+
</>
|
| 164 |
+
)}
|
| 165 |
+
</div>
|
| 166 |
+
</div>
|
| 167 |
+
|
| 168 |
+
<div style={rightSectionStyle}>
|
| 169 |
+
{isOnPostPage && (
|
| 170 |
+
<Link to="/" style={iconButtonStyle} title={siteConfig.messages.backToHome}>
|
| 171 |
+
<Home size={18} />
|
| 172 |
+
</Link>
|
| 173 |
+
)}
|
| 174 |
+
<button
|
| 175 |
+
type="button"
|
| 176 |
+
onClick={cycleTheme}
|
| 177 |
+
style={iconButtonStyle}
|
| 178 |
+
title={`Theme: ${theme}`}
|
| 179 |
+
>
|
| 180 |
+
<ThemeIcon theme={theme} />
|
| 181 |
+
</button>
|
| 182 |
+
</div>
|
| 183 |
</div>
|
| 184 |
</header>
|
| 185 |
);
|
src/client/components/layout/Layout.tsx
CHANGED
|
@@ -12,24 +12,36 @@ interface LayoutProps {
|
|
| 12 |
}
|
| 13 |
|
| 14 |
const layoutStyle: CSSProperties = {
|
| 15 |
-
|
| 16 |
display: "flex",
|
| 17 |
flexDirection: "column",
|
|
|
|
| 18 |
};
|
| 19 |
|
| 20 |
const mainStyle: CSSProperties = {
|
| 21 |
flex: 1,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
width: "100%",
|
| 23 |
maxWidth: "var(--max-width-container)",
|
| 24 |
margin: "0 auto",
|
| 25 |
padding: "var(--spacing-xl) var(--spacing-md)",
|
|
|
|
|
|
|
|
|
|
| 26 |
};
|
| 27 |
|
| 28 |
export const Layout = ({ children }: LayoutProps): JSX.Element => {
|
| 29 |
return (
|
| 30 |
<div style={layoutStyle}>
|
| 31 |
<Header />
|
| 32 |
-
<main style={mainStyle}>
|
|
|
|
|
|
|
| 33 |
<Footer />
|
| 34 |
</div>
|
| 35 |
);
|
|
|
|
| 12 |
}
|
| 13 |
|
| 14 |
const layoutStyle: CSSProperties = {
|
| 15 |
+
height: "100%",
|
| 16 |
display: "flex",
|
| 17 |
flexDirection: "column",
|
| 18 |
+
overflow: "hidden",
|
| 19 |
};
|
| 20 |
|
| 21 |
const mainStyle: CSSProperties = {
|
| 22 |
flex: 1,
|
| 23 |
+
overflowY: "auto",
|
| 24 |
+
overflowX: "hidden",
|
| 25 |
+
scrollbarGutter: "stable",
|
| 26 |
+
};
|
| 27 |
+
|
| 28 |
+
const mainContentStyle: CSSProperties = {
|
| 29 |
width: "100%",
|
| 30 |
maxWidth: "var(--max-width-container)",
|
| 31 |
margin: "0 auto",
|
| 32 |
padding: "var(--spacing-xl) var(--spacing-md)",
|
| 33 |
+
display: "flex",
|
| 34 |
+
flexDirection: "column",
|
| 35 |
+
minHeight: "100%",
|
| 36 |
};
|
| 37 |
|
| 38 |
export const Layout = ({ children }: LayoutProps): JSX.Element => {
|
| 39 |
return (
|
| 40 |
<div style={layoutStyle}>
|
| 41 |
<Header />
|
| 42 |
+
<main style={mainStyle}>
|
| 43 |
+
<div style={mainContentStyle}>{children}</div>
|
| 44 |
+
</main>
|
| 45 |
<Footer />
|
| 46 |
</div>
|
| 47 |
);
|
src/client/contexts/PageContext.tsx
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
//
|
| 2 |
+
// SPDX-FileCopyrightText: Hadad <hadad@linuxmail.org>
|
| 3 |
+
// SPDX-License-Identifier: Apache-2.0
|
| 4 |
+
//
|
| 5 |
+
|
| 6 |
+
import { createContext, useContext, useState, useCallback, useMemo } from "react";
|
| 7 |
+
import type { ReactNode } from "react";
|
| 8 |
+
|
| 9 |
+
interface PageContextValue {
|
| 10 |
+
currentPostTitle: string | null;
|
| 11 |
+
setCurrentPostTitle: (title: string | null) => void;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
const PageContext = createContext<PageContextValue | null>(null);
|
| 15 |
+
|
| 16 |
+
interface PageProviderProps {
|
| 17 |
+
children: ReactNode;
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
export const PageProvider = ({ children }: PageProviderProps): JSX.Element => {
|
| 21 |
+
const [currentPostTitle, setCurrentPostTitleState] = useState<string | null>(null);
|
| 22 |
+
|
| 23 |
+
const setCurrentPostTitle = useCallback((title: string | null): void => {
|
| 24 |
+
setCurrentPostTitleState(title);
|
| 25 |
+
}, []);
|
| 26 |
+
|
| 27 |
+
const contextValue = useMemo((): PageContextValue => ({
|
| 28 |
+
currentPostTitle,
|
| 29 |
+
setCurrentPostTitle,
|
| 30 |
+
}), [currentPostTitle, setCurrentPostTitle]);
|
| 31 |
+
|
| 32 |
+
return (
|
| 33 |
+
<PageContext.Provider value={contextValue}>
|
| 34 |
+
{children}
|
| 35 |
+
</PageContext.Provider>
|
| 36 |
+
);
|
| 37 |
+
};
|
| 38 |
+
|
| 39 |
+
export const usePageContext = (): PageContextValue => {
|
| 40 |
+
const context = useContext(PageContext);
|
| 41 |
+
if (!context) {
|
| 42 |
+
throw new Error("usePageContext must be used within a PageProvider");
|
| 43 |
+
}
|
| 44 |
+
return context;
|
| 45 |
+
};
|
src/client/contexts/ThemeContext.tsx
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
//
|
| 2 |
+
// SPDX-FileCopyrightText: Hadad <hadad@linuxmail.org>
|
| 3 |
+
// SPDX-License-Identifier: Apache-2.0
|
| 4 |
+
//
|
| 5 |
+
|
| 6 |
+
import { createContext, useContext, useState, useEffect, useCallback, useMemo } from "react";
|
| 7 |
+
import type { ReactNode } from "react";
|
| 8 |
+
|
| 9 |
+
type ThemeMode = "system" | "light" | "dark";
|
| 10 |
+
type ResolvedTheme = "light" | "dark";
|
| 11 |
+
|
| 12 |
+
interface ThemeContextValue {
|
| 13 |
+
theme: ThemeMode;
|
| 14 |
+
resolvedTheme: ResolvedTheme;
|
| 15 |
+
setTheme: (theme: ThemeMode) => void;
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
const ThemeContext = createContext<ThemeContextValue | null>(null);
|
| 19 |
+
|
| 20 |
+
const THEME_STORAGE_KEY = "blog-theme-preference";
|
| 21 |
+
|
| 22 |
+
const getSystemTheme = (): ResolvedTheme => {
|
| 23 |
+
if (typeof window !== "undefined" && window.matchMedia) {
|
| 24 |
+
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
| 25 |
+
}
|
| 26 |
+
return "light";
|
| 27 |
+
};
|
| 28 |
+
|
| 29 |
+
const getStoredTheme = (): ThemeMode => {
|
| 30 |
+
if (typeof window !== "undefined" && window.localStorage) {
|
| 31 |
+
const stored = localStorage.getItem(THEME_STORAGE_KEY);
|
| 32 |
+
if (stored === "light" || stored === "dark" || stored === "system") {
|
| 33 |
+
return stored;
|
| 34 |
+
}
|
| 35 |
+
}
|
| 36 |
+
return "system";
|
| 37 |
+
};
|
| 38 |
+
|
| 39 |
+
interface ThemeProviderProps {
|
| 40 |
+
children: ReactNode;
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
export const ThemeProvider = ({ children }: ThemeProviderProps): JSX.Element => {
|
| 44 |
+
const [theme, setThemeState] = useState<ThemeMode>(getStoredTheme);
|
| 45 |
+
const [resolvedTheme, setResolvedTheme] = useState<ResolvedTheme>(() => {
|
| 46 |
+
const stored = getStoredTheme();
|
| 47 |
+
return stored === "system" ? getSystemTheme() : stored;
|
| 48 |
+
});
|
| 49 |
+
|
| 50 |
+
const applyTheme = useCallback((mode: ThemeMode): void => {
|
| 51 |
+
const resolved: ResolvedTheme = mode === "system" ? getSystemTheme() : mode;
|
| 52 |
+
setResolvedTheme(resolved);
|
| 53 |
+
|
| 54 |
+
if (mode === "system") {
|
| 55 |
+
document.documentElement.removeAttribute("data-theme");
|
| 56 |
+
} else {
|
| 57 |
+
document.documentElement.setAttribute("data-theme", mode);
|
| 58 |
+
}
|
| 59 |
+
}, []);
|
| 60 |
+
|
| 61 |
+
const setTheme = useCallback((newTheme: ThemeMode): void => {
|
| 62 |
+
setThemeState(newTheme);
|
| 63 |
+
if (typeof window !== "undefined" && window.localStorage) {
|
| 64 |
+
localStorage.setItem(THEME_STORAGE_KEY, newTheme);
|
| 65 |
+
}
|
| 66 |
+
applyTheme(newTheme);
|
| 67 |
+
}, [applyTheme]);
|
| 68 |
+
|
| 69 |
+
useEffect((): (() => void) => {
|
| 70 |
+
applyTheme(theme);
|
| 71 |
+
|
| 72 |
+
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
| 73 |
+
const handleSystemThemeChange = (): void => {
|
| 74 |
+
if (theme === "system") {
|
| 75 |
+
setResolvedTheme(getSystemTheme());
|
| 76 |
+
}
|
| 77 |
+
};
|
| 78 |
+
|
| 79 |
+
mediaQuery.addEventListener("change", handleSystemThemeChange);
|
| 80 |
+
|
| 81 |
+
return (): void => {
|
| 82 |
+
mediaQuery.removeEventListener("change", handleSystemThemeChange);
|
| 83 |
+
};
|
| 84 |
+
}, [theme, applyTheme]);
|
| 85 |
+
|
| 86 |
+
const contextValue = useMemo((): ThemeContextValue => ({
|
| 87 |
+
theme,
|
| 88 |
+
resolvedTheme,
|
| 89 |
+
setTheme,
|
| 90 |
+
}), [theme, resolvedTheme, setTheme]);
|
| 91 |
+
|
| 92 |
+
return (
|
| 93 |
+
<ThemeContext.Provider value={contextValue}>
|
| 94 |
+
{children}
|
| 95 |
+
</ThemeContext.Provider>
|
| 96 |
+
);
|
| 97 |
+
};
|
| 98 |
+
|
| 99 |
+
export const useTheme = (): ThemeContextValue => {
|
| 100 |
+
const context = useContext(ThemeContext);
|
| 101 |
+
if (!context) {
|
| 102 |
+
throw new Error("useTheme must be used within a ThemeProvider");
|
| 103 |
+
}
|
| 104 |
+
return context;
|
| 105 |
+
};
|
src/client/hooks/usePosts.ts
CHANGED
|
@@ -37,7 +37,7 @@ export const usePosts = (): UsePostsResult => {
|
|
| 37 |
} else {
|
| 38 |
setError(result.error || "Failed to fetch posts");
|
| 39 |
}
|
| 40 |
-
} catch
|
| 41 |
setError("Failed to connect to server");
|
| 42 |
} finally {
|
| 43 |
setLoading(false);
|
|
@@ -70,7 +70,7 @@ export const usePost = (slug: string): UsePostResult => {
|
|
| 70 |
} else {
|
| 71 |
setError(result.error || "Post not found");
|
| 72 |
}
|
| 73 |
-
} catch
|
| 74 |
setError("Failed to connect to server");
|
| 75 |
} finally {
|
| 76 |
setLoading(false);
|
|
|
|
| 37 |
} else {
|
| 38 |
setError(result.error || "Failed to fetch posts");
|
| 39 |
}
|
| 40 |
+
} catch {
|
| 41 |
setError("Failed to connect to server");
|
| 42 |
} finally {
|
| 43 |
setLoading(false);
|
|
|
|
| 70 |
} else {
|
| 71 |
setError(result.error || "Post not found");
|
| 72 |
}
|
| 73 |
+
} catch {
|
| 74 |
setError("Failed to connect to server");
|
| 75 |
} finally {
|
| 76 |
setLoading(false);
|
src/client/pages/HomePage.tsx
CHANGED
|
@@ -3,24 +3,25 @@
|
|
| 3 |
// SPDX-License-Identifier: Apache-2.0
|
| 4 |
//
|
| 5 |
|
|
|
|
| 6 |
import { usePosts } from "../hooks/usePosts";
|
|
|
|
| 7 |
import { PostList } from "../components/blog/PostList";
|
| 8 |
import { Head } from "../components/common/Head";
|
| 9 |
import { siteConfig } from "@shared/config";
|
| 10 |
-
import { Loader, AlertCircle } from "lucide-react";
|
| 11 |
import type { CSSProperties } from "react";
|
| 12 |
|
|
|
|
|
|
|
| 13 |
const containerStyle: CSSProperties = {
|
| 14 |
-
maxWidth: "var(--max-width-
|
| 15 |
margin: "0 auto",
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
marginBottom: "var(--spacing-xl)",
|
| 22 |
-
color: "var(--color-text-primary)",
|
| 23 |
-
textAlign: "center",
|
| 24 |
};
|
| 25 |
|
| 26 |
const loadingContainerStyle: CSSProperties = {
|
|
@@ -28,7 +29,7 @@ const loadingContainerStyle: CSSProperties = {
|
|
| 28 |
flexDirection: "column",
|
| 29 |
alignItems: "center",
|
| 30 |
justifyContent: "center",
|
| 31 |
-
|
| 32 |
gap: "var(--spacing-md)",
|
| 33 |
color: "var(--color-text-muted)",
|
| 34 |
};
|
|
@@ -38,7 +39,7 @@ const errorContainerStyle: CSSProperties = {
|
|
| 38 |
flexDirection: "column",
|
| 39 |
alignItems: "center",
|
| 40 |
justifyContent: "center",
|
| 41 |
-
|
| 42 |
gap: "var(--spacing-md)",
|
| 43 |
color: "var(--color-text-muted)",
|
| 44 |
textAlign: "center",
|
|
@@ -48,37 +49,158 @@ const spinnerStyle: CSSProperties = {
|
|
| 48 |
animation: "spin 1s linear infinite",
|
| 49 |
};
|
| 50 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
export const HomePage = (): JSX.Element => {
|
| 52 |
const { posts, loading, error } = usePosts();
|
|
|
|
|
|
|
| 53 |
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
|
| 58 |
-
|
|
|
|
|
|
|
| 59 |
|
| 60 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
<div style={loadingContainerStyle}>
|
| 62 |
<Loader size={32} style={spinnerStyle} />
|
| 63 |
<span>{siteConfig.messages.loading}</span>
|
| 64 |
</div>
|
| 65 |
-
|
|
|
|
|
|
|
| 66 |
|
| 67 |
-
|
|
|
|
|
|
|
|
|
|
| 68 |
<div style={errorContainerStyle}>
|
| 69 |
<AlertCircle size={32} />
|
| 70 |
<span>{error}</span>
|
| 71 |
</div>
|
| 72 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
|
| 74 |
-
|
|
|
|
|
|
|
| 75 |
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
</div>
|
| 83 |
);
|
| 84 |
};
|
|
|
|
| 3 |
// SPDX-License-Identifier: Apache-2.0
|
| 4 |
//
|
| 5 |
|
| 6 |
+
import { useState, useEffect, useMemo } from "react";
|
| 7 |
import { usePosts } from "../hooks/usePosts";
|
| 8 |
+
import { usePageContext } from "../contexts/PageContext";
|
| 9 |
import { PostList } from "../components/blog/PostList";
|
| 10 |
import { Head } from "../components/common/Head";
|
| 11 |
import { siteConfig } from "@shared/config";
|
| 12 |
+
import { Loader, AlertCircle, ChevronLeft, ChevronRight } from "lucide-react";
|
| 13 |
import type { CSSProperties } from "react";
|
| 14 |
|
| 15 |
+
const POSTS_PER_PAGE = 5;
|
| 16 |
+
|
| 17 |
const containerStyle: CSSProperties = {
|
| 18 |
+
maxWidth: "var(--max-width-container)",
|
| 19 |
margin: "0 auto",
|
| 20 |
+
display: "flex",
|
| 21 |
+
flexDirection: "column",
|
| 22 |
+
flex: 1,
|
| 23 |
+
justifyContent: "center",
|
| 24 |
+
width: "100%",
|
|
|
|
|
|
|
|
|
|
| 25 |
};
|
| 26 |
|
| 27 |
const loadingContainerStyle: CSSProperties = {
|
|
|
|
| 29 |
flexDirection: "column",
|
| 30 |
alignItems: "center",
|
| 31 |
justifyContent: "center",
|
| 32 |
+
flex: 1,
|
| 33 |
gap: "var(--spacing-md)",
|
| 34 |
color: "var(--color-text-muted)",
|
| 35 |
};
|
|
|
|
| 39 |
flexDirection: "column",
|
| 40 |
alignItems: "center",
|
| 41 |
justifyContent: "center",
|
| 42 |
+
flex: 1,
|
| 43 |
gap: "var(--spacing-md)",
|
| 44 |
color: "var(--color-text-muted)",
|
| 45 |
textAlign: "center",
|
|
|
|
| 49 |
animation: "spin 1s linear infinite",
|
| 50 |
};
|
| 51 |
|
| 52 |
+
const contentWrapperStyle: CSSProperties = {
|
| 53 |
+
display: "flex",
|
| 54 |
+
flexDirection: "column",
|
| 55 |
+
gap: "var(--spacing-lg)",
|
| 56 |
+
};
|
| 57 |
+
|
| 58 |
+
const paginationContainerStyle: CSSProperties = {
|
| 59 |
+
display: "flex",
|
| 60 |
+
alignItems: "center",
|
| 61 |
+
justifyContent: "space-between",
|
| 62 |
+
paddingTop: "var(--spacing-lg)",
|
| 63 |
+
};
|
| 64 |
+
|
| 65 |
+
const paginationButtonStyle: CSSProperties = {
|
| 66 |
+
display: "inline-flex",
|
| 67 |
+
alignItems: "center",
|
| 68 |
+
justifyContent: "center",
|
| 69 |
+
gap: "var(--spacing-xs)",
|
| 70 |
+
padding: "var(--spacing-sm) var(--spacing-md)",
|
| 71 |
+
borderRadius: "var(--border-radius-md)",
|
| 72 |
+
border: "1px solid var(--color-border)",
|
| 73 |
+
backgroundColor: "var(--color-surface)",
|
| 74 |
+
color: "var(--color-text-primary)",
|
| 75 |
+
fontSize: "var(--font-size-sm)",
|
| 76 |
+
fontWeight: 500,
|
| 77 |
+
cursor: "pointer",
|
| 78 |
+
transition: "background-color var(--transition-fast), border-color var(--transition-fast)",
|
| 79 |
+
};
|
| 80 |
+
|
| 81 |
+
const paginationButtonDisabledStyle: CSSProperties = {
|
| 82 |
+
...paginationButtonStyle,
|
| 83 |
+
opacity: 0.5,
|
| 84 |
+
cursor: "not-allowed",
|
| 85 |
+
};
|
| 86 |
+
|
| 87 |
+
const paginationInfoStyle: CSSProperties = {
|
| 88 |
+
fontSize: "var(--font-size-sm)",
|
| 89 |
+
color: "var(--color-text-muted)",
|
| 90 |
+
position: "absolute",
|
| 91 |
+
left: "50%",
|
| 92 |
+
transform: "translateX(-50%)",
|
| 93 |
+
};
|
| 94 |
+
|
| 95 |
+
const paginationWrapperStyle: CSSProperties = {
|
| 96 |
+
position: "relative",
|
| 97 |
+
width: "100%",
|
| 98 |
+
display: "flex",
|
| 99 |
+
alignItems: "center",
|
| 100 |
+
justifyContent: "space-between",
|
| 101 |
+
};
|
| 102 |
+
|
| 103 |
export const HomePage = (): JSX.Element => {
|
| 104 |
const { posts, loading, error } = usePosts();
|
| 105 |
+
const { setCurrentPostTitle } = usePageContext();
|
| 106 |
+
const [currentPage, setCurrentPage] = useState<number>(1);
|
| 107 |
|
| 108 |
+
useEffect((): void => {
|
| 109 |
+
setCurrentPostTitle(null);
|
| 110 |
+
}, [setCurrentPostTitle]);
|
| 111 |
|
| 112 |
+
const totalPages = useMemo((): number => {
|
| 113 |
+
return Math.ceil(posts.length / POSTS_PER_PAGE);
|
| 114 |
+
}, [posts.length]);
|
| 115 |
|
| 116 |
+
const paginatedPosts = useMemo(() => {
|
| 117 |
+
const startIndex = (currentPage - 1) * POSTS_PER_PAGE;
|
| 118 |
+
const endIndex = startIndex + POSTS_PER_PAGE;
|
| 119 |
+
return posts.slice(startIndex, endIndex);
|
| 120 |
+
}, [posts, currentPage]);
|
| 121 |
+
|
| 122 |
+
const latestSlug = useMemo((): string | undefined => {
|
| 123 |
+
if (posts.length > 0 && currentPage === 1) {
|
| 124 |
+
return posts[0].slug;
|
| 125 |
+
}
|
| 126 |
+
return undefined;
|
| 127 |
+
}, [posts, currentPage]);
|
| 128 |
+
|
| 129 |
+
const handlePreviousPage = (): void => {
|
| 130 |
+
if (currentPage > 1) {
|
| 131 |
+
setCurrentPage(currentPage - 1);
|
| 132 |
+
window.scrollTo({ top: 0, behavior: "smooth" });
|
| 133 |
+
}
|
| 134 |
+
};
|
| 135 |
+
|
| 136 |
+
const handleNextPage = (): void => {
|
| 137 |
+
if (currentPage < totalPages) {
|
| 138 |
+
setCurrentPage(currentPage + 1);
|
| 139 |
+
window.scrollTo({ top: 0, behavior: "smooth" });
|
| 140 |
+
}
|
| 141 |
+
};
|
| 142 |
+
|
| 143 |
+
if (loading) {
|
| 144 |
+
return (
|
| 145 |
+
<div style={containerStyle}>
|
| 146 |
+
<Head title={siteConfig.title} description={siteConfig.description} />
|
| 147 |
<div style={loadingContainerStyle}>
|
| 148 |
<Loader size={32} style={spinnerStyle} />
|
| 149 |
<span>{siteConfig.messages.loading}</span>
|
| 150 |
</div>
|
| 151 |
+
</div>
|
| 152 |
+
);
|
| 153 |
+
}
|
| 154 |
|
| 155 |
+
if (error) {
|
| 156 |
+
return (
|
| 157 |
+
<div style={containerStyle}>
|
| 158 |
+
<Head title={siteConfig.title} description={siteConfig.description} />
|
| 159 |
<div style={errorContainerStyle}>
|
| 160 |
<AlertCircle size={32} />
|
| 161 |
<span>{error}</span>
|
| 162 |
</div>
|
| 163 |
+
</div>
|
| 164 |
+
);
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
return (
|
| 168 |
+
<div style={containerStyle}>
|
| 169 |
+
<Head title={siteConfig.title} description={siteConfig.description} />
|
| 170 |
+
|
| 171 |
+
<div style={contentWrapperStyle}>
|
| 172 |
+
<PostList posts={paginatedPosts} latestSlug={latestSlug} />
|
| 173 |
+
|
| 174 |
+
{totalPages > 1 && (
|
| 175 |
+
<div style={paginationContainerStyle}>
|
| 176 |
+
<div style={paginationWrapperStyle}>
|
| 177 |
+
<button
|
| 178 |
+
type="button"
|
| 179 |
+
onClick={handlePreviousPage}
|
| 180 |
+
disabled={currentPage === 1}
|
| 181 |
+
style={currentPage === 1 ? paginationButtonDisabledStyle : paginationButtonStyle}
|
| 182 |
+
>
|
| 183 |
+
<ChevronLeft size={18} />
|
| 184 |
+
{siteConfig.messages.previous}
|
| 185 |
+
</button>
|
| 186 |
|
| 187 |
+
<span style={paginationInfoStyle}>
|
| 188 |
+
{currentPage} / {totalPages}
|
| 189 |
+
</span>
|
| 190 |
|
| 191 |
+
<button
|
| 192 |
+
type="button"
|
| 193 |
+
onClick={handleNextPage}
|
| 194 |
+
disabled={currentPage === totalPages}
|
| 195 |
+
style={currentPage === totalPages ? paginationButtonDisabledStyle : paginationButtonStyle}
|
| 196 |
+
>
|
| 197 |
+
{siteConfig.messages.next}
|
| 198 |
+
<ChevronRight size={18} />
|
| 199 |
+
</button>
|
| 200 |
+
</div>
|
| 201 |
+
</div>
|
| 202 |
+
)}
|
| 203 |
+
</div>
|
| 204 |
</div>
|
| 205 |
);
|
| 206 |
};
|
src/client/pages/PostPage.tsx
CHANGED
|
@@ -4,19 +4,27 @@
|
|
| 4 |
//
|
| 5 |
|
| 6 |
import { useParams, Link } from "react-router-dom";
|
|
|
|
| 7 |
import { usePost } from "../hooks/usePosts";
|
|
|
|
| 8 |
import { PostContent } from "../components/blog/PostContent";
|
| 9 |
import { Head } from "../components/common/Head";
|
| 10 |
import { siteConfig } from "@shared/config";
|
| 11 |
import { Loader, AlertCircle, ArrowLeft } from "lucide-react";
|
| 12 |
import type { CSSProperties } from "react";
|
| 13 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
const loadingContainerStyle: CSSProperties = {
|
| 15 |
display: "flex",
|
| 16 |
flexDirection: "column",
|
| 17 |
alignItems: "center",
|
| 18 |
justifyContent: "center",
|
| 19 |
-
|
| 20 |
gap: "var(--spacing-md)",
|
| 21 |
color: "var(--color-text-muted)",
|
| 22 |
};
|
|
@@ -26,7 +34,7 @@ const errorContainerStyle: CSSProperties = {
|
|
| 26 |
flexDirection: "column",
|
| 27 |
alignItems: "center",
|
| 28 |
justifyContent: "center",
|
| 29 |
-
|
| 30 |
gap: "var(--spacing-md)",
|
| 31 |
color: "var(--color-text-muted)",
|
| 32 |
textAlign: "center",
|
|
@@ -48,37 +56,46 @@ const spinnerStyle: CSSProperties = {
|
|
| 48 |
export const PostPage = (): JSX.Element => {
|
| 49 |
const { slug } = useParams<{ slug: string }>();
|
| 50 |
const { post, loading, error } = usePost(slug || "");
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
|
| 52 |
if (loading) {
|
| 53 |
return (
|
| 54 |
-
<div style={
|
| 55 |
-
<
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
from { transform: rotate(0deg); }
|
| 60 |
-
to { transform: rotate(360deg); }
|
| 61 |
-
}
|
| 62 |
-
`}</style>
|
| 63 |
</div>
|
| 64 |
);
|
| 65 |
}
|
| 66 |
|
| 67 |
if (error || !post) {
|
| 68 |
return (
|
| 69 |
-
<div style={
|
| 70 |
-
<
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
<
|
| 74 |
-
|
| 75 |
-
|
|
|
|
|
|
|
| 76 |
</div>
|
| 77 |
);
|
| 78 |
}
|
| 79 |
|
| 80 |
return (
|
| 81 |
-
<>
|
| 82 |
<Head
|
| 83 |
title={post.frontMatter.title}
|
| 84 |
description={post.frontMatter.description}
|
|
@@ -86,6 +103,6 @@ export const PostPage = (): JSX.Element => {
|
|
| 86 |
type="article"
|
| 87 |
/>
|
| 88 |
<PostContent post={post} />
|
| 89 |
-
</>
|
| 90 |
);
|
| 91 |
};
|
|
|
|
| 4 |
//
|
| 5 |
|
| 6 |
import { useParams, Link } from "react-router-dom";
|
| 7 |
+
import { useEffect } from "react";
|
| 8 |
import { usePost } from "../hooks/usePosts";
|
| 9 |
+
import { usePageContext } from "../contexts/PageContext";
|
| 10 |
import { PostContent } from "../components/blog/PostContent";
|
| 11 |
import { Head } from "../components/common/Head";
|
| 12 |
import { siteConfig } from "@shared/config";
|
| 13 |
import { Loader, AlertCircle, ArrowLeft } from "lucide-react";
|
| 14 |
import type { CSSProperties } from "react";
|
| 15 |
|
| 16 |
+
const containerStyle: CSSProperties = {
|
| 17 |
+
display: "flex",
|
| 18 |
+
flexDirection: "column",
|
| 19 |
+
flex: 1,
|
| 20 |
+
};
|
| 21 |
+
|
| 22 |
const loadingContainerStyle: CSSProperties = {
|
| 23 |
display: "flex",
|
| 24 |
flexDirection: "column",
|
| 25 |
alignItems: "center",
|
| 26 |
justifyContent: "center",
|
| 27 |
+
flex: 1,
|
| 28 |
gap: "var(--spacing-md)",
|
| 29 |
color: "var(--color-text-muted)",
|
| 30 |
};
|
|
|
|
| 34 |
flexDirection: "column",
|
| 35 |
alignItems: "center",
|
| 36 |
justifyContent: "center",
|
| 37 |
+
flex: 1,
|
| 38 |
gap: "var(--spacing-md)",
|
| 39 |
color: "var(--color-text-muted)",
|
| 40 |
textAlign: "center",
|
|
|
|
| 56 |
export const PostPage = (): JSX.Element => {
|
| 57 |
const { slug } = useParams<{ slug: string }>();
|
| 58 |
const { post, loading, error } = usePost(slug || "");
|
| 59 |
+
const { setCurrentPostTitle } = usePageContext();
|
| 60 |
+
|
| 61 |
+
useEffect((): (() => void) => {
|
| 62 |
+
if (post) {
|
| 63 |
+
setCurrentPostTitle(post.frontMatter.title);
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
return (): void => {
|
| 67 |
+
setCurrentPostTitle(null);
|
| 68 |
+
};
|
| 69 |
+
}, [post, setCurrentPostTitle]);
|
| 70 |
|
| 71 |
if (loading) {
|
| 72 |
return (
|
| 73 |
+
<div style={containerStyle}>
|
| 74 |
+
<div style={loadingContainerStyle}>
|
| 75 |
+
<Loader size={32} style={spinnerStyle} />
|
| 76 |
+
<span>{siteConfig.messages.loading}</span>
|
| 77 |
+
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
</div>
|
| 79 |
);
|
| 80 |
}
|
| 81 |
|
| 82 |
if (error || !post) {
|
| 83 |
return (
|
| 84 |
+
<div style={containerStyle}>
|
| 85 |
+
<div style={errorContainerStyle}>
|
| 86 |
+
<AlertCircle size={32} />
|
| 87 |
+
<span>{error || siteConfig.messages.postNotFound}</span>
|
| 88 |
+
<Link to="/" style={backLinkStyle}>
|
| 89 |
+
<ArrowLeft size={16} />
|
| 90 |
+
{siteConfig.messages.backToHome}
|
| 91 |
+
</Link>
|
| 92 |
+
</div>
|
| 93 |
</div>
|
| 94 |
);
|
| 95 |
}
|
| 96 |
|
| 97 |
return (
|
| 98 |
+
<div style={containerStyle}>
|
| 99 |
<Head
|
| 100 |
title={post.frontMatter.title}
|
| 101 |
description={post.frontMatter.description}
|
|
|
|
| 103 |
type="article"
|
| 104 |
/>
|
| 105 |
<PostContent post={post} />
|
| 106 |
+
</div>
|
| 107 |
);
|
| 108 |
};
|
src/client/styles/global.css
CHANGED
|
@@ -22,6 +22,7 @@
|
|
| 22 |
--color-border: #e5e7eb;
|
| 23 |
--color-code-background: #1e1e2e;
|
| 24 |
--color-code-text: #cdd6f4;
|
|
|
|
| 25 |
--font-family-base: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
| 26 |
"Helvetica Neue", Arial, sans-serif;
|
| 27 |
--font-family-mono: "SF Mono", Monaco, "Cascadia Code", "Roboto Mono",
|
|
@@ -53,13 +54,52 @@
|
|
| 53 |
--line-height-tight: 1.4;
|
| 54 |
--line-height-base: 1.75;
|
| 55 |
--line-height-relaxed: 2;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
}
|
| 57 |
|
| 58 |
html {
|
| 59 |
font-size: 16px;
|
| 60 |
-
scroll-behavior: smooth;
|
| 61 |
-webkit-text-size-adjust: 100%;
|
| 62 |
text-size-adjust: 100%;
|
|
|
|
| 63 |
}
|
| 64 |
|
| 65 |
body {
|
|
@@ -68,16 +108,13 @@ body {
|
|
| 68 |
line-height: var(--line-height-base);
|
| 69 |
color: var(--color-text-primary);
|
| 70 |
background-color: var(--color-background);
|
| 71 |
-
|
| 72 |
-
overflow-x: hidden;
|
| 73 |
-webkit-font-smoothing: antialiased;
|
| 74 |
-moz-osx-font-smoothing: grayscale;
|
| 75 |
}
|
| 76 |
|
| 77 |
#root {
|
| 78 |
-
|
| 79 |
-
display: flex;
|
| 80 |
-
flex-direction: column;
|
| 81 |
}
|
| 82 |
|
| 83 |
a {
|
|
@@ -304,6 +341,13 @@ pre code {
|
|
| 304 |
color: #f38ba8;
|
| 305 |
}
|
| 306 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 307 |
table {
|
| 308 |
width: 100%;
|
| 309 |
border-collapse: collapse;
|
|
@@ -325,8 +369,10 @@ th {
|
|
| 325 |
|
| 326 |
hr {
|
| 327 |
border: none;
|
| 328 |
-
|
|
|
|
| 329 |
margin: var(--spacing-2xl) 0;
|
|
|
|
| 330 |
}
|
| 331 |
|
| 332 |
iframe {
|
|
@@ -340,28 +386,6 @@ iframe {
|
|
| 340 |
color: var(--color-background);
|
| 341 |
}
|
| 342 |
|
| 343 |
-
@media (prefers-color-scheme: dark) {
|
| 344 |
-
:root {
|
| 345 |
-
--color-background: oklch(0.263 0 0);
|
| 346 |
-
--color-surface: oklch(0.32 0 0);
|
| 347 |
-
--color-text-primary: #f5f5f7;
|
| 348 |
-
--color-text-secondary: #c5c5d0;
|
| 349 |
-
--color-text-muted: #9898a8;
|
| 350 |
-
--color-accent: #60a5fa;
|
| 351 |
-
--color-accent-hover: #93c5fd;
|
| 352 |
-
--color-border: oklch(0.4 0 0);
|
| 353 |
-
--color-code-background: oklch(0.22 0 0);
|
| 354 |
-
--color-code-text: #e2e8f0;
|
| 355 |
-
}
|
| 356 |
-
|
| 357 |
-
.hljs-comment,
|
| 358 |
-
.hljs-quote,
|
| 359 |
-
.hljs-deletion,
|
| 360 |
-
.hljs-meta {
|
| 361 |
-
color: #8888a0;
|
| 362 |
-
}
|
| 363 |
-
}
|
| 364 |
-
|
| 365 |
@media (max-width: 320px) {
|
| 366 |
html {
|
| 367 |
font-size: 14px;
|
|
@@ -384,4 +408,13 @@ iframe {
|
|
| 384 |
html {
|
| 385 |
font-size: 24px;
|
| 386 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 387 |
}
|
|
|
|
| 22 |
--color-border: #e5e7eb;
|
| 23 |
--color-code-background: #1e1e2e;
|
| 24 |
--color-code-text: #cdd6f4;
|
| 25 |
+
--color-hr: #9ca3af;
|
| 26 |
--font-family-base: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
| 27 |
"Helvetica Neue", Arial, sans-serif;
|
| 28 |
--font-family-mono: "SF Mono", Monaco, "Cascadia Code", "Roboto Mono",
|
|
|
|
| 54 |
--line-height-tight: 1.4;
|
| 55 |
--line-height-base: 1.75;
|
| 56 |
--line-height-relaxed: 2;
|
| 57 |
+
--header-height: 60px;
|
| 58 |
+
--footer-height: 44px;
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
[data-theme="dark"] {
|
| 62 |
+
--color-background: oklch(0.263 0 0);
|
| 63 |
+
--color-surface: oklch(0.32 0 0);
|
| 64 |
+
--color-text-primary: #f5f5f7;
|
| 65 |
+
--color-text-secondary: #c5c5d0;
|
| 66 |
+
--color-text-muted: #9898a8;
|
| 67 |
+
--color-accent: #60a5fa;
|
| 68 |
+
--color-accent-hover: #93c5fd;
|
| 69 |
+
--color-border: oklch(0.4 0 0);
|
| 70 |
+
--color-code-background: oklch(0.22 0 0);
|
| 71 |
+
--color-code-text: #e2e8f0;
|
| 72 |
+
--color-hr: #7a7a8c;
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
@media (prefers-color-scheme: dark) {
|
| 76 |
+
:root:not([data-theme="light"]) {
|
| 77 |
+
--color-background: oklch(0.263 0 0);
|
| 78 |
+
--color-surface: oklch(0.32 0 0);
|
| 79 |
+
--color-text-primary: #f5f5f7;
|
| 80 |
+
--color-text-secondary: #c5c5d0;
|
| 81 |
+
--color-text-muted: #9898a8;
|
| 82 |
+
--color-accent: #60a5fa;
|
| 83 |
+
--color-accent-hover: #93c5fd;
|
| 84 |
+
--color-border: oklch(0.4 0 0);
|
| 85 |
+
--color-code-background: oklch(0.22 0 0);
|
| 86 |
+
--color-code-text: #e2e8f0;
|
| 87 |
+
--color-hr: #7a7a8c;
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
:root:not([data-theme="light"]) .hljs-comment,
|
| 91 |
+
:root:not([data-theme="light"]) .hljs-quote,
|
| 92 |
+
:root:not([data-theme="light"]) .hljs-deletion,
|
| 93 |
+
:root:not([data-theme="light"]) .hljs-meta {
|
| 94 |
+
color: #8888a0;
|
| 95 |
+
}
|
| 96 |
}
|
| 97 |
|
| 98 |
html {
|
| 99 |
font-size: 16px;
|
|
|
|
| 100 |
-webkit-text-size-adjust: 100%;
|
| 101 |
text-size-adjust: 100%;
|
| 102 |
+
height: 100%;
|
| 103 |
}
|
| 104 |
|
| 105 |
body {
|
|
|
|
| 108 |
line-height: var(--line-height-base);
|
| 109 |
color: var(--color-text-primary);
|
| 110 |
background-color: var(--color-background);
|
| 111 |
+
height: 100%;
|
|
|
|
| 112 |
-webkit-font-smoothing: antialiased;
|
| 113 |
-moz-osx-font-smoothing: grayscale;
|
| 114 |
}
|
| 115 |
|
| 116 |
#root {
|
| 117 |
+
height: 100%;
|
|
|
|
|
|
|
| 118 |
}
|
| 119 |
|
| 120 |
a {
|
|
|
|
| 341 |
color: #f38ba8;
|
| 342 |
}
|
| 343 |
|
| 344 |
+
[data-theme="dark"] .hljs-comment,
|
| 345 |
+
[data-theme="dark"] .hljs-quote,
|
| 346 |
+
[data-theme="dark"] .hljs-deletion,
|
| 347 |
+
[data-theme="dark"] .hljs-meta {
|
| 348 |
+
color: #8888a0;
|
| 349 |
+
}
|
| 350 |
+
|
| 351 |
table {
|
| 352 |
width: 100%;
|
| 353 |
border-collapse: collapse;
|
|
|
|
| 369 |
|
| 370 |
hr {
|
| 371 |
border: none;
|
| 372 |
+
height: 0.5px;
|
| 373 |
+
background-color: var(--color-hr);
|
| 374 |
margin: var(--spacing-2xl) 0;
|
| 375 |
+
border-radius: 1px;
|
| 376 |
}
|
| 377 |
|
| 378 |
iframe {
|
|
|
|
| 386 |
color: var(--color-background);
|
| 387 |
}
|
| 388 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 389 |
@media (max-width: 320px) {
|
| 390 |
html {
|
| 391 |
font-size: 14px;
|
|
|
|
| 408 |
html {
|
| 409 |
font-size: 24px;
|
| 410 |
}
|
| 411 |
+
}
|
| 412 |
+
|
| 413 |
+
@keyframes spin {
|
| 414 |
+
from {
|
| 415 |
+
transform: rotate(0deg);
|
| 416 |
+
}
|
| 417 |
+
to {
|
| 418 |
+
transform: rotate(360deg);
|
| 419 |
+
}
|
| 420 |
}
|
src/server/routes/apiRoutes.ts
CHANGED
|
@@ -35,7 +35,8 @@ apiRoutes.get(
|
|
| 35 |
"/posts/:slug",
|
| 36 |
async (request: Request, response: Response): Promise<void> => {
|
| 37 |
try {
|
| 38 |
-
const
|
|
|
|
| 39 |
const post: Post | null = await getPostBySlug(slug);
|
| 40 |
|
| 41 |
if (!post) {
|
|
|
|
| 35 |
"/posts/:slug",
|
| 36 |
async (request: Request, response: Response): Promise<void> => {
|
| 37 |
try {
|
| 38 |
+
const slugParam = request.params.slug;
|
| 39 |
+
const slug: string = Array.isArray(slugParam) ? slugParam[0] : slugParam;
|
| 40 |
const post: Post | null = await getPostBySlug(slug);
|
| 41 |
|
| 42 |
if (!post) {
|
src/shared/config.ts
CHANGED
|
@@ -17,12 +17,15 @@ export const siteConfig = {
|
|
| 17 |
copyrightText: "Copyright",
|
| 18 |
},
|
| 19 |
messages: {
|
| 20 |
-
loading: "
|
| 21 |
noPosts: "There are no post available at this time",
|
| 22 |
postNotFound: "Article not found",
|
| 23 |
backToHome: "Back to Home",
|
| 24 |
readMore: "Read more",
|
| 25 |
-
latestArticles: "Latest
|
|
|
|
|
|
|
|
|
|
| 26 |
},
|
| 27 |
defaults: {
|
| 28 |
postTitle: "Untitled",
|
|
|
|
| 17 |
copyrightText: "Copyright",
|
| 18 |
},
|
| 19 |
messages: {
|
| 20 |
+
loading: "Please wait a moment...",
|
| 21 |
noPosts: "There are no post available at this time",
|
| 22 |
postNotFound: "Article not found",
|
| 23 |
backToHome: "Back to Home",
|
| 24 |
readMore: "Read more",
|
| 25 |
+
latestArticles: "Latest",
|
| 26 |
+
shareArticle: "Share this article",
|
| 27 |
+
previous: "Previous",
|
| 28 |
+
next: "Next",
|
| 29 |
},
|
| 30 |
defaults: {
|
| 31 |
postTitle: "Untitled",
|