hadadrjt commited on
Commit
4524aaa
·
1 Parent(s): 1ace9ba

blog: Bump to 0.0.1 version.

Browse files
.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:20-alpine
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.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": "^3.6.0",
19
  "cors": "^2.8.5",
20
- "express": "^4.21.0",
21
  "front-matter": "^4.0.2",
22
- "highlight.js": "^11.10.0",
23
- "lucide-react": "^0.454.0",
24
- "marked": "^14.1.2",
25
- "marked-highlight": "^2.1.4",
26
  "react": "^18.3.1",
27
  "react-dom": "^18.3.1",
28
  "react-helmet-async": "^2.0.5",
29
- "react-router-dom": "^6.27.0"
30
  },
31
  "devDependencies": {
32
  "@types/cors": "^2.8.17",
33
- "@types/express": "^4.17.21",
34
- "@types/node": "^22.7.5",
35
- "@types/react": "^18.3.11",
36
- "@types/react-dom": "^18.3.1",
37
- "@vitejs/plugin-react": "^4.3.2",
38
- "concurrently": "^9.0.1",
39
- "tsx": "^4.19.1",
40
- "typescript": "^5.6.3",
41
- "vite": "^5.4.9"
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
- <BrowserRouter>
14
- <Layout>
15
- <Routes>
16
- <Route path="/" element={<HomePage />} />
17
- <Route path="/post/:slug" element={<PostPage />} />
18
- </Routes>
19
- </Layout>
20
- </BrowserRouter>
 
 
 
 
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
- "transform var(--transition-fast), box-shadow var(--transition-fast)",
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 { Link } from "react-router-dom";
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-content)",
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 key={post.slug} post={post} />
 
 
 
 
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
- marginTop: "auto",
 
11
  borderTop: "1px solid var(--color-border)",
12
- padding: "var(--spacing-lg) 0",
13
- backgroundColor: "var(--color-surface)",
 
 
 
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
- position: "sticky",
12
- top: 0,
13
  zIndex: 100,
14
  backgroundColor: "var(--color-background)",
15
  borderBottom: "1px solid var(--color-border)",
16
- padding: "var(--spacing-md) 0",
 
 
17
  };
18
 
19
  const headerContainerStyle: CSSProperties = {
20
  maxWidth: "var(--max-width-container)",
21
  margin: "0 auto",
22
- padding: "0 var(--spacing-md)",
23
  display: "flex",
24
  alignItems: "center",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
  justifyContent: "center",
 
 
26
  };
27
 
28
- const logoLinkStyle: CSSProperties = {
29
  display: "flex",
30
  alignItems: "center",
31
  gap: "var(--spacing-sm)",
32
- color: "var(--color-text-primary)",
33
- fontWeight: 600,
34
- fontSize: "var(--font-size-xl)",
 
 
 
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
- <Link to="/" style={logoLinkStyle}>
50
- <img
51
- src="/assets/images/profile.png"
52
- alt={siteConfig.name}
53
- style={profileImageStyle}
54
- />
55
- <span>{siteConfig.description}</span>
56
- </Link>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- minHeight: "100vh",
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}>{children}</main>
 
 
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 (fetchError) {
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 (fetchError) {
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-content)",
15
  margin: "0 auto",
16
- };
17
-
18
- const headingStyle: CSSProperties = {
19
- fontSize: "var(--font-size-3xl)",
20
- fontWeight: 700,
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
- padding: "var(--spacing-2xl)",
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
- padding: "var(--spacing-2xl)",
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
- return (
55
- <div style={containerStyle}>
56
- <Head title={siteConfig.title} description={siteConfig.description} />
57
 
58
- <h1 style={headingStyle}>{siteConfig.messages.latestArticles}</h1>
 
 
59
 
60
- {loading && (
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61
  <div style={loadingContainerStyle}>
62
  <Loader size={32} style={spinnerStyle} />
63
  <span>{siteConfig.messages.loading}</span>
64
  </div>
65
- )}
 
 
66
 
67
- {error && (
 
 
 
68
  <div style={errorContainerStyle}>
69
  <AlertCircle size={32} />
70
  <span>{error}</span>
71
  </div>
72
- )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73
 
74
- {!loading && !error && <PostList posts={posts} />}
 
 
75
 
76
- <style>{`
77
- @keyframes spin {
78
- from { transform: rotate(0deg); }
79
- to { transform: rotate(360deg); }
80
- }
81
- `}</style>
 
 
 
 
 
 
 
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
- padding: "var(--spacing-2xl)",
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
- padding: "var(--spacing-2xl)",
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={loadingContainerStyle}>
55
- <Loader size={32} style={spinnerStyle} />
56
- <span>{siteConfig.messages.loading}</span>
57
- <style>{`
58
- @keyframes spin {
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={errorContainerStyle}>
70
- <AlertCircle size={32} />
71
- <span>{error || siteConfig.messages.postNotFound}</span>
72
- <Link to="/" style={backLinkStyle}>
73
- <ArrowLeft size={16} />
74
- {siteConfig.messages.backToHome}
75
- </Link>
 
 
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
- min-height: 100vh;
72
- overflow-x: hidden;
73
  -webkit-font-smoothing: antialiased;
74
  -moz-osx-font-smoothing: grayscale;
75
  }
76
 
77
  #root {
78
- min-height: 100vh;
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
- border-top: 1px solid var(--color-border);
 
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 slug: string = request.params.slug;
 
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: "Loading article...",
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 Articles",
 
 
 
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",