hadadrjt commited on
Commit
fc1907d
ยท
1 Parent(s): 66eb087

blog: Bump to 0.0.4 version.

Browse files
README.md CHANGED
@@ -9,5 +9,5 @@ sdk: docker
9
  app_port: 3000
10
  pinned: false
11
  models:
12
- - openai/gpt-oss-20b
13
  ---
 
9
  app_port: 3000
10
  pinned: false
11
  models:
12
+ - nvidia/NVIDIA-Nemotron-3-Nano-30B-A3B-BF16
13
  ---
index.html CHANGED
@@ -8,10 +8,9 @@
8
  <head>
9
  <meta charset="UTF-8" />
10
  <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5.0, minimum-scale=1.0" />
11
- <meta name="description" content="Personal Blog" />
12
  <meta name="theme-color" content="#ffffff" />
13
  <link rel="icon" type="image/x-icon" href="/assets/images/favicon.ico" />
14
- <title>Hadad Darajat</title>
15
  </head>
16
  <body>
17
  <div id="root"></div>
 
8
  <head>
9
  <meta charset="UTF-8" />
10
  <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5.0, minimum-scale=1.0" />
 
11
  <meta name="theme-color" content="#ffffff" />
12
  <link rel="icon" type="image/x-icon" href="/assets/images/favicon.ico" />
13
+ <!--SEO_META_TAGS-->
14
  </head>
15
  <body>
16
  <div id="root"></div>
package.json CHANGED
@@ -1,6 +1,6 @@
1
  {
2
  "name": "personal-blog",
3
- "version": "0.0.3",
4
  "private": true,
5
  "author": "Hadad Darajat",
6
  "type": "module",
@@ -17,27 +17,27 @@
17
  "dependencies": {
18
  "chokidar": "^4.0.3",
19
  "cors": "^2.8.5",
20
- "express": "^5.0.1",
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.3",
41
- "vite": "^6.0.7"
42
  }
43
  }
 
1
  {
2
  "name": "personal-blog",
3
+ "version": "0.0.4",
4
  "private": true,
5
  "author": "Hadad Darajat",
6
  "type": "module",
 
17
  "dependencies": {
18
  "chokidar": "^4.0.3",
19
  "cors": "^2.8.5",
20
+ "express": "^5.1.0",
21
  "front-matter": "^4.0.2",
22
  "highlight.js": "^11.11.1",
23
+ "lucide-react": "^0.487.0",
24
+ "marked": "^15.0.7",
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.5.0"
30
  },
31
  "devDependencies": {
32
  "@types/cors": "^2.8.17",
33
+ "@types/express": "^5.0.1",
34
+ "@types/node": "^22.14.0",
35
+ "@types/react": "^18.3.20",
36
+ "@types/react-dom": "^18.3.6",
37
  "@vitejs/plugin-react": "^4.3.4",
38
  "concurrently": "^9.1.2",
39
+ "tsx": "^4.19.3",
40
+ "typescript": "^5.8.3",
41
+ "vite": "^6.2.5"
42
  }
43
  }
public/BingSiteAuth.xml ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ <?xml version="1.0"?>
2
+ <users>
3
+ <user>867CFEF9C4B44CFD5EEA1A6C65ED3F6B</user>
4
+ </users>
public/google15aba15fe250d693.html ADDED
@@ -0,0 +1 @@
 
 
1
+ google-site-verification: google15aba15fe250d693.html
src/client/App.tsx CHANGED
@@ -7,6 +7,7 @@ import { BrowserRouter, Routes, Route } from "react-router-dom";
7
  import { ThemeProvider } from "./contexts/ThemeContext";
8
  import { PageProvider } from "./contexts/PageContext";
9
  import { AnalysisProvider } from "./contexts/AnalysisContext";
 
10
  import { Layout } from "./components/layout/Layout";
11
  import { HomePage } from "./pages/HomePage";
12
  import { PostPage } from "./pages/PostPage";
@@ -16,14 +17,16 @@ export const App = (): JSX.Element => {
16
  <ThemeProvider>
17
  <PageProvider>
18
  <AnalysisProvider>
19
- <BrowserRouter>
20
- <Layout>
21
- <Routes>
22
- <Route path="/" element={<HomePage />} />
23
- <Route path="/post/:slug" element={<PostPage />} />
24
- </Routes>
25
- </Layout>
26
- </BrowserRouter>
 
 
27
  </AnalysisProvider>
28
  </PageProvider>
29
  </ThemeProvider>
 
7
  import { ThemeProvider } from "./contexts/ThemeContext";
8
  import { PageProvider } from "./contexts/PageContext";
9
  import { AnalysisProvider } from "./contexts/AnalysisContext";
10
+ import { TranslationProvider } from "./contexts/TranslationContext";
11
  import { Layout } from "./components/layout/Layout";
12
  import { HomePage } from "./pages/HomePage";
13
  import { PostPage } from "./pages/PostPage";
 
17
  <ThemeProvider>
18
  <PageProvider>
19
  <AnalysisProvider>
20
+ <TranslationProvider>
21
+ <BrowserRouter>
22
+ <Layout>
23
+ <Routes>
24
+ <Route path="/" element={<HomePage />} />
25
+ <Route path="/post/:slug" element={<PostPage />} />
26
+ </Routes>
27
+ </Layout>
28
+ </BrowserRouter>
29
+ </TranslationProvider>
30
  </AnalysisProvider>
31
  </PageProvider>
32
  </ThemeProvider>
src/client/components/ai/AnalysisModal.tsx CHANGED
@@ -243,10 +243,11 @@ export const AnalysisModal = ({ analysisState, onClose }: AnalysisModalProps): J
243
  const reasoningContentRef = useRef<HTMLDivElement>(null);
244
  const [isReasoningExpanded, setIsReasoningExpanded] = useState<boolean>(false);
245
  const [userToggledReasoning, setUserToggledReasoning] = useState<boolean>(false);
246
- const [userHasScrolledContent, setUserHasScrolledContent] = useState<boolean>(false);
247
- const [userHasScrolledReasoning, setUserHasScrolledReasoning] = useState<boolean>(false);
248
- const lastContentScrollTopRef = useRef<number>(0);
249
- const lastReasoningScrollTopRef = useRef<number>(0);
 
250
  const hasAutoCollapsedRef = useRef<boolean>(false);
251
 
252
  useEffect((): void => {
@@ -265,56 +266,54 @@ export const AnalysisModal = ({ analysisState, onClose }: AnalysisModalProps): J
265
  }, [analysisState.isLoading, analysisState.isReasoningComplete, analysisState.reasoning, userToggledReasoning]);
266
 
267
  useEffect((): void => {
268
- if (contentRef.current && analysisState.content && !userHasScrolledContent) {
269
- const element = contentRef.current;
270
- const isNearBottom = element.scrollHeight - element.scrollTop - element.clientHeight < 100;
271
- if (isNearBottom || lastContentScrollTopRef.current === 0) {
272
- element.scrollTop = element.scrollHeight;
273
- }
 
 
 
274
  }
275
- }, [analysisState.content, userHasScrolledContent]);
 
 
276
 
277
  useEffect((): void => {
278
- if (reasoningContentRef.current && analysisState.reasoning && !userHasScrolledReasoning && isReasoningExpanded) {
279
- const element = reasoningContentRef.current;
280
- const isNearBottom = element.scrollHeight - element.scrollTop - element.clientHeight < 50;
281
- if (isNearBottom || lastReasoningScrollTopRef.current === 0) {
282
- element.scrollTop = element.scrollHeight;
283
- }
284
  }
285
- }, [analysisState.reasoning, userHasScrolledReasoning, isReasoningExpanded]);
 
 
 
 
 
 
 
 
 
286
 
287
  const handleContentScroll = useCallback((): void => {
288
  if (!contentRef.current) {
289
  return;
290
  }
 
291
  const element = contentRef.current;
292
- const currentScrollTop = element.scrollTop;
293
- if (currentScrollTop < lastContentScrollTopRef.current - 10) {
294
- setUserHasScrolledContent(true);
295
- }
296
- const isAtBottom = element.scrollHeight - currentScrollTop - element.clientHeight < 20;
297
- if (isAtBottom && analysisState.isLoading) {
298
- setUserHasScrolledContent(false);
299
- }
300
- lastContentScrollTopRef.current = currentScrollTop;
301
- }, [analysisState.isLoading]);
302
 
303
  const handleReasoningScroll = useCallback((): void => {
304
  if (!reasoningContentRef.current) {
305
  return;
306
  }
 
307
  const element = reasoningContentRef.current;
308
- const currentScrollTop = element.scrollTop;
309
- if (currentScrollTop < lastReasoningScrollTopRef.current - 10) {
310
- setUserHasScrolledReasoning(true);
311
- }
312
- const isAtBottom = element.scrollHeight - currentScrollTop - element.clientHeight < 20;
313
- if (isAtBottom && analysisState.isLoading && !analysisState.isReasoningComplete) {
314
- setUserHasScrolledReasoning(false);
315
- }
316
- lastReasoningScrollTopRef.current = currentScrollTop;
317
- }, [analysisState.isLoading, analysisState.isReasoningComplete]);
318
 
319
  useEffect((): (() => void) => {
320
  const handleEscape = (event: KeyboardEvent): void => {
@@ -332,13 +331,13 @@ export const AnalysisModal = ({ analysisState, onClose }: AnalysisModalProps): J
332
 
333
  useEffect((): void => {
334
  if (!analysisState.isOpen) {
335
- setUserHasScrolledContent(false);
336
- setUserHasScrolledReasoning(false);
337
- setUserToggledReasoning(false);
338
  setIsReasoningExpanded(false);
339
- lastContentScrollTopRef.current = 0;
340
- lastReasoningScrollTopRef.current = 0;
341
  hasAutoCollapsedRef.current = false;
 
 
 
 
342
  }
343
  }, [analysisState.isOpen]);
344
 
 
243
  const reasoningContentRef = useRef<HTMLDivElement>(null);
244
  const [isReasoningExpanded, setIsReasoningExpanded] = useState<boolean>(false);
245
  const [userToggledReasoning, setUserToggledReasoning] = useState<boolean>(false);
246
+
247
+ const shouldAutoScrollContentRef = useRef<boolean>(true);
248
+ const shouldAutoScrollReasoningRef = useRef<boolean>(true);
249
+ const previousContentHeightRef = useRef<number>(0);
250
+ const previousReasoningHeightRef = useRef<number>(0);
251
  const hasAutoCollapsedRef = useRef<boolean>(false);
252
 
253
  useEffect((): void => {
 
266
  }, [analysisState.isLoading, analysisState.isReasoningComplete, analysisState.reasoning, userToggledReasoning]);
267
 
268
  useEffect((): void => {
269
+ if (!contentRef.current) {
270
+ return;
271
+ }
272
+
273
+ const element = contentRef.current;
274
+ const currentHeight = element.scrollHeight;
275
+
276
+ if (shouldAutoScrollContentRef.current && analysisState.content) {
277
+ element.scrollTop = currentHeight;
278
  }
279
+
280
+ previousContentHeightRef.current = currentHeight;
281
+ }, [analysisState.content]);
282
 
283
  useEffect((): void => {
284
+ if (!reasoningContentRef.current || !isReasoningExpanded) {
285
+ return;
 
 
 
 
286
  }
287
+
288
+ const element = reasoningContentRef.current;
289
+ const currentHeight = element.scrollHeight;
290
+
291
+ if (shouldAutoScrollReasoningRef.current && analysisState.reasoning) {
292
+ element.scrollTop = currentHeight;
293
+ }
294
+
295
+ previousReasoningHeightRef.current = currentHeight;
296
+ }, [analysisState.reasoning, isReasoningExpanded]);
297
 
298
  const handleContentScroll = useCallback((): void => {
299
  if (!contentRef.current) {
300
  return;
301
  }
302
+
303
  const element = contentRef.current;
304
+ const isAtBottom = element.scrollHeight - element.scrollTop - element.clientHeight < 50;
305
+ shouldAutoScrollContentRef.current = isAtBottom;
306
+ }, []);
 
 
 
 
 
 
 
307
 
308
  const handleReasoningScroll = useCallback((): void => {
309
  if (!reasoningContentRef.current) {
310
  return;
311
  }
312
+
313
  const element = reasoningContentRef.current;
314
+ const isAtBottom = element.scrollHeight - element.scrollTop - element.clientHeight < 50;
315
+ shouldAutoScrollReasoningRef.current = isAtBottom;
316
+ }, []);
 
 
 
 
 
 
 
317
 
318
  useEffect((): (() => void) => {
319
  const handleEscape = (event: KeyboardEvent): void => {
 
331
 
332
  useEffect((): void => {
333
  if (!analysisState.isOpen) {
 
 
 
334
  setIsReasoningExpanded(false);
335
+ setUserToggledReasoning(false);
 
336
  hasAutoCollapsedRef.current = false;
337
+ shouldAutoScrollContentRef.current = true;
338
+ shouldAutoScrollReasoningRef.current = true;
339
+ previousContentHeightRef.current = 0;
340
+ previousReasoningHeightRef.current = 0;
341
  }
342
  }, [analysisState.isOpen]);
343
 
src/client/components/ai/TranslationModal.tsx ADDED
@@ -0,0 +1,613 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //
2
+ // SPDX-FileCopyrightText: Hadad <hadad@linuxmail.org>
3
+ // SPDX-License-Identifier: Apache-2.0
4
+ //
5
+
6
+ import { useEffect, useRef, useState, useCallback } from "react";
7
+ import { createPortal } from "react-dom";
8
+ import { X, Globe, AlertCircle, Brain, ChevronDown, ChevronUp } from "lucide-react";
9
+ import { siteConfig } from "@shared/config";
10
+ import { renderMarkdown } from "../../utils/markdownRenderer";
11
+ import type { TranslationState, SupportedLanguage } from "@shared/types";
12
+ import type { CSSProperties } from "react";
13
+
14
+ interface TranslationModalProps {
15
+ translationState: TranslationState;
16
+ onClose: () => void;
17
+ onSelectLanguage: (language: SupportedLanguage) => void;
18
+ }
19
+
20
+ const overlayStyle: CSSProperties = {
21
+ position: "fixed",
22
+ top: 0,
23
+ left: 0,
24
+ right: 0,
25
+ bottom: 0,
26
+ zIndex: 1000,
27
+ display: "flex",
28
+ alignItems: "flex-end",
29
+ justifyContent: "center",
30
+ padding: "0 var(--spacing-md)",
31
+ pointerEvents: "none",
32
+ };
33
+
34
+ const panelStyle: CSSProperties = {
35
+ width: "100%",
36
+ maxWidth: "var(--max-width-container)",
37
+ height: "70vh",
38
+ backgroundColor: "var(--color-background)",
39
+ borderRadius: "var(--border-radius-lg) var(--border-radius-lg) 0 0",
40
+ boxShadow: "0 -8px 32px rgba(0, 0, 0, 0.2)",
41
+ display: "flex",
42
+ flexDirection: "column",
43
+ overflow: "hidden",
44
+ pointerEvents: "auto",
45
+ animation: "slideUp 0.4s cubic-bezier(0.16, 1, 0.3, 1)",
46
+ border: "1px solid var(--color-border)",
47
+ borderBottom: "none",
48
+ };
49
+
50
+ const headerStyle: CSSProperties = {
51
+ display: "flex",
52
+ alignItems: "center",
53
+ justifyContent: "space-between",
54
+ padding: "var(--spacing-md) var(--spacing-lg)",
55
+ borderBottom: "1px solid var(--color-border)",
56
+ backgroundColor: "var(--color-surface)",
57
+ flexShrink: 0,
58
+ };
59
+
60
+ const headerLeftStyle: CSSProperties = {
61
+ display: "flex",
62
+ alignItems: "center",
63
+ gap: "var(--spacing-sm)",
64
+ };
65
+
66
+ const headerIconContainerStyle: CSSProperties = {
67
+ display: "flex",
68
+ alignItems: "center",
69
+ justifyContent: "center",
70
+ width: "32px",
71
+ height: "32px",
72
+ borderRadius: "var(--border-radius-md)",
73
+ backgroundColor: "var(--color-accent)",
74
+ color: "#ffffff",
75
+ };
76
+
77
+ const headerTitleStyle: CSSProperties = {
78
+ fontSize: "var(--font-size-base)",
79
+ fontWeight: 600,
80
+ color: "var(--color-text-primary)",
81
+ };
82
+
83
+ const headerSubtitleStyle: CSSProperties = {
84
+ fontSize: "var(--font-size-xs)",
85
+ color: "var(--color-text-muted)",
86
+ };
87
+
88
+ const closeButtonStyle: CSSProperties = {
89
+ display: "flex",
90
+ alignItems: "center",
91
+ justifyContent: "center",
92
+ width: "32px",
93
+ height: "32px",
94
+ borderRadius: "var(--border-radius-md)",
95
+ border: "2px solid var(--color-border)",
96
+ backgroundColor: "var(--color-surface)",
97
+ color: "var(--color-text-muted)",
98
+ cursor: "pointer",
99
+ transition: "all var(--transition-fast)",
100
+ };
101
+
102
+ const contentContainerStyle: CSSProperties = {
103
+ flex: 1,
104
+ overflowY: "auto",
105
+ overflowX: "hidden",
106
+ scrollbarGutter: "stable",
107
+ };
108
+
109
+ const contentInnerStyle: CSSProperties = {
110
+ padding: "var(--spacing-lg)",
111
+ display: "flex",
112
+ flexDirection: "column",
113
+ gap: "var(--spacing-lg)",
114
+ };
115
+
116
+ const languageSelectorContainerStyle: CSSProperties = {
117
+ display: "flex",
118
+ flexDirection: "column",
119
+ gap: "var(--spacing-md)",
120
+ };
121
+
122
+ const languageButtonsContainerStyle: CSSProperties = {
123
+ display: "flex",
124
+ flexDirection: "row",
125
+ flexWrap: "wrap",
126
+ gap: "var(--spacing-sm)",
127
+ };
128
+
129
+ const languageButtonStyle: CSSProperties = {
130
+ display: "flex",
131
+ alignItems: "center",
132
+ gap: "var(--spacing-xs)",
133
+ padding: "var(--spacing-md) var(--spacing-lg)",
134
+ borderRadius: "var(--border-radius-md)",
135
+ border: "2px solid var(--color-border)",
136
+ backgroundColor: "var(--color-surface)",
137
+ color: "var(--color-text-primary)",
138
+ cursor: "pointer",
139
+ transition: "all var(--transition-fast)",
140
+ fontSize: "var(--font-size-base)",
141
+ fontWeight: 500,
142
+ };
143
+
144
+ const languageButtonHoverStyle: CSSProperties = {
145
+ borderColor: "var(--color-accent)",
146
+ backgroundColor: "var(--color-background)",
147
+ };
148
+
149
+ const loadingContainerStyle: CSSProperties = {
150
+ display: "flex",
151
+ flexDirection: "column",
152
+ alignItems: "center",
153
+ justifyContent: "center",
154
+ padding: "var(--spacing-2xl)",
155
+ gap: "var(--spacing-md)",
156
+ color: "var(--color-text-muted)",
157
+ };
158
+
159
+ const loadingSpinnerStyle: CSSProperties = {
160
+ animation: "spin 1s linear infinite",
161
+ color: "var(--color-accent)",
162
+ };
163
+
164
+ const loadingTextStyle: CSSProperties = {
165
+ fontSize: "var(--font-size-sm)",
166
+ display: "flex",
167
+ alignItems: "center",
168
+ gap: "var(--spacing-sm)",
169
+ };
170
+
171
+ const loadingDotsStyle: CSSProperties = {
172
+ display: "inline-flex",
173
+ gap: "4px",
174
+ };
175
+
176
+ const errorContainerStyle: CSSProperties = {
177
+ display: "flex",
178
+ flexDirection: "column",
179
+ alignItems: "center",
180
+ justifyContent: "center",
181
+ padding: "var(--spacing-2xl)",
182
+ gap: "var(--spacing-md)",
183
+ color: "var(--color-text-muted)",
184
+ textAlign: "center",
185
+ };
186
+
187
+ const errorIconStyle: CSSProperties = {
188
+ color: "#ef4444",
189
+ };
190
+
191
+ const reasoningSectionStyle: CSSProperties = {
192
+ borderRadius: "var(--border-radius-md)",
193
+ border: "1px solid var(--color-border)",
194
+ backgroundColor: "var(--color-surface)",
195
+ overflow: "hidden",
196
+ };
197
+
198
+ const reasoningHeaderStyle: CSSProperties = {
199
+ display: "flex",
200
+ alignItems: "center",
201
+ gap: "var(--spacing-sm)",
202
+ padding: "var(--spacing-sm) var(--spacing-md)",
203
+ cursor: "pointer",
204
+ userSelect: "none",
205
+ backgroundColor: "var(--color-surface)",
206
+ transition: "background-color var(--transition-fast)",
207
+ };
208
+
209
+ const reasoningIconStyle: CSSProperties = {
210
+ color: "var(--color-accent)",
211
+ flexShrink: 0,
212
+ };
213
+
214
+ const reasoningTitleStyle: CSSProperties = {
215
+ flex: 1,
216
+ fontSize: "var(--font-size-sm)",
217
+ fontWeight: 600,
218
+ color: "var(--color-text-primary)",
219
+ display: "flex",
220
+ alignItems: "center",
221
+ gap: "var(--spacing-xs)",
222
+ };
223
+
224
+ const pulsingDotStyle: CSSProperties = {
225
+ width: "6px",
226
+ height: "6px",
227
+ borderRadius: "50%",
228
+ backgroundColor: "var(--color-accent)",
229
+ animation: "pulse 1.5s ease-in-out infinite",
230
+ };
231
+
232
+ const chevronStyle: CSSProperties = {
233
+ color: "var(--color-text-muted)",
234
+ flexShrink: 0,
235
+ transition: "transform var(--transition-fast)",
236
+ };
237
+
238
+ const reasoningContentStyle: CSSProperties = {
239
+ maxHeight: "350px",
240
+ overflowY: "auto",
241
+ padding: "var(--spacing-md)",
242
+ borderTop: "1px solid var(--color-border)",
243
+ scrollbarGutter: "stable",
244
+ };
245
+
246
+ const reasoningTextStyle: CSSProperties = {
247
+ fontSize: "var(--font-size-sm)",
248
+ color: "var(--color-text-secondary)",
249
+ lineHeight: 1.7,
250
+ };
251
+
252
+ const translationContentStyle: CSSProperties = {
253
+ fontSize: "var(--font-size-base)",
254
+ color: "var(--color-text-primary)",
255
+ lineHeight: 1.8,
256
+ };
257
+
258
+ const streamingIndicatorStyle: CSSProperties = {
259
+ display: "flex",
260
+ alignItems: "center",
261
+ gap: "var(--spacing-sm)",
262
+ padding: "var(--spacing-sm) 0",
263
+ color: "var(--color-text-muted)",
264
+ fontSize: "var(--font-size-sm)",
265
+ };
266
+
267
+ const streamingDotStyle: CSSProperties = {
268
+ width: "8px",
269
+ height: "8px",
270
+ borderRadius: "50%",
271
+ backgroundColor: "var(--color-accent)",
272
+ animation: "pulse 1s ease-in-out infinite",
273
+ };
274
+
275
+ const getHeaderTitle = (translationState: TranslationState): string => {
276
+ if (!translationState.targetLanguage) {
277
+ return siteConfig.messages.translationReady;
278
+ }
279
+ if (translationState.isLoading) {
280
+ return siteConfig.messages.translating;
281
+ }
282
+ return siteConfig.messages.translationComplete;
283
+ };
284
+
285
+ const getHeaderSubtitle = (translationState: TranslationState): string => {
286
+ if (!translationState.targetLanguage) {
287
+ return siteConfig.messages.selectLanguage;
288
+ }
289
+ return siteConfig.languages[translationState.targetLanguage].name;
290
+ };
291
+
292
+ export const TranslationModal = ({ translationState, onClose, onSelectLanguage }: TranslationModalProps): JSX.Element | null => {
293
+ const contentRef = useRef<HTMLDivElement>(null);
294
+ const reasoningContentRef = useRef<HTMLDivElement>(null);
295
+ const [isReasoningExpanded, setIsReasoningExpanded] = useState<boolean>(false);
296
+ const [userToggledReasoning, setUserToggledReasoning] = useState<boolean>(false);
297
+ const [hoveredLanguage, setHoveredLanguage] = useState<SupportedLanguage | null>(null);
298
+
299
+ const shouldAutoScrollContentRef = useRef<boolean>(true);
300
+ const shouldAutoScrollReasoningRef = useRef<boolean>(true);
301
+
302
+ useEffect((): void => {
303
+ if (userToggledReasoning) {
304
+ return;
305
+ }
306
+
307
+ if (translationState.isLoading && translationState.reasoning.length > 0 && !translationState.isReasoningComplete) {
308
+ setIsReasoningExpanded(true);
309
+ }
310
+
311
+ if (translationState.isReasoningComplete && translationState.reasoning.length > 0 && !userToggledReasoning) {
312
+ setIsReasoningExpanded(false);
313
+ }
314
+ }, [translationState.isLoading, translationState.isReasoningComplete, translationState.reasoning, userToggledReasoning]);
315
+
316
+ useEffect((): void => {
317
+ if (!contentRef.current) {
318
+ return;
319
+ }
320
+
321
+ const element = contentRef.current;
322
+
323
+ if (shouldAutoScrollContentRef.current && translationState.content) {
324
+ element.scrollTop = element.scrollHeight;
325
+ }
326
+ }, [translationState.content]);
327
+
328
+ useEffect((): void => {
329
+ if (!reasoningContentRef.current || !isReasoningExpanded) {
330
+ return;
331
+ }
332
+
333
+ const element = reasoningContentRef.current;
334
+
335
+ if (shouldAutoScrollReasoningRef.current && translationState.reasoning) {
336
+ element.scrollTop = element.scrollHeight;
337
+ }
338
+ }, [translationState.reasoning, isReasoningExpanded]);
339
+
340
+ const handleContentScroll = useCallback((): void => {
341
+ if (!contentRef.current) {
342
+ return;
343
+ }
344
+
345
+ const element = contentRef.current;
346
+ const isAtBottom = element.scrollHeight - element.scrollTop - element.clientHeight < 50;
347
+ shouldAutoScrollContentRef.current = isAtBottom;
348
+ }, []);
349
+
350
+ const handleReasoningScroll = useCallback((): void => {
351
+ if (!reasoningContentRef.current) {
352
+ return;
353
+ }
354
+
355
+ const element = reasoningContentRef.current;
356
+ const isAtBottom = element.scrollHeight - element.scrollTop - element.clientHeight < 50;
357
+ shouldAutoScrollReasoningRef.current = isAtBottom;
358
+ }, []);
359
+
360
+ useEffect((): (() => void) => {
361
+ const handleEscape = (event: KeyboardEvent): void => {
362
+ if (event.key === "Escape") {
363
+ onClose();
364
+ }
365
+ };
366
+
367
+ document.addEventListener("keydown", handleEscape);
368
+
369
+ return (): void => {
370
+ document.removeEventListener("keydown", handleEscape);
371
+ };
372
+ }, [onClose]);
373
+
374
+ useEffect((): void => {
375
+ if (!translationState.isOpen) {
376
+ setIsReasoningExpanded(false);
377
+ setUserToggledReasoning(false);
378
+ setHoveredLanguage(null);
379
+ shouldAutoScrollContentRef.current = true;
380
+ shouldAutoScrollReasoningRef.current = true;
381
+ }
382
+ }, [translationState.isOpen]);
383
+
384
+ if (!translationState.isOpen) {
385
+ return null;
386
+ }
387
+
388
+ const handleReasoningToggle = (): void => {
389
+ setUserToggledReasoning(true);
390
+ setIsReasoningExpanded((previous) => !previous);
391
+ };
392
+
393
+ const handleLanguageSelect = (language: SupportedLanguage): void => {
394
+ if (!translationState.isLoading) {
395
+ onSelectLanguage(language);
396
+ }
397
+ };
398
+
399
+ const renderLanguageSelector = (): JSX.Element | null => {
400
+ if (translationState.targetLanguage) {
401
+ return null;
402
+ }
403
+
404
+ const languages: Array<{ code: SupportedLanguage; name: string; flag: string }> = [
405
+ { code: "id", name: siteConfig.languages.id.name, flag: siteConfig.languages.id.flag },
406
+ { code: "zh", name: siteConfig.languages.zh.name, flag: siteConfig.languages.zh.flag },
407
+ { code: "ja", name: siteConfig.languages.ja.name, flag: siteConfig.languages.ja.flag },
408
+ { code: "fr", name: siteConfig.languages.fr.name, flag: siteConfig.languages.fr.flag },
409
+ { code: "es", name: siteConfig.languages.es.name, flag: siteConfig.languages.es.flag },
410
+ ];
411
+
412
+ return (
413
+ <div style={languageSelectorContainerStyle}>
414
+ <div style={languageButtonsContainerStyle}>
415
+ {languages.map((language) => (
416
+ <button
417
+ key={language.code}
418
+ type="button"
419
+ onClick={() => handleLanguageSelect(language.code)}
420
+ onMouseEnter={() => setHoveredLanguage(language.code)}
421
+ onMouseLeave={() => setHoveredLanguage(null)}
422
+ disabled={translationState.isLoading}
423
+ style={{
424
+ ...languageButtonStyle,
425
+ ...(hoveredLanguage === language.code ? languageButtonHoverStyle : {}),
426
+ }}
427
+ >
428
+ <span>{language.flag}</span>
429
+ <span>{language.name}</span>
430
+ </button>
431
+ ))}
432
+ </div>
433
+ </div>
434
+ );
435
+ };
436
+
437
+ const renderSelectedLanguage = (): JSX.Element | null => {
438
+ if (!translationState.targetLanguage) {
439
+ return null;
440
+ }
441
+
442
+ const language = siteConfig.languages[translationState.targetLanguage];
443
+
444
+ return (
445
+ <div style={languageSelectorContainerStyle}>
446
+ <div style={languageButtonsContainerStyle}>
447
+ <button
448
+ type="button"
449
+ disabled
450
+ style={{
451
+ ...languageButtonStyle,
452
+ borderColor: "var(--color-accent)",
453
+ backgroundColor: "var(--color-accent)",
454
+ color: "#ffffff",
455
+ cursor: "default",
456
+ opacity: 1,
457
+ }}
458
+ >
459
+ <span>{language.flag}</span>
460
+ <span>{language.name}</span>
461
+ </button>
462
+ </div>
463
+ </div>
464
+ );
465
+ };
466
+
467
+ const renderReasoningSection = (): JSX.Element | null => {
468
+ if (!translationState.reasoning) {
469
+ return null;
470
+ }
471
+
472
+ const reasoningHtml = renderMarkdown(translationState.reasoning);
473
+
474
+ return (
475
+ <div style={reasoningSectionStyle}>
476
+ <div
477
+ style={reasoningHeaderStyle}
478
+ onClick={handleReasoningToggle}
479
+ role="button"
480
+ tabIndex={0}
481
+ onKeyDown={(event): void => {
482
+ if (event.key === "Enter" || event.key === " ") {
483
+ handleReasoningToggle();
484
+ }
485
+ }}
486
+ >
487
+ <Brain size={16} style={reasoningIconStyle} />
488
+ <span style={reasoningTitleStyle}>
489
+ {siteConfig.messages.reasoning}
490
+ {translationState.isLoading && !translationState.isReasoningComplete && (
491
+ <span style={pulsingDotStyle} />
492
+ )}
493
+ </span>
494
+ {isReasoningExpanded ? (
495
+ <ChevronUp size={16} style={chevronStyle} />
496
+ ) : (
497
+ <ChevronDown size={16} style={chevronStyle} />
498
+ )}
499
+ </div>
500
+ {isReasoningExpanded && (
501
+ <div
502
+ ref={reasoningContentRef}
503
+ style={reasoningContentStyle}
504
+ onScroll={handleReasoningScroll}
505
+ >
506
+ <div
507
+ style={reasoningTextStyle}
508
+ dangerouslySetInnerHTML={{ __html: reasoningHtml }}
509
+ />
510
+ </div>
511
+ )}
512
+ </div>
513
+ );
514
+ };
515
+
516
+ const renderContent = (): JSX.Element => {
517
+ if (translationState.error) {
518
+ return (
519
+ <div style={errorContainerStyle}>
520
+ <AlertCircle size={40} style={errorIconStyle} />
521
+ <span>{translationState.error}</span>
522
+ </div>
523
+ );
524
+ }
525
+
526
+ if (translationState.isLoading && !translationState.reasoning && !translationState.content && translationState.targetLanguage) {
527
+ return (
528
+ <div style={loadingContainerStyle}>
529
+ <Globe size={40} style={loadingSpinnerStyle} />
530
+ <div style={loadingTextStyle}>
531
+ <span>{siteConfig.messages.translating}</span>
532
+ <span style={loadingDotsStyle}>
533
+ <span style={{ ...pulsingDotStyle, animationDelay: "0s" }} />
534
+ <span style={{ ...pulsingDotStyle, animationDelay: "0.2s" }} />
535
+ <span style={{ ...pulsingDotStyle, animationDelay: "0.4s" }} />
536
+ </span>
537
+ </div>
538
+ </div>
539
+ );
540
+ }
541
+
542
+ if (!translationState.targetLanguage) {
543
+ return (
544
+ <div style={contentInnerStyle}>
545
+ {renderLanguageSelector()}
546
+ </div>
547
+ );
548
+ }
549
+
550
+ const contentHtml = translationState.content ? renderMarkdown(translationState.content) : "";
551
+
552
+ return (
553
+ <div style={contentInnerStyle}>
554
+ {renderSelectedLanguage()}
555
+ {renderReasoningSection()}
556
+ {translationState.content && (
557
+ <div
558
+ style={translationContentStyle}
559
+ dangerouslySetInnerHTML={{ __html: contentHtml }}
560
+ />
561
+ )}
562
+ {translationState.isLoading && translationState.content && (
563
+ <div style={streamingIndicatorStyle}>
564
+ <span style={streamingDotStyle} />
565
+ <span>{siteConfig.messages.translating}</span>
566
+ </div>
567
+ )}
568
+ </div>
569
+ );
570
+ };
571
+
572
+ const headerTitle = getHeaderTitle(translationState);
573
+ const headerSubtitle = getHeaderSubtitle(translationState);
574
+
575
+ const modalContent = (
576
+ <div style={overlayStyle}>
577
+ <div style={panelStyle}>
578
+ <div style={headerStyle}>
579
+ <div style={headerLeftStyle}>
580
+ <div style={headerIconContainerStyle}>
581
+ <Globe size={16} />
582
+ </div>
583
+ <div>
584
+ <div style={headerTitleStyle}>
585
+ {headerTitle}
586
+ </div>
587
+ <div style={headerSubtitleStyle}>
588
+ {headerSubtitle}
589
+ </div>
590
+ </div>
591
+ </div>
592
+ <button
593
+ type="button"
594
+ onClick={onClose}
595
+ style={closeButtonStyle}
596
+ title={siteConfig.messages.closeTranslation}
597
+ >
598
+ <X size={18} />
599
+ </button>
600
+ </div>
601
+ <div
602
+ ref={contentRef}
603
+ style={contentContainerStyle}
604
+ onScroll={handleContentScroll}
605
+ >
606
+ {renderContent()}
607
+ </div>
608
+ </div>
609
+ </div>
610
+ );
611
+
612
+ return createPortal(modalContent, document.body);
613
+ };
src/client/components/common/Head.tsx CHANGED
@@ -28,18 +28,18 @@ export const Head = ({
28
  <Helmet>
29
  <title>{fullTitle}</title>
30
  <meta name="description" content={description} />
31
-
32
  <meta property="og:type" content={type} />
33
  <meta property="og:title" content={fullTitle} />
34
  <meta property="og:description" content={description} />
35
  {url && <meta property="og:url" content={url} />}
36
  {image && <meta property="og:image" content={image} />}
37
-
38
  <meta name="twitter:card" content="summary_large_image" />
39
  <meta name="twitter:title" content={fullTitle} />
40
  <meta name="twitter:description" content={description} />
41
  {image && <meta name="twitter:image" content={image} />}
42
-
43
  <link rel="canonical" href={url} />
44
  </Helmet>
45
  );
 
28
  <Helmet>
29
  <title>{fullTitle}</title>
30
  <meta name="description" content={description} />
31
+
32
  <meta property="og:type" content={type} />
33
  <meta property="og:title" content={fullTitle} />
34
  <meta property="og:description" content={description} />
35
  {url && <meta property="og:url" content={url} />}
36
  {image && <meta property="og:image" content={image} />}
37
+
38
  <meta name="twitter:card" content="summary_large_image" />
39
  <meta name="twitter:title" content={fullTitle} />
40
  <meta name="twitter:description" content={description} />
41
  {image && <meta name="twitter:image" content={image} />}
42
+
43
  <link rel="canonical" href={url} />
44
  </Helmet>
45
  );
src/client/components/layout/Header.tsx CHANGED
@@ -8,7 +8,8 @@ import { siteConfig } from "@shared/config";
8
  import { useTheme } from "../../contexts/ThemeContext";
9
  import { usePageContext } from "../../contexts/PageContext";
10
  import { useAnalysisContext } from "../../contexts/AnalysisContext";
11
- import { Sun, Moon, MonitorCog, Home, ChevronRight, Sparkles } from "lucide-react";
 
12
  import type { CSSProperties } from "react";
13
 
14
  const headerStyle: CSSProperties = {
@@ -74,6 +75,9 @@ const blogTitleStyle: CSSProperties = {
74
  color: "var(--color-text-primary)",
75
  textDecoration: "none",
76
  whiteSpace: "nowrap",
 
 
 
77
  };
78
 
79
  const breadcrumbContainerStyle: CSSProperties = {
@@ -96,52 +100,55 @@ const postTitleStyle: CSSProperties = {
96
  whiteSpace: "nowrap",
97
  overflow: "hidden",
98
  textOverflow: "ellipsis",
99
- maxWidth: "200px",
100
  };
101
 
102
- const iconButtonStyle: CSSProperties = {
103
  display: "flex",
104
  alignItems: "center",
105
  justifyContent: "center",
106
  width: "36px",
107
  height: "36px",
108
  borderRadius: "var(--border-radius-md)",
109
- border: "1px solid var(--color-border)",
110
  backgroundColor: "var(--color-surface)",
111
- color: "var(--color-text-primary)",
112
  cursor: "pointer",
113
  transition: "background-color var(--transition-fast), border-color var(--transition-fast)",
114
  textDecoration: "none",
 
 
 
 
 
 
 
115
  };
116
 
117
  const analyzeButtonStyle: CSSProperties = {
118
- display: "flex",
119
- alignItems: "center",
120
- justifyContent: "center",
121
- width: "36px",
122
- height: "36px",
123
- borderRadius: "var(--border-radius-md)",
124
  border: "1px solid var(--color-accent)",
125
- backgroundColor: "var(--color-surface)",
126
  color: "var(--color-accent)",
127
- cursor: "pointer",
128
- transition: "all var(--transition-fast)",
129
- textDecoration: "none",
130
  };
131
 
132
  const analyzeButtonDisabledStyle: CSSProperties = {
133
- display: "flex",
134
- alignItems: "center",
135
- justifyContent: "center",
136
- width: "36px",
137
- height: "36px",
138
- borderRadius: "var(--border-radius-md)",
 
 
 
 
 
 
 
 
 
139
  border: "1px solid var(--color-border)",
140
- backgroundColor: "var(--color-surface)",
141
  color: "var(--color-text-muted)",
142
  cursor: "not-allowed",
143
  opacity: 0.6,
144
- textDecoration: "none",
145
  };
146
 
147
  const spinnerIconStyle: CSSProperties = {
@@ -162,6 +169,7 @@ export const Header = (): JSX.Element => {
162
  const { theme, setTheme } = useTheme();
163
  const { currentPostTitle } = usePageContext();
164
  const { analysisState, startAnalysis, postContent } = useAnalysisContext();
 
165
 
166
  const cycleTheme = (): void => {
167
  const themes: Array<"system" | "light" | "dark"> = ["system", "light", "dark"];
@@ -173,6 +181,9 @@ export const Header = (): JSX.Element => {
173
  const isOnPostPage = currentPostTitle !== null;
174
  const canAnalyze = isOnPostPage && postContent !== null && !analysisState.isOpen && !analysisState.isLoading;
175
  const showAnalyzeButton = siteConfig.features.enableAIAnalysis && isOnPostPage;
 
 
 
176
 
177
  const handleAnalyzeClick = (): void => {
178
  if (canAnalyze) {
@@ -180,6 +191,12 @@ export const Header = (): JSX.Element => {
180
  }
181
  };
182
 
 
 
 
 
 
 
183
  return (
184
  <header style={headerStyle}>
185
  <div style={headerContainerStyle}>
@@ -210,6 +227,17 @@ export const Header = (): JSX.Element => {
210
  </div>
211
 
212
  <div style={rightSectionStyle}>
 
 
 
 
 
 
 
 
 
 
 
213
  {showAnalyzeButton && (
214
  <button
215
  type="button"
 
8
  import { useTheme } from "../../contexts/ThemeContext";
9
  import { usePageContext } from "../../contexts/PageContext";
10
  import { useAnalysisContext } from "../../contexts/AnalysisContext";
11
+ import { useTranslationContext } from "../../contexts/TranslationContext";
12
+ import { Sun, Moon, MonitorCog, Home, ChevronRight, Sparkles, Globe } from "lucide-react";
13
  import type { CSSProperties } from "react";
14
 
15
  const headerStyle: CSSProperties = {
 
75
  color: "var(--color-text-primary)",
76
  textDecoration: "none",
77
  whiteSpace: "nowrap",
78
+ overflow: "hidden",
79
+ textOverflow: "ellipsis",
80
+ maxWidth: "clamp(80px, 25vw, 200px)",
81
  };
82
 
83
  const breadcrumbContainerStyle: CSSProperties = {
 
100
  whiteSpace: "nowrap",
101
  overflow: "hidden",
102
  textOverflow: "ellipsis",
103
+ maxWidth: "clamp(60px, 20vw, 200px)",
104
  };
105
 
106
+ const iconButtonBaseStyle: CSSProperties = {
107
  display: "flex",
108
  alignItems: "center",
109
  justifyContent: "center",
110
  width: "36px",
111
  height: "36px",
112
  borderRadius: "var(--border-radius-md)",
 
113
  backgroundColor: "var(--color-surface)",
 
114
  cursor: "pointer",
115
  transition: "background-color var(--transition-fast), border-color var(--transition-fast)",
116
  textDecoration: "none",
117
+ flexShrink: 0,
118
+ };
119
+
120
+ const iconButtonStyle: CSSProperties = {
121
+ ...iconButtonBaseStyle,
122
+ border: "1px solid var(--color-border)",
123
+ color: "var(--color-text-primary)",
124
  };
125
 
126
  const analyzeButtonStyle: CSSProperties = {
127
+ ...iconButtonBaseStyle,
 
 
 
 
 
128
  border: "1px solid var(--color-accent)",
 
129
  color: "var(--color-accent)",
 
 
 
130
  };
131
 
132
  const analyzeButtonDisabledStyle: CSSProperties = {
133
+ ...iconButtonBaseStyle,
134
+ border: "1px solid var(--color-border)",
135
+ color: "var(--color-text-muted)",
136
+ cursor: "not-allowed",
137
+ opacity: 0.6,
138
+ };
139
+
140
+ const translateButtonStyle: CSSProperties = {
141
+ ...iconButtonBaseStyle,
142
+ border: "1px solid var(--color-accent)",
143
+ color: "var(--color-accent)",
144
+ };
145
+
146
+ const translateButtonDisabledStyle: CSSProperties = {
147
+ ...iconButtonBaseStyle,
148
  border: "1px solid var(--color-border)",
 
149
  color: "var(--color-text-muted)",
150
  cursor: "not-allowed",
151
  opacity: 0.6,
 
152
  };
153
 
154
  const spinnerIconStyle: CSSProperties = {
 
169
  const { theme, setTheme } = useTheme();
170
  const { currentPostTitle } = usePageContext();
171
  const { analysisState, startAnalysis, postContent } = useAnalysisContext();
172
+ const { translationState, openTranslation, postContent: translationPostContent } = useTranslationContext();
173
 
174
  const cycleTheme = (): void => {
175
  const themes: Array<"system" | "light" | "dark"> = ["system", "light", "dark"];
 
181
  const isOnPostPage = currentPostTitle !== null;
182
  const canAnalyze = isOnPostPage && postContent !== null && !analysisState.isOpen && !analysisState.isLoading;
183
  const showAnalyzeButton = siteConfig.features.enableAIAnalysis && isOnPostPage;
184
+
185
+ const canTranslate = isOnPostPage && translationPostContent !== null && !translationState.isOpen && !translationState.isLoading;
186
+ const showTranslateButton = siteConfig.features.enableTranslation && isOnPostPage;
187
 
188
  const handleAnalyzeClick = (): void => {
189
  if (canAnalyze) {
 
191
  }
192
  };
193
 
194
+ const handleTranslateClick = (): void => {
195
+ if (canTranslate) {
196
+ openTranslation();
197
+ }
198
+ };
199
+
200
  return (
201
  <header style={headerStyle}>
202
  <div style={headerContainerStyle}>
 
227
  </div>
228
 
229
  <div style={rightSectionStyle}>
230
+ {showTranslateButton && (
231
+ <button
232
+ type="button"
233
+ onClick={handleTranslateClick}
234
+ disabled={!canTranslate}
235
+ style={canTranslate ? translateButtonStyle : translateButtonDisabledStyle}
236
+ title={siteConfig.messages.translateArticle}
237
+ >
238
+ <Globe size={18} style={translationState.isLoading ? spinnerIconStyle : undefined} />
239
+ </button>
240
+ )}
241
  {showAnalyzeButton && (
242
  <button
243
  type="button"
src/client/components/layout/Layout.tsx CHANGED
@@ -6,6 +6,7 @@
6
  import { Header } from "./Header";
7
  import { Footer } from "./Footer";
8
  import { useAnalysisContext } from "../../contexts/AnalysisContext";
 
9
  import type { ReactNode, CSSProperties } from "react";
10
 
11
  interface LayoutProps {
@@ -49,7 +50,10 @@ const mainContentStyle: CSSProperties = {
49
  };
50
 
51
  export const Layout = ({ children }: LayoutProps): JSX.Element => {
52
- const { isBlurred } = useAnalysisContext();
 
 
 
53
 
54
  return (
55
  <div style={isBlurred ? layoutBlurredStyle : layoutStyle}>
 
6
  import { Header } from "./Header";
7
  import { Footer } from "./Footer";
8
  import { useAnalysisContext } from "../../contexts/AnalysisContext";
9
+ import { useTranslationContext } from "../../contexts/TranslationContext";
10
  import type { ReactNode, CSSProperties } from "react";
11
 
12
  interface LayoutProps {
 
50
  };
51
 
52
  export const Layout = ({ children }: LayoutProps): JSX.Element => {
53
+ const { isBlurred: isAnalysisBlurred } = useAnalysisContext();
54
+ const { isBlurred: isTranslationBlurred } = useTranslationContext();
55
+
56
+ const isBlurred = isAnalysisBlurred || isTranslationBlurred;
57
 
58
  return (
59
  <div style={isBlurred ? layoutBlurredStyle : layoutStyle}>
src/client/contexts/TranslationContext.tsx ADDED
@@ -0,0 +1,242 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //
2
+ // SPDX-FileCopyrightText: Hadad <hadad@linuxmail.org>
3
+ // SPDX-License-Identifier: Apache-2.0
4
+ //
5
+
6
+ import { createContext, useContext, useState, useCallback, useMemo, useRef, useEffect } from "react";
7
+ import type { ReactNode } from "react";
8
+ import type { TranslationState, SupportedLanguage, AIStreamChunk } from "@shared/types";
9
+
10
+ const initialTranslationState: TranslationState = {
11
+ isOpen: false,
12
+ isLoading: false,
13
+ targetLanguage: null,
14
+ reasoning: "",
15
+ content: "",
16
+ isReasoningComplete: false,
17
+ error: null,
18
+ };
19
+
20
+ interface TranslationContextValue {
21
+ isBlurred: boolean;
22
+ translationState: TranslationState;
23
+ postContent: string | null;
24
+ postTitle: string | null;
25
+ setPostInfo: (content: string | null, title: string | null) => void;
26
+ openTranslation: () => void;
27
+ startTranslation: (language: SupportedLanguage) => Promise<void>;
28
+ resetTranslation: () => void;
29
+ }
30
+
31
+ const TranslationContext = createContext<TranslationContextValue | null>(null);
32
+
33
+ interface TranslationProviderProps {
34
+ children: ReactNode;
35
+ }
36
+
37
+ export const TranslationProvider = ({ children }: TranslationProviderProps): JSX.Element => {
38
+ const [translationState, setTranslationState] = useState<TranslationState>(initialTranslationState);
39
+ const [postContent, setPostContent] = useState<string | null>(null);
40
+ const [postTitle, setPostTitle] = useState<string | null>(null);
41
+ const abortControllerRef = useRef<AbortController | null>(null);
42
+
43
+ const isBlurred = translationState.isOpen;
44
+
45
+ const setPostInfo = useCallback((content: string | null, title: string | null): void => {
46
+ setPostContent(content);
47
+ setPostTitle(title);
48
+ }, []);
49
+
50
+ const openTranslation = useCallback((): void => {
51
+ setTranslationState({
52
+ isOpen: true,
53
+ isLoading: false,
54
+ targetLanguage: null,
55
+ reasoning: "",
56
+ content: "",
57
+ isReasoningComplete: false,
58
+ error: null,
59
+ });
60
+ }, []);
61
+
62
+ const resetTranslation = useCallback((): void => {
63
+ if (abortControllerRef.current) {
64
+ abortControllerRef.current.abort();
65
+ abortControllerRef.current = null;
66
+ }
67
+ setTranslationState(initialTranslationState);
68
+ }, []);
69
+
70
+ const startTranslation = useCallback(async (language: SupportedLanguage): Promise<void> => {
71
+ if (!postContent || !postTitle) {
72
+ return;
73
+ }
74
+
75
+ if (abortControllerRef.current) {
76
+ abortControllerRef.current.abort();
77
+ }
78
+
79
+ abortControllerRef.current = new AbortController();
80
+
81
+ setTranslationState((previous) => ({
82
+ ...previous,
83
+ isLoading: true,
84
+ targetLanguage: language,
85
+ }));
86
+
87
+ try {
88
+ const response = await fetch("/api/translate", {
89
+ method: "POST",
90
+ headers: {
91
+ "Content-Type": "application/json",
92
+ },
93
+ body: JSON.stringify({ content: postContent, title: postTitle, targetLanguage: language }),
94
+ signal: abortControllerRef.current.signal,
95
+ });
96
+
97
+ if (!response.ok) {
98
+ throw new Error("Failed to start translation");
99
+ }
100
+
101
+ const reader = response.body?.getReader();
102
+ if (!reader) {
103
+ throw new Error("No response stream");
104
+ }
105
+
106
+ const decoder = new TextDecoder("utf-8");
107
+ let buffer = "";
108
+ let hasReceivedContent = false;
109
+
110
+ while (true) {
111
+ const { done, value } = await reader.read();
112
+
113
+ if (done) {
114
+ break;
115
+ }
116
+
117
+ buffer += decoder.decode(value, { stream: true });
118
+ const lines = buffer.split("\n");
119
+ buffer = lines.pop() || "";
120
+
121
+ for (const line of lines) {
122
+ const trimmedLine = line.trim();
123
+
124
+ if (!trimmedLine || !trimmedLine.startsWith("data: ")) {
125
+ continue;
126
+ }
127
+
128
+ const dataContent = trimmedLine.slice(6);
129
+
130
+ try {
131
+ const chunk: AIStreamChunk = JSON.parse(dataContent);
132
+
133
+ if (chunk.type === "error") {
134
+ setTranslationState((previous) => ({
135
+ ...previous,
136
+ isLoading: false,
137
+ error: chunk.content || "An error occurred",
138
+ }));
139
+ return;
140
+ }
141
+
142
+ if (chunk.type === "done") {
143
+ setTranslationState((previous) => ({
144
+ ...previous,
145
+ isLoading: false,
146
+ isReasoningComplete: true,
147
+ }));
148
+ return;
149
+ }
150
+
151
+ if (chunk.type === "reasoning" && chunk.content) {
152
+ setTranslationState((previous) => ({
153
+ ...previous,
154
+ reasoning: previous.reasoning + chunk.content,
155
+ }));
156
+ }
157
+
158
+ if (chunk.type === "content" && chunk.content) {
159
+ if (!hasReceivedContent) {
160
+ hasReceivedContent = true;
161
+ setTranslationState((previous) => ({
162
+ ...previous,
163
+ isReasoningComplete: true,
164
+ }));
165
+ }
166
+ setTranslationState((previous) => ({
167
+ ...previous,
168
+ content: previous.content + chunk.content,
169
+ }));
170
+ }
171
+ } catch {
172
+ continue;
173
+ }
174
+ }
175
+ }
176
+
177
+ setTranslationState((previous) => ({
178
+ ...previous,
179
+ isLoading: false,
180
+ isReasoningComplete: true,
181
+ }));
182
+ } catch (error) {
183
+ if (error instanceof Error && error.name === "AbortError") {
184
+ return;
185
+ }
186
+
187
+ setTranslationState((previous) => ({
188
+ ...previous,
189
+ isLoading: false,
190
+ error: error instanceof Error ? error.message : "An error occurred",
191
+ }));
192
+ }
193
+ }, [postContent, postTitle]);
194
+
195
+ useEffect((): (() => void) => {
196
+ const handlePopState = (): void => {
197
+ if (translationState.isOpen) {
198
+ resetTranslation();
199
+ }
200
+ };
201
+
202
+ window.addEventListener("popstate", handlePopState);
203
+
204
+ return (): void => {
205
+ window.removeEventListener("popstate", handlePopState);
206
+ };
207
+ }, [translationState.isOpen, resetTranslation]);
208
+
209
+ useEffect((): (() => void) => {
210
+ return (): void => {
211
+ if (abortControllerRef.current) {
212
+ abortControllerRef.current.abort();
213
+ abortControllerRef.current = null;
214
+ }
215
+ };
216
+ }, []);
217
+
218
+ const contextValue = useMemo((): TranslationContextValue => ({
219
+ isBlurred,
220
+ translationState,
221
+ postContent,
222
+ postTitle,
223
+ setPostInfo,
224
+ openTranslation,
225
+ startTranslation,
226
+ resetTranslation,
227
+ }), [isBlurred, translationState, postContent, postTitle, setPostInfo, openTranslation, startTranslation, resetTranslation]);
228
+
229
+ return (
230
+ <TranslationContext.Provider value={contextValue}>
231
+ {children}
232
+ </TranslationContext.Provider>
233
+ );
234
+ };
235
+
236
+ export const useTranslationContext = (): TranslationContextValue => {
237
+ const context = useContext(TranslationContext);
238
+ if (!context) {
239
+ throw new Error("useTranslationContext must be used within a TranslationProvider");
240
+ }
241
+ return context;
242
+ };
src/client/pages/PostPage.tsx CHANGED
@@ -8,12 +8,15 @@ import { useEffect, useCallback, useMemo } from "react";
8
  import { usePost } from "../hooks/usePosts";
9
  import { usePageContext } from "../contexts/PageContext";
10
  import { useAnalysisContext } from "../contexts/AnalysisContext";
 
11
  import { PostContent } from "../components/blog/PostContent";
12
  import { AnalysisModal } from "../components/ai/AnalysisModal";
 
13
  import { Head } from "../components/common/Head";
14
  import { siteConfig } from "@shared/config";
15
  import { Loader, AlertCircle, ArrowLeft } from "lucide-react";
16
  import type { CSSProperties } from "react";
 
17
 
18
  const containerStyle: CSSProperties = {
19
  display: "flex",
@@ -65,7 +68,8 @@ export const PostPage = (): JSX.Element => {
65
  const { slug } = useParams<{ slug: string }>();
66
  const { post, loading, error } = usePost(slug || "");
67
  const { setCurrentPostTitle } = usePageContext();
68
- const { analysisState, setPostInfo, resetAnalysis } = useAnalysisContext();
 
69
 
70
  const textContent = useMemo((): string | null => {
71
  if (post) {
@@ -74,22 +78,40 @@ export const PostPage = (): JSX.Element => {
74
  return null;
75
  }, [post]);
76
 
 
 
 
 
 
 
 
77
  useEffect((): (() => void) => {
78
  if (post) {
79
  setCurrentPostTitle(post.frontMatter.title);
80
- setPostInfo(textContent, post.frontMatter.title);
 
81
  }
82
 
83
  return (): void => {
84
  setCurrentPostTitle(null);
85
- setPostInfo(null, null);
86
- resetAnalysis();
 
 
87
  };
88
- }, [post, textContent, setCurrentPostTitle, setPostInfo, resetAnalysis]);
89
 
90
  const handleCloseAnalysis = useCallback((): void => {
91
- resetAnalysis();
92
- }, [resetAnalysis]);
 
 
 
 
 
 
 
 
93
 
94
  if (loading) {
95
  return (
@@ -130,6 +152,11 @@ export const PostPage = (): JSX.Element => {
130
  analysisState={analysisState}
131
  onClose={handleCloseAnalysis}
132
  />
 
 
 
 
 
133
  </div>
134
  );
135
  };
 
8
  import { usePost } from "../hooks/usePosts";
9
  import { usePageContext } from "../contexts/PageContext";
10
  import { useAnalysisContext } from "../contexts/AnalysisContext";
11
+ import { useTranslationContext } from "../contexts/TranslationContext";
12
  import { PostContent } from "../components/blog/PostContent";
13
  import { AnalysisModal } from "../components/ai/AnalysisModal";
14
+ import { TranslationModal } from "../components/ai/TranslationModal";
15
  import { Head } from "../components/common/Head";
16
  import { siteConfig } from "@shared/config";
17
  import { Loader, AlertCircle, ArrowLeft } from "lucide-react";
18
  import type { CSSProperties } from "react";
19
+ import type { SupportedLanguage } from "@shared/types";
20
 
21
  const containerStyle: CSSProperties = {
22
  display: "flex",
 
68
  const { slug } = useParams<{ slug: string }>();
69
  const { post, loading, error } = usePost(slug || "");
70
  const { setCurrentPostTitle } = usePageContext();
71
+ const { analysisState, setPostInfo: setAnalysisPostInfo, resetAnalysis: resetAnalysisState } = useAnalysisContext();
72
+ const { translationState, setPostInfo: setTranslationPostInfo, resetTranslation: resetTranslationState, startTranslation } = useTranslationContext();
73
 
74
  const textContent = useMemo((): string | null => {
75
  if (post) {
 
78
  return null;
79
  }, [post]);
80
 
81
+ const rawMarkdownContent = useMemo((): string | null => {
82
+ if (post) {
83
+ return post.rawContent;
84
+ }
85
+ return null;
86
+ }, [post]);
87
+
88
  useEffect((): (() => void) => {
89
  if (post) {
90
  setCurrentPostTitle(post.frontMatter.title);
91
+ setAnalysisPostInfo(textContent, post.frontMatter.title);
92
+ setTranslationPostInfo(rawMarkdownContent, post.frontMatter.title);
93
  }
94
 
95
  return (): void => {
96
  setCurrentPostTitle(null);
97
+ setAnalysisPostInfo(null, null);
98
+ setTranslationPostInfo(null, null);
99
+ resetAnalysisState();
100
+ resetTranslationState();
101
  };
102
+ }, [post, textContent, rawMarkdownContent, setCurrentPostTitle, setAnalysisPostInfo, setTranslationPostInfo, resetAnalysisState, resetTranslationState]);
103
 
104
  const handleCloseAnalysis = useCallback((): void => {
105
+ resetAnalysisState();
106
+ }, [resetAnalysisState]);
107
+
108
+ const handleCloseTranslation = useCallback((): void => {
109
+ resetTranslationState();
110
+ }, [resetTranslationState]);
111
+
112
+ const handleSelectLanguage = useCallback((language: SupportedLanguage): void => {
113
+ startTranslation(language);
114
+ }, [startTranslation]);
115
 
116
  if (loading) {
117
  return (
 
152
  analysisState={analysisState}
153
  onClose={handleCloseAnalysis}
154
  />
155
+ <TranslationModal
156
+ translationState={translationState}
157
+ onClose={handleCloseTranslation}
158
+ onSelectLanguage={handleSelectLanguage}
159
+ />
160
  </div>
161
  );
162
  };
src/client/styles/global.css CHANGED
@@ -236,7 +236,7 @@ blockquote {
236
  padding-left: var(--spacing-lg);
237
  padding-top: var(--spacing-sm);
238
  padding-bottom: var(--spacing-sm);
239
- padding-right: var(--spacing-sm);
240
  margin: var(--spacing-lg) 0;
241
  font-style: italic;
242
  color: var(--color-text-secondary);
 
236
  padding-left: var(--spacing-lg);
237
  padding-top: var(--spacing-sm);
238
  padding-bottom: var(--spacing-sm);
239
+ padding-right: var(--spacing-md);
240
  margin: var(--spacing-lg) 0;
241
  font-style: italic;
242
  color: var(--color-text-secondary);
src/server/index.ts CHANGED
@@ -11,6 +11,12 @@ import { apiRoutes } from "./routes/apiRoutes.js";
11
  import { aiRoutes } from "./routes/aiRoutes.js";
12
  import { initializeFileWatcher } from "./utils/fileWatcher.js";
13
  import { initializePostService } from "./services/postService.js";
 
 
 
 
 
 
14
 
15
  const __filename = fileURLToPath(import.meta.url);
16
  const __dirname = dirname(__filename);
@@ -18,41 +24,73 @@ const __dirname = dirname(__filename);
18
  const HOST: string = "0.0.0.0";
19
  const PORT: number = 3000;
20
 
 
 
 
 
21
  const app: Application = express();
22
 
23
  app.use(cors({
24
  origin: true,
25
- methods: [
26
- "GET",
27
- "POST"
28
- ],
29
  allowedHeaders: ["Content-Type"],
30
  }));
31
 
32
- app.use(express.json({ limit: "1mb" }));
33
 
34
  app.use("/api", apiRoutes);
35
  app.use("/api", aiRoutes);
36
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37
  const clientDistPath: string = join(__dirname, "../client");
38
  app.use(express.static(clientDistPath));
39
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
  app.use((_request: Request, response: Response, next: NextFunction): void => {
41
  if (_request.method === "GET" && !_request.path.startsWith("/api")) {
42
- const indexPath: string = join(clientDistPath, "index.html");
43
- response.sendFile(indexPath);
 
 
 
 
 
44
  return;
45
  }
46
  next();
47
  });
48
 
49
  app.use(
50
- (
51
- error: Error,
52
- _request: Request,
53
- response: Response,
54
- _next: NextFunction
55
- ): void => {
56
  console.error("Server Error:", error.message);
57
  response.status(500).json({
58
  success: false,
@@ -64,10 +102,10 @@ app.use(
64
  const startServer = async (): Promise<void> => {
65
  await initializePostService();
66
  initializeFileWatcher();
67
-
68
- app.listen(PORT, HOST, (): void => {
69
- console.log(`Server running at http://${HOST}:${PORT}`);
70
- });
71
  };
72
 
73
- startServer();
 
11
  import { aiRoutes } from "./routes/aiRoutes.js";
12
  import { initializeFileWatcher } from "./utils/fileWatcher.js";
13
  import { initializePostService } from "./services/postService.js";
14
+ import {
15
+ renderPostPage,
16
+ generateRobotsTxt,
17
+ generateSitemapXml
18
+ } from "./services/seoService.js";
19
+ import { siteConfig } from "../shared/config.js";
20
 
21
  const __filename = fileURLToPath(import.meta.url);
22
  const __dirname = dirname(__filename);
 
24
  const HOST: string = "0.0.0.0";
25
  const PORT: number = 3000;
26
 
27
+ const getBaseUrl = (): string => {
28
+ return siteConfig.siteUrl || `http://localhost:${PORT}`;
29
+ };
30
+
31
  const app: Application = express();
32
 
33
  app.use(cors({
34
  origin: true,
35
+ methods: ["GET", "POST"],
 
 
 
36
  allowedHeaders: ["Content-Type"],
37
  }));
38
 
39
+ app.use(express.json({ limit: "2mb" }));
40
 
41
  app.use("/api", apiRoutes);
42
  app.use("/api", aiRoutes);
43
 
44
+ app.get("/robots.txt", (_request: Request, response: Response): void => {
45
+ const baseUrl = getBaseUrl();
46
+ response.setHeader("Content-Type", "text/plain");
47
+ response.send(generateRobotsTxt(baseUrl));
48
+ });
49
+
50
+ app.get("/sitemap.xml", async (_request: Request, response: Response): Promise<void> => {
51
+ try {
52
+ const baseUrl = getBaseUrl();
53
+ const sitemap = await generateSitemapXml(baseUrl);
54
+ response.setHeader("Content-Type", "application/xml");
55
+ response.send(sitemap);
56
+ } catch (error) {
57
+ console.error("Error generating sitemap:", error);
58
+ response.status(500).send("Error generating sitemap");
59
+ }
60
+ });
61
+
62
  const clientDistPath: string = join(__dirname, "../client");
63
  app.use(express.static(clientDistPath));
64
 
65
+ app.get("/post/:slug", async (request: Request, response: Response): Promise<void> => {
66
+ try {
67
+ const slugParam = request.params.slug;
68
+ const slug: string = Array.isArray(slugParam) ? slugParam[0] : slugParam;
69
+ const html = await renderPostPage(slug);
70
+ response.setHeader("Content-Type", "text/html");
71
+ response.send(html);
72
+ } catch (error) {
73
+ console.error("Error rendering post page:", error);
74
+ response.status(500).send("Server Error");
75
+ }
76
+ });
77
+
78
  app.use((_request: Request, response: Response, next: NextFunction): void => {
79
  if (_request.method === "GET" && !_request.path.startsWith("/api")) {
80
+ renderPostPage("").then((html) => {
81
+ response.setHeader("Content-Type", "text/html");
82
+ response.send(html);
83
+ }).catch((error) => {
84
+ console.error("Error rendering page:", error);
85
+ next();
86
+ });
87
  return;
88
  }
89
  next();
90
  });
91
 
92
  app.use(
93
+ (error: Error, _request: Request, response: Response, _next: NextFunction): void => {
 
 
 
 
 
94
  console.error("Server Error:", error.message);
95
  response.status(500).json({
96
  success: false,
 
102
  const startServer = async (): Promise<void> => {
103
  await initializePostService();
104
  initializeFileWatcher();
105
+
106
+ console.log(`Server running at http://${HOST}:${PORT}`);
107
+ console.log(`Site URL: ${getBaseUrl()}`);
108
+ console.log(`Environment: ${process.env.NODE_ENV || "development"}`);
109
  };
110
 
111
+ app.listen(PORT, HOST, startServer);
src/server/routes/aiRoutes.ts CHANGED
@@ -4,8 +4,8 @@
4
  //
5
 
6
  import { Router, Request, Response } from "express";
7
- import { streamAnalysis } from "../services/aiService.js";
8
- import type { AIAnalysisRequest } from "../../shared/types.js";
9
 
10
  export const aiRoutes: Router = Router();
11
 
@@ -30,6 +30,35 @@ aiRoutes.post(
30
  }
31
  );
32
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
  aiRoutes.all(
34
  "/analyze",
35
  (_request: Request, response: Response): void => {
@@ -40,4 +69,16 @@ aiRoutes.all(
40
  });
41
  }
42
  }
 
 
 
 
 
 
 
 
 
 
 
 
43
  );
 
4
  //
5
 
6
  import { Router, Request, Response } from "express";
7
+ import { streamAnalysis, streamTranslation } from "../services/aiService.js";
8
+ import type { AIAnalysisRequest, TranslationRequest } from "../../shared/types.js";
9
 
10
  export const aiRoutes: Router = Router();
11
 
 
30
  }
31
  );
32
 
33
+ aiRoutes.post(
34
+ "/translate",
35
+ async (request: Request, response: Response): Promise<void> => {
36
+ const { content, title, targetLanguage } = request.body as TranslationRequest;
37
+
38
+ if (!content || typeof content !== "string") {
39
+ response.status(400).json({
40
+ success: false,
41
+ error: "Article is required",
42
+ });
43
+ return;
44
+ }
45
+
46
+ if (!targetLanguage || typeof targetLanguage !== "string") {
47
+ response.status(400).json({
48
+ success: false,
49
+ error: "Target language is required",
50
+ });
51
+ return;
52
+ }
53
+
54
+ response.setHeader("Content-Type", "text/event-stream");
55
+ response.setHeader("X-Accel-Buffering", "no");
56
+ response.flushHeaders();
57
+
58
+ await streamTranslation(content, title || "Untitled", targetLanguage, response);
59
+ }
60
+ );
61
+
62
  aiRoutes.all(
63
  "/analyze",
64
  (_request: Request, response: Response): void => {
 
69
  });
70
  }
71
  }
72
+ );
73
+
74
+ aiRoutes.all(
75
+ "/translate",
76
+ (_request: Request, response: Response): void => {
77
+ if (_request.method !== "POST") {
78
+ response.status(405).json({
79
+ success: false,
80
+ error: "Method not allowed",
81
+ });
82
+ }
83
+ }
84
  );
src/server/services/aiService.ts CHANGED
@@ -5,6 +5,7 @@
5
 
6
  import type { Response } from "express";
7
  import { siteConfig } from "../../shared/config.js";
 
8
 
9
  const getBaseUrl = (): string => {
10
  return process.env.BASE_URL || "";
@@ -23,11 +24,54 @@ Do NOT address or speak to the author.
23
 
24
  Do NOT use second-person language such as "you", "your", or any motivational or coaching tone.
25
 
26
- Do NOT claim to represent the readerโ€™s perspective. Remember that you are only an AI assistant whose role is to help readers understand the content of the article.
27
 
28
  Position yourself as a reader who happens to come across an article from the blog, and provide an explanation stating whether the article is accurate or whether it needs to be rechecked.
29
 
30
- Your task is to analyze the article as a reader, not as someone providing improvements, and not as an instructor.`
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
  };
32
 
33
  export const streamAnalysis = async (
@@ -54,7 +98,6 @@ export const streamAnalysis = async (
54
  { role: "user", content: userMessage },
55
  ],
56
  max_tokens: siteConfig.ai.maxTokens,
57
- reasoning_effort: siteConfig.ai.reasoningEffort,
58
  stream: true,
59
  };
60
 
@@ -88,7 +131,7 @@ export const streamAnalysis = async (
88
 
89
  while (true) {
90
  const { done, value } = await reader.read();
91
-
92
  if (done) {
93
  response.write(`data: ${JSON.stringify({ type: "done" })}\n\n`);
94
  break;
@@ -100,13 +143,13 @@ export const streamAnalysis = async (
100
 
101
  for (const line of lines) {
102
  const trimmedLine = line.trim();
103
-
104
  if (!trimmedLine || !trimmedLine.startsWith("data: ")) {
105
  continue;
106
  }
107
 
108
  const dataContent = trimmedLine.slice(6);
109
-
110
  if (dataContent === "[DONE]") {
111
  response.write(`data: ${JSON.stringify({ type: "done" })}\n\n`);
112
  continue;
@@ -137,5 +180,115 @@ export const streamAnalysis = async (
137
  response.write(`data: ${JSON.stringify({ type: "error", content: "Connection error occurred" })}\n\n`);
138
  }
139
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
140
  response.end();
141
  };
 
5
 
6
  import type { Response } from "express";
7
  import { siteConfig } from "../../shared/config.js";
8
+ import type { SupportedLanguage } from "../../shared/types.js";
9
 
10
  const getBaseUrl = (): string => {
11
  return process.env.BASE_URL || "";
 
24
 
25
  Do NOT use second-person language such as "you", "your", or any motivational or coaching tone.
26
 
27
+ Do NOT claim to represent the reader's perspective. Remember that you are only an AI assistant whose role is to help readers understand the content of the article.
28
 
29
  Position yourself as a reader who happens to come across an article from the blog, and provide an explanation stating whether the article is accurate or whether it needs to be rechecked.
30
 
31
+ Your task is to analyze the article as a reader, not as someone providing improvements, and not as an instructor.`;
32
+ };
33
+
34
+ const createTranslationSystemPrompt = (targetLanguage: SupportedLanguage): string => {
35
+ const languageNames: Record<SupportedLanguage, string> = {
36
+ id: "Indonesian",
37
+ zh: "Chinese",
38
+ ja: "Japanese",
39
+ fr: "French",
40
+ es: "Spanish",
41
+ };
42
+
43
+ const targetLanguageName = languageNames[targetLanguage];
44
+
45
+ return `You are a professional translator specializing in translating English blog posts into ${targetLanguageName}.
46
+
47
+ Your task is to translate the following article accurately while maintaining:
48
+
49
+ 1. The original meaning and tone
50
+
51
+ 2. Markdown formatting
52
+
53
+ 3. Technical terms appropriately translated or kept in English if commonly used
54
+
55
+ 4. Natural flow and readability in ${targetLanguageName}
56
+
57
+ Do NOT add explanations about the translation process.
58
+
59
+ Do NOT modify the structure of the content.
60
+
61
+ Translate only the text content, preserve all markdown syntax.
62
+
63
+ Output the translated content directly.
64
+
65
+ Exclude the following metadata when translating :
66
+
67
+ ---
68
+ title: .....
69
+ date: .....
70
+ description: .....
71
+ author: .....
72
+ tags: .....
73
+ ---
74
+ `;
75
  };
76
 
77
  export const streamAnalysis = async (
 
98
  { role: "user", content: userMessage },
99
  ],
100
  max_tokens: siteConfig.ai.maxTokens,
 
101
  stream: true,
102
  };
103
 
 
131
 
132
  while (true) {
133
  const { done, value } = await reader.read();
134
+
135
  if (done) {
136
  response.write(`data: ${JSON.stringify({ type: "done" })}\n\n`);
137
  break;
 
143
 
144
  for (const line of lines) {
145
  const trimmedLine = line.trim();
146
+
147
  if (!trimmedLine || !trimmedLine.startsWith("data: ")) {
148
  continue;
149
  }
150
 
151
  const dataContent = trimmedLine.slice(6);
152
+
153
  if (dataContent === "[DONE]") {
154
  response.write(`data: ${JSON.stringify({ type: "done" })}\n\n`);
155
  continue;
 
180
  response.write(`data: ${JSON.stringify({ type: "error", content: "Connection error occurred" })}\n\n`);
181
  }
182
 
183
+ response.end();
184
+ };
185
+
186
+ export const streamTranslation = async (
187
+ postContent: string,
188
+ postTitle: string,
189
+ targetLanguage: SupportedLanguage,
190
+ response: Response
191
+ ): Promise<void> => {
192
+ const baseUrl = getBaseUrl();
193
+ const apiKey = getApiKey();
194
+
195
+ if (!baseUrl || !apiKey) {
196
+ response.write(`data: ${JSON.stringify({ type: "error", content: "AI service is not configured" })}\n\n`);
197
+ response.end();
198
+ return;
199
+ }
200
+
201
+ const systemPrompt = createTranslationSystemPrompt(targetLanguage);
202
+ const userMessage = `Please translate this blog post titled "${postTitle}" into ${siteConfig.languages[targetLanguage].name}:\n\n${postContent}`;
203
+
204
+ const requestBody = {
205
+ model: siteConfig.ai.model,
206
+ messages: [
207
+ { role: "system", content: systemPrompt },
208
+ { role: "user", content: userMessage },
209
+ ],
210
+ max_tokens: siteConfig.ai.maxTokens,
211
+ stream: true,
212
+ };
213
+
214
+ try {
215
+ const apiResponse = await fetch(`${baseUrl}/v1/chat/completions`, {
216
+ method: "POST",
217
+ headers: {
218
+ "Content-Type": "application/json",
219
+ "Authorization": `Bearer ${apiKey}`,
220
+ },
221
+ body: JSON.stringify(requestBody),
222
+ });
223
+
224
+ if (!apiResponse.ok) {
225
+ const errorText = await apiResponse.text();
226
+ console.error("AI API Error:", errorText);
227
+ response.write(`data: ${JSON.stringify({ type: "error", content: "Failed to get AI response" })}\n\n`);
228
+ response.end();
229
+ return;
230
+ }
231
+
232
+ const reader = apiResponse.body?.getReader();
233
+ if (!reader) {
234
+ response.write(`data: ${JSON.stringify({ type: "error", content: "No response available" })}\n\n`);
235
+ response.end();
236
+ return;
237
+ }
238
+
239
+ const decoder = new TextDecoder("utf-8");
240
+ let buffer = "";
241
+
242
+ while (true) {
243
+ const { done, value } = await reader.read();
244
+
245
+ if (done) {
246
+ response.write(`data: ${JSON.stringify({ type: "done" })}\n\n`);
247
+ break;
248
+ }
249
+
250
+ buffer += decoder.decode(value, { stream: true });
251
+ const lines = buffer.split("\n");
252
+ buffer = lines.pop() || "";
253
+
254
+ for (const line of lines) {
255
+ const trimmedLine = line.trim();
256
+
257
+ if (!trimmedLine || !trimmedLine.startsWith("data: ")) {
258
+ continue;
259
+ }
260
+
261
+ const dataContent = trimmedLine.slice(6);
262
+
263
+ if (dataContent === "[DONE]") {
264
+ response.write(`data: ${JSON.stringify({ type: "done" })}\n\n`);
265
+ continue;
266
+ }
267
+
268
+ try {
269
+ const parsed = JSON.parse(dataContent);
270
+ const delta = parsed.choices?.[0]?.delta;
271
+
272
+ if (!delta) {
273
+ continue;
274
+ }
275
+
276
+ if (delta.reasoning_content !== undefined && delta.reasoning_content !== null && delta.reasoning_content !== "") {
277
+ response.write(`data: ${JSON.stringify({ type: "reasoning", content: delta.reasoning_content })}\n\n`);
278
+ }
279
+
280
+ if (delta.content !== undefined && delta.content !== null && delta.content !== "") {
281
+ response.write(`data: ${JSON.stringify({ type: "content", content: delta.content })}\n\n`);
282
+ }
283
+ } catch {
284
+ continue;
285
+ }
286
+ }
287
+ }
288
+ } catch (error) {
289
+ console.error("Translation Error:", error);
290
+ response.write(`data: ${JSON.stringify({ type: "error", content: "Connection error occurred" })}\n\n`);
291
+ }
292
+
293
  response.end();
294
  };
src/server/services/seoService.ts ADDED
@@ -0,0 +1,175 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //
2
+ // SPDX-FileCopyrightText: Hadad <hadad@linuxmail.org>
3
+ // SPDX-License-Identifier: Apache-2.0
4
+ //
5
+
6
+ import { readFile } from "fs/promises";
7
+ import { join } from "path";
8
+ import { fileURLToPath } from "url";
9
+ import { dirname } from "path";
10
+ import { siteConfig } from "../../shared/config.js";
11
+ import { getAllPosts, getPostBySlug } from "./postService.js";
12
+ import type { Post } from "../../shared/types.js";
13
+
14
+ const __filename = fileURLToPath(import.meta.url);
15
+ const __dirname = dirname(__filename);
16
+
17
+ const buildDir = join(__dirname, "../../client");
18
+
19
+ let indexHtmlTemplate: string | null = null;
20
+
21
+ export const loadIndexHtmlTemplate = async (): Promise<string> => {
22
+ if (indexHtmlTemplate) {
23
+ return indexHtmlTemplate;
24
+ }
25
+
26
+ const indexPath = join(buildDir, "index.html");
27
+ try {
28
+ indexHtmlTemplate = await readFile(indexPath, "utf-8");
29
+ return indexHtmlTemplate;
30
+ } catch (error) {
31
+ console.error("Failed to load index.html template:", error);
32
+ return "";
33
+ }
34
+ };
35
+
36
+ const formatKeywords = (keywords: string[]): string => {
37
+ if (!keywords || keywords.length === 0) {
38
+ return "";
39
+ }
40
+
41
+ const cleanedKeywords = keywords
42
+ .map(keyword => keyword.trim().toLowerCase())
43
+ .filter(keyword => keyword.length > 0)
44
+ .filter((keyword, index, self) => self.indexOf(keyword) === index)
45
+ .slice(0, 10);
46
+
47
+ return cleanedKeywords.join(", ");
48
+ };
49
+
50
+ const mergeKeywords = (postKeywords: string[] | undefined): string => {
51
+ const defaultKeywords = siteConfig.keywords || [];
52
+ const articleKeywords = postKeywords || [];
53
+
54
+ const combinedKeywords = [...articleKeywords, ...defaultKeywords];
55
+ return formatKeywords(combinedKeywords);
56
+ };
57
+
58
+ export const generateMetaTags = (post: Post): string => {
59
+ const title = `${post.frontMatter.title} - ${siteConfig.title}`;
60
+ const description = post.frontMatter.description || siteConfig.description;
61
+ const url = `${siteConfig.siteUrl || ""}/post/${post.slug}`;
62
+ const image = post.frontMatter.image || `${siteConfig.siteUrl || ""}/assets/images/profile.png`;
63
+ const keywords = mergeKeywords(post.frontMatter.tags);
64
+
65
+ return `
66
+ <title>${escapeHtml(title)}</title>
67
+ <meta name="description" content="${escapeHtml(description)}">
68
+ <meta name="keywords" content="${escapeHtml(keywords)}">
69
+ <meta name="author" content="${escapeHtml(post.frontMatter.author || siteConfig.author.name)}">
70
+ <meta name="robots" content="index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1">
71
+ <meta name="google-site-verification" content="m0OGuzVr40bOdghFXVbs3AYpfEcXnrRZ83RR1DCDvoc" />
72
+ <meta name="msvalidate.01" content="867CFEF9C4B44CFD5EEA1A6C65ED3F6B" />
73
+ <meta property="og:type" content="article">
74
+ <meta property="og:url" content="${escapeHtml(url)}">
75
+ <meta property="og:title" content="${escapeHtml(title)}">
76
+ <meta property="og:description" content="${escapeHtml(description)}">
77
+ <meta property="og:image" content="${escapeHtml(image)}">
78
+ <meta property="og:site_name" content="${escapeHtml(siteConfig.name)}">
79
+ <meta property="article:published_time" content="${escapeHtml(post.frontMatter.date)}">
80
+ <meta property="article:author" content="${escapeHtml(post.frontMatter.author || siteConfig.author.name)}">
81
+ ${post.frontMatter.tags?.map(tag => `<meta property="article:tag" content="${escapeHtml(tag)}">`).join("") || ""}
82
+ <meta name="twitter:card" content="summary_large_image">
83
+ <meta name="twitter:url" content="${escapeHtml(url)}">
84
+ <meta name="twitter:title" content="${escapeHtml(title)}">
85
+ <meta name="twitter:description" content="${escapeHtml(description)}">
86
+ <meta name="twitter:image" content="${escapeHtml(image)}">
87
+ `;
88
+ };
89
+
90
+ export const generateDefaultMetaTags = (): string => {
91
+ const keywords = formatKeywords(siteConfig.keywords);
92
+
93
+ return `
94
+ <title>${escapeHtml(siteConfig.title)}</title>
95
+ <meta name="description" content="${escapeHtml(siteConfig.description)}">
96
+ <meta name="keywords" content="${escapeHtml(keywords)}">
97
+ <meta name="author" content="${escapeHtml(siteConfig.author.name)}">
98
+ <meta name="robots" content="index, follow">
99
+ <meta name="google-site-verification" content="m0OGuzVr40bOdghFXVbs3AYpfEcXnrRZ83RR1DCDvoc" />
100
+ <meta name="msvalidate.01" content="867CFEF9C4B44CFD5EEA1A6C65ED3F6B" />
101
+ <meta property="og:type" content="website">
102
+ <meta property="og:title" content="${escapeHtml(siteConfig.title)}">
103
+ <meta property="og:description" content="${escapeHtml(siteConfig.description)}">
104
+ <meta name="twitter:card" content="summary_large_image">
105
+ <meta name="twitter:title" content="${escapeHtml(siteConfig.title)}">
106
+ <meta name="twitter:description" content="${escapeHtml(siteConfig.description)}">
107
+ `;
108
+ };
109
+
110
+ export const renderPostPage = async (slug: string): Promise<string> => {
111
+ const template = await loadIndexHtmlTemplate();
112
+
113
+ if (!slug) {
114
+ return template.replace("<!--SEO_META_TAGS-->", generateDefaultMetaTags());
115
+ }
116
+
117
+ const post = await getPostBySlug(slug);
118
+
119
+ if (!post) {
120
+ return template.replace("<!--SEO_META_TAGS-->", generateDefaultMetaTags());
121
+ }
122
+
123
+ const metaTags = generateMetaTags(post);
124
+ return template.replace("<!--SEO_META_TAGS-->", metaTags);
125
+ };
126
+
127
+ export const generateRobotsTxt = (baseUrl: string): string => {
128
+ return `User-agent: *
129
+ Allow: /
130
+ Disallow: /api/
131
+
132
+ Sitemap: ${baseUrl}/sitemap.xml
133
+ `;
134
+ };
135
+
136
+ export const generateSitemapXml = async (baseUrl: string): Promise<string> => {
137
+ const posts = await getAllPosts();
138
+ const currentDate = new Date().toISOString().split("T")[0];
139
+
140
+ const urlEntries: string[] = [];
141
+
142
+ urlEntries.push(`
143
+ <url>
144
+ <loc>${escapeHtml(baseUrl)}/</loc>
145
+ <lastmod>${currentDate}</lastmod>
146
+ <changefreq>daily</changefreq>
147
+ <priority>1.0</priority>
148
+ </url>`);
149
+
150
+ for (const post of posts) {
151
+ const lastmod = new Date(post.frontMatter.date).toISOString().split("T")[0];
152
+ urlEntries.push(`
153
+ <url>
154
+ <loc>${escapeHtml(baseUrl)}/post/${post.slug}</loc>
155
+ <lastmod>${lastmod}</lastmod>
156
+ <changefreq>weekly</changefreq>
157
+ <priority>0.8</priority>
158
+ </url>`);
159
+ }
160
+
161
+ return `<?xml version="1.0" encoding="UTF-8"?>
162
+ <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
163
+ ${urlEntries.join("")}
164
+ </urlset>`;
165
+ };
166
+
167
+ const escapeHtml = (text: string): string => {
168
+ if (!text) return "";
169
+ return text
170
+ .replace(/&/g, "&amp;")
171
+ .replace(/</g, "&lt;")
172
+ .replace(/>/g, "&gt;")
173
+ .replace(/"/g, "&quot;")
174
+ .replace(/'/g, "&#039;");
175
+ };
src/shared/config.ts CHANGED
@@ -7,11 +7,32 @@ export const siteConfig = {
7
  name: "Hadad Darajat",
8
  title: "Hadad Darajat",
9
  description: "Personal Blog",
 
10
  locale: "en-US",
11
  author: {
12
  name: "Hadad Darajat",
13
  linkedin: "https://linkedin.com/in/hadadrjt",
14
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
  footer: {
16
  copyrightYear: "2025",
17
  copyrightText: "Copyright",
@@ -33,6 +54,14 @@ export const siteConfig = {
33
  reasoning: "Reasoning",
34
  closeAnalysis: "Close",
35
  analysisError: "Failed to analyze article",
 
 
 
 
 
 
 
 
36
  },
37
  defaults: {
38
  postTitle: "Untitled",
@@ -46,10 +75,17 @@ export const siteConfig = {
46
  },
47
  features: {
48
  enableAIAnalysis: true,
 
49
  },
50
  ai: {
51
- model: "openai/gpt-oss-20b",
52
- maxTokens: 8192,
53
- reasoningEffort: "high",
 
 
 
 
 
 
54
  },
55
  };
 
7
  name: "Hadad Darajat",
8
  title: "Hadad Darajat",
9
  description: "Personal Blog",
10
+ siteUrl: process.env.SITE_URL,
11
  locale: "en-US",
12
  author: {
13
  name: "Hadad Darajat",
14
  linkedin: "https://linkedin.com/in/hadadrjt",
15
  },
16
+ keywords: [
17
+ "hadad",
18
+ "hadad darajat",
19
+ "hadadrjt",
20
+ "hadadxyz",
21
+ "hadad blog",
22
+ "hadad darajat blog",
23
+ "hadad personal blog",
24
+ "hadad darajat personal blog",
25
+ "blog",
26
+ "personal blog",
27
+ "technology",
28
+ "programming",
29
+ "software development",
30
+ "tech articles",
31
+ "huggingface",
32
+ "hugging face",
33
+ "hf",
34
+ "hugging face space"
35
+ ],
36
  footer: {
37
  copyrightYear: "2025",
38
  copyrightText: "Copyright",
 
54
  reasoning: "Reasoning",
55
  closeAnalysis: "Close",
56
  analysisError: "Failed to analyze article",
57
+ translateArticle: "Translate this article",
58
+ translating: "Translating...",
59
+ translationComplete: "Translation Complete",
60
+ translationInfo: "AI Translation",
61
+ selectLanguage: "Select Language",
62
+ translationError: "Failed to translate article",
63
+ closeTranslation: "Close",
64
+ translationReady: "Translation",
65
  },
66
  defaults: {
67
  postTitle: "Untitled",
 
75
  },
76
  features: {
77
  enableAIAnalysis: true,
78
+ enableTranslation: true,
79
  },
80
  ai: {
81
+ model: "nvidia/nemotron-3-nano-30b-a3b",
82
+ maxTokens: 131072,
83
+ },
84
+ languages: {
85
+ id: { name: "Indonesian", flag: "๐Ÿ‡ฎ๐Ÿ‡ฉ" },
86
+ zh: { name: "Chinese", flag: "๐Ÿ‡จ๐Ÿ‡ณ" },
87
+ ja: { name: "Japanese", flag: "๐Ÿ‡ฏ๐Ÿ‡ต" },
88
+ fr: { name: "French", flag: "๐Ÿ‡ซ๐Ÿ‡ท" },
89
+ es: { name: "Spanish", flag: "๐Ÿ‡ช๐Ÿ‡ธ" },
90
  },
91
  };
src/shared/types.ts CHANGED
@@ -49,4 +49,22 @@ export interface AnalysisState {
49
  content: string;
50
  isReasoningComplete: boolean;
51
  error: string | null;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
  }
 
49
  content: string;
50
  isReasoningComplete: boolean;
51
  error: string | null;
52
+ }
53
+
54
+ export type SupportedLanguage = "id" | "zh" | "ja" | "fr" | "es";
55
+
56
+ export interface TranslationState {
57
+ isOpen: boolean;
58
+ isLoading: boolean;
59
+ targetLanguage: SupportedLanguage | null;
60
+ reasoning: string;
61
+ content: string;
62
+ isReasoningComplete: boolean;
63
+ error: string | null;
64
+ }
65
+
66
+ export interface TranslationRequest {
67
+ content: string;
68
+ title: string;
69
+ targetLanguage: SupportedLanguage;
70
  }