hadadrjt commited on
Commit
47f204a
·
1 Parent(s): 4d8c8bb

blog: Bump to 0.0.3 version.

Browse files
package.json CHANGED
@@ -1,6 +1,6 @@
1
  {
2
  "name": "personal-blog",
3
- "version": "0.0.2",
4
  "private": true,
5
  "author": "Hadad Darajat",
6
  "type": "module",
 
1
  {
2
  "name": "personal-blog",
3
+ "version": "0.0.3",
4
  "private": true,
5
  "author": "Hadad Darajat",
6
  "type": "module",
src/client/components/ai/AnalysisModal.tsx CHANGED
@@ -3,12 +3,11 @@
3
  // SPDX-License-Identifier: Apache-2.0
4
  //
5
 
6
- import { useEffect, useRef } from "react";
7
  import { createPortal } from "react-dom";
8
- import { X, Sparkles, AlertCircle } from "lucide-react";
9
- import { ReasoningSection } from "./ReasoningSection";
10
  import { siteConfig } from "@shared/config";
11
- import { Marked } from "marked";
12
  import type { AnalysisState } from "@shared/types";
13
  import type { CSSProperties } from "react";
14
 
@@ -17,36 +16,34 @@ interface AnalysisModalProps {
17
  onClose: () => void;
18
  }
19
 
20
- const marked = new Marked();
21
- marked.setOptions({
22
- gfm: true,
23
- breaks: true,
24
- });
25
-
26
  const overlayStyle: CSSProperties = {
27
  position: "fixed",
28
  top: 0,
29
  left: 0,
30
  right: 0,
31
  bottom: 0,
32
- backgroundColor: "rgba(0, 0, 0, 0.5)",
33
  zIndex: 1000,
34
  display: "flex",
35
- alignItems: "center",
36
  justifyContent: "center",
37
- padding: "var(--spacing-md)",
 
38
  };
39
 
40
- const modalStyle: CSSProperties = {
41
  width: "100%",
42
- maxWidth: "800px",
43
- maxHeight: "90vh",
44
  backgroundColor: "var(--color-background)",
45
- borderRadius: "var(--border-radius-lg)",
46
- boxShadow: "var(--shadow-lg)",
47
  display: "flex",
48
  flexDirection: "column",
49
  overflow: "hidden",
 
 
 
 
50
  };
51
 
52
  const headerStyle: CSSProperties = {
@@ -59,31 +56,43 @@ const headerStyle: CSSProperties = {
59
  flexShrink: 0,
60
  };
61
 
62
- const headerTitleContainerStyle: CSSProperties = {
63
  display: "flex",
64
  alignItems: "center",
65
  gap: "var(--spacing-sm)",
66
  };
67
 
68
- const headerIconStyle: CSSProperties = {
69
- color: "var(--color-accent)",
 
 
 
 
 
 
 
70
  };
71
 
72
  const headerTitleStyle: CSSProperties = {
73
- fontSize: "var(--font-size-lg)",
74
  fontWeight: 600,
75
  color: "var(--color-text-primary)",
76
  };
77
 
 
 
 
 
 
78
  const closeButtonStyle: CSSProperties = {
79
  display: "flex",
80
  alignItems: "center",
81
  justifyContent: "center",
82
  width: "32px",
83
  height: "32px",
84
- borderRadius: "var(--border-radius-sm)",
85
- border: "none",
86
- backgroundColor: "transparent",
87
  color: "var(--color-text-muted)",
88
  cursor: "pointer",
89
  transition: "all var(--transition-fast)",
@@ -92,7 +101,15 @@ const closeButtonStyle: CSSProperties = {
92
  const contentContainerStyle: CSSProperties = {
93
  flex: 1,
94
  overflowY: "auto",
 
 
 
 
 
95
  padding: "var(--spacing-lg)",
 
 
 
96
  };
97
 
98
  const loadingContainerStyle: CSSProperties = {
@@ -105,11 +122,23 @@ const loadingContainerStyle: CSSProperties = {
105
  color: "var(--color-text-muted)",
106
  };
107
 
108
- const spinnerStyle: CSSProperties = {
109
  animation: "spin 1s linear infinite",
110
  color: "var(--color-accent)",
111
  };
112
 
 
 
 
 
 
 
 
 
 
 
 
 
113
  const errorContainerStyle: CSSProperties = {
114
  display: "flex",
115
  flexDirection: "column",
@@ -125,6 +154,67 @@ const errorIconStyle: CSSProperties = {
125
  color: "#ef4444",
126
  };
127
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
128
  const analysisContentStyle: CSSProperties = {
129
  fontSize: "var(--font-size-base)",
130
  color: "var(--color-text-primary)",
@@ -132,25 +222,99 @@ const analysisContentStyle: CSSProperties = {
132
  };
133
 
134
  const streamingIndicatorStyle: CSSProperties = {
135
- marginTop: "var(--spacing-md)",
136
  display: "flex",
137
  alignItems: "center",
138
  gap: "var(--spacing-sm)",
 
139
  color: "var(--color-text-muted)",
 
140
  };
141
 
142
- const streamingTextStyle: CSSProperties = {
143
- fontSize: "var(--font-size-sm)",
 
 
 
 
144
  };
145
 
146
  export const AnalysisModal = ({ analysisState, onClose }: AnalysisModalProps): JSX.Element | null => {
147
  const contentRef = useRef<HTMLDivElement>(null);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
148
 
149
  useEffect((): void => {
150
- if (contentRef.current && analysisState.content) {
151
- contentRef.current.scrollTop = contentRef.current.scrollHeight;
 
 
 
 
152
  }
153
- }, [analysisState.content]);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
154
 
155
  useEffect((): (() => void) => {
156
  const handleEscape = (event: KeyboardEvent): void => {
@@ -160,29 +324,87 @@ export const AnalysisModal = ({ analysisState, onClose }: AnalysisModalProps): J
160
  };
161
 
162
  document.addEventListener("keydown", handleEscape);
163
- document.body.style.overflow = "hidden";
164
 
165
  return (): void => {
166
  document.removeEventListener("keydown", handleEscape);
167
- document.body.style.overflow = "";
168
  };
169
  }, [onClose]);
170
 
 
 
 
 
 
 
 
 
 
 
 
 
171
  if (!analysisState.isOpen) {
172
  return null;
173
  }
174
 
175
- const handleOverlayClick = (event: React.MouseEvent<HTMLDivElement>): void => {
176
- if (event.target === event.currentTarget) {
177
- onClose();
 
 
 
 
 
178
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
179
  };
180
 
181
  const renderContent = (): JSX.Element => {
182
  if (analysisState.error) {
183
  return (
184
  <div style={errorContainerStyle}>
185
- <AlertCircle size={48} style={errorIconStyle} />
186
  <span>{analysisState.error}</span>
187
  </div>
188
  );
@@ -191,46 +413,56 @@ export const AnalysisModal = ({ analysisState, onClose }: AnalysisModalProps): J
191
  if (analysisState.isLoading && !analysisState.reasoning && !analysisState.content) {
192
  return (
193
  <div style={loadingContainerStyle}>
194
- <Sparkles size={48} style={spinnerStyle} />
195
- <span>{siteConfig.messages.analyzing}</span>
 
 
 
 
 
 
 
196
  </div>
197
  );
198
  }
199
 
200
- const htmlContent = analysisState.content ? marked.parse(analysisState.content) as string : "";
201
 
202
  return (
203
- <>
204
- <ReasoningSection
205
- reasoning={analysisState.reasoning}
206
- isReasoningComplete={analysisState.isReasoningComplete}
207
- isLoading={analysisState.isLoading}
208
- />
209
  {analysisState.content && (
210
  <div
211
  style={analysisContentStyle}
212
- dangerouslySetInnerHTML={{ __html: htmlContent }}
213
  />
214
  )}
215
  {analysisState.isLoading && analysisState.content && (
216
  <div style={streamingIndicatorStyle}>
217
- <Sparkles size={16} style={spinnerStyle} />
218
- <span style={streamingTextStyle}>{siteConfig.messages.analyzing}</span>
219
  </div>
220
  )}
221
- </>
222
  );
223
  };
224
 
225
  const modalContent = (
226
- <div style={overlayStyle} onClick={handleOverlayClick}>
227
- <div style={modalStyle}>
228
  <div style={headerStyle}>
229
- <div style={headerTitleContainerStyle}>
230
- <Sparkles size={20} style={headerIconStyle} />
231
- <span style={headerTitleStyle}>
232
- {analysisState.isLoading ? siteConfig.messages.analyzing : siteConfig.messages.analysisComplete}
233
- </span>
 
 
 
 
 
 
 
234
  </div>
235
  <button
236
  type="button"
@@ -238,10 +470,14 @@ export const AnalysisModal = ({ analysisState, onClose }: AnalysisModalProps): J
238
  style={closeButtonStyle}
239
  title={siteConfig.messages.closeAnalysis}
240
  >
241
- <X size={20} />
242
  </button>
243
  </div>
244
- <div style={contentContainerStyle} ref={contentRef}>
 
 
 
 
245
  {renderContent()}
246
  </div>
247
  </div>
 
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, Sparkles, AlertCircle, Brain, ChevronDown, ChevronUp } from "lucide-react";
 
9
  import { siteConfig } from "@shared/config";
10
+ import { renderMarkdown } from "../../utils/markdownRenderer";
11
  import type { AnalysisState } from "@shared/types";
12
  import type { CSSProperties } from "react";
13
 
 
16
  onClose: () => void;
17
  }
18
 
 
 
 
 
 
 
19
  const overlayStyle: CSSProperties = {
20
  position: "fixed",
21
  top: 0,
22
  left: 0,
23
  right: 0,
24
  bottom: 0,
 
25
  zIndex: 1000,
26
  display: "flex",
27
+ alignItems: "flex-end",
28
  justifyContent: "center",
29
+ padding: "0 var(--spacing-md)",
30
+ pointerEvents: "none",
31
  };
32
 
33
+ const panelStyle: CSSProperties = {
34
  width: "100%",
35
+ maxWidth: "var(--max-width-container)",
36
+ height: "70vh",
37
  backgroundColor: "var(--color-background)",
38
+ borderRadius: "var(--border-radius-lg) var(--border-radius-lg) 0 0",
39
+ boxShadow: "0 -8px 32px rgba(0, 0, 0, 0.2)",
40
  display: "flex",
41
  flexDirection: "column",
42
  overflow: "hidden",
43
+ pointerEvents: "auto",
44
+ animation: "slideUp 0.4s cubic-bezier(0.16, 1, 0.3, 1)",
45
+ border: "1px solid var(--color-border)",
46
+ borderBottom: "none",
47
  };
48
 
49
  const headerStyle: CSSProperties = {
 
56
  flexShrink: 0,
57
  };
58
 
59
+ const headerLeftStyle: CSSProperties = {
60
  display: "flex",
61
  alignItems: "center",
62
  gap: "var(--spacing-sm)",
63
  };
64
 
65
+ const headerIconContainerStyle: CSSProperties = {
66
+ display: "flex",
67
+ alignItems: "center",
68
+ justifyContent: "center",
69
+ width: "32px",
70
+ height: "32px",
71
+ borderRadius: "var(--border-radius-md)",
72
+ backgroundColor: "var(--color-accent)",
73
+ color: "#ffffff",
74
  };
75
 
76
  const headerTitleStyle: CSSProperties = {
77
+ fontSize: "var(--font-size-base)",
78
  fontWeight: 600,
79
  color: "var(--color-text-primary)",
80
  };
81
 
82
+ const headerSubtitleStyle: CSSProperties = {
83
+ fontSize: "var(--font-size-xs)",
84
+ color: "var(--color-text-muted)",
85
+ };
86
+
87
  const closeButtonStyle: CSSProperties = {
88
  display: "flex",
89
  alignItems: "center",
90
  justifyContent: "center",
91
  width: "32px",
92
  height: "32px",
93
+ borderRadius: "var(--border-radius-md)",
94
+ border: "2px solid var(--color-border)",
95
+ backgroundColor: "var(--color-surface)",
96
  color: "var(--color-text-muted)",
97
  cursor: "pointer",
98
  transition: "all var(--transition-fast)",
 
101
  const contentContainerStyle: CSSProperties = {
102
  flex: 1,
103
  overflowY: "auto",
104
+ overflowX: "hidden",
105
+ scrollbarGutter: "stable",
106
+ };
107
+
108
+ const contentInnerStyle: CSSProperties = {
109
  padding: "var(--spacing-lg)",
110
+ display: "flex",
111
+ flexDirection: "column",
112
+ gap: "var(--spacing-lg)",
113
  };
114
 
115
  const loadingContainerStyle: CSSProperties = {
 
122
  color: "var(--color-text-muted)",
123
  };
124
 
125
+ const loadingSpinnerStyle: CSSProperties = {
126
  animation: "spin 1s linear infinite",
127
  color: "var(--color-accent)",
128
  };
129
 
130
+ const loadingTextStyle: CSSProperties = {
131
+ fontSize: "var(--font-size-sm)",
132
+ display: "flex",
133
+ alignItems: "center",
134
+ gap: "var(--spacing-sm)",
135
+ };
136
+
137
+ const loadingDotsStyle: CSSProperties = {
138
+ display: "inline-flex",
139
+ gap: "4px",
140
+ };
141
+
142
  const errorContainerStyle: CSSProperties = {
143
  display: "flex",
144
  flexDirection: "column",
 
154
  color: "#ef4444",
155
  };
156
 
157
+ const reasoningSectionStyle: CSSProperties = {
158
+ borderRadius: "var(--border-radius-md)",
159
+ border: "1px solid var(--color-border)",
160
+ backgroundColor: "var(--color-surface)",
161
+ overflow: "hidden",
162
+ };
163
+
164
+ const reasoningHeaderStyle: CSSProperties = {
165
+ display: "flex",
166
+ alignItems: "center",
167
+ gap: "var(--spacing-sm)",
168
+ padding: "var(--spacing-sm) var(--spacing-md)",
169
+ cursor: "pointer",
170
+ userSelect: "none",
171
+ backgroundColor: "var(--color-surface)",
172
+ transition: "background-color var(--transition-fast)",
173
+ };
174
+
175
+ const reasoningIconStyle: CSSProperties = {
176
+ color: "var(--color-accent)",
177
+ flexShrink: 0,
178
+ };
179
+
180
+ const reasoningTitleStyle: CSSProperties = {
181
+ flex: 1,
182
+ fontSize: "var(--font-size-sm)",
183
+ fontWeight: 600,
184
+ color: "var(--color-text-primary)",
185
+ display: "flex",
186
+ alignItems: "center",
187
+ gap: "var(--spacing-xs)",
188
+ };
189
+
190
+ const pulsingDotStyle: CSSProperties = {
191
+ width: "6px",
192
+ height: "6px",
193
+ borderRadius: "50%",
194
+ backgroundColor: "var(--color-accent)",
195
+ animation: "pulse 1.5s ease-in-out infinite",
196
+ };
197
+
198
+ const chevronStyle: CSSProperties = {
199
+ color: "var(--color-text-muted)",
200
+ flexShrink: 0,
201
+ transition: "transform var(--transition-fast)",
202
+ };
203
+
204
+ const reasoningContentStyle: CSSProperties = {
205
+ maxHeight: "350px",
206
+ overflowY: "auto",
207
+ padding: "var(--spacing-md)",
208
+ borderTop: "1px solid var(--color-border)",
209
+ scrollbarGutter: "stable",
210
+ };
211
+
212
+ const reasoningTextStyle: CSSProperties = {
213
+ fontSize: "var(--font-size-sm)",
214
+ color: "var(--color-text-secondary)",
215
+ lineHeight: 1.7,
216
+ };
217
+
218
  const analysisContentStyle: CSSProperties = {
219
  fontSize: "var(--font-size-base)",
220
  color: "var(--color-text-primary)",
 
222
  };
223
 
224
  const streamingIndicatorStyle: CSSProperties = {
 
225
  display: "flex",
226
  alignItems: "center",
227
  gap: "var(--spacing-sm)",
228
+ padding: "var(--spacing-sm) 0",
229
  color: "var(--color-text-muted)",
230
+ fontSize: "var(--font-size-sm)",
231
  };
232
 
233
+ const streamingDotStyle: CSSProperties = {
234
+ width: "8px",
235
+ height: "8px",
236
+ borderRadius: "50%",
237
+ backgroundColor: "var(--color-accent)",
238
+ animation: "pulse 1s ease-in-out infinite",
239
  };
240
 
241
  export const AnalysisModal = ({ analysisState, onClose }: AnalysisModalProps): JSX.Element | null => {
242
  const contentRef = useRef<HTMLDivElement>(null);
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 => {
253
+ if (userToggledReasoning) {
254
+ return;
255
+ }
256
+
257
+ if (analysisState.isLoading && analysisState.reasoning.length > 0 && !analysisState.isReasoningComplete) {
258
+ setIsReasoningExpanded(true);
259
+ }
260
+
261
+ if (analysisState.isReasoningComplete && analysisState.reasoning.length > 0 && !hasAutoCollapsedRef.current) {
262
+ hasAutoCollapsedRef.current = true;
263
+ setIsReasoningExpanded(false);
264
+ }
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 => {
 
324
  };
325
 
326
  document.addEventListener("keydown", handleEscape);
 
327
 
328
  return (): void => {
329
  document.removeEventListener("keydown", handleEscape);
 
330
  };
331
  }, [onClose]);
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
+
345
  if (!analysisState.isOpen) {
346
  return null;
347
  }
348
 
349
+ const handleReasoningToggle = (): void => {
350
+ setUserToggledReasoning(true);
351
+ setIsReasoningExpanded((previous) => !previous);
352
+ };
353
+
354
+ const renderReasoningSection = (): JSX.Element | null => {
355
+ if (!analysisState.reasoning) {
356
+ return null;
357
  }
358
+
359
+ const reasoningHtml = renderMarkdown(analysisState.reasoning);
360
+
361
+ return (
362
+ <div style={reasoningSectionStyle}>
363
+ <div
364
+ style={reasoningHeaderStyle}
365
+ onClick={handleReasoningToggle}
366
+ role="button"
367
+ tabIndex={0}
368
+ onKeyDown={(event): void => {
369
+ if (event.key === "Enter" || event.key === " ") {
370
+ handleReasoningToggle();
371
+ }
372
+ }}
373
+ >
374
+ <Brain size={16} style={reasoningIconStyle} />
375
+ <span style={reasoningTitleStyle}>
376
+ {siteConfig.messages.reasoning}
377
+ {analysisState.isLoading && !analysisState.isReasoningComplete && (
378
+ <span style={pulsingDotStyle} />
379
+ )}
380
+ </span>
381
+ {isReasoningExpanded ? (
382
+ <ChevronUp size={16} style={chevronStyle} />
383
+ ) : (
384
+ <ChevronDown size={16} style={chevronStyle} />
385
+ )}
386
+ </div>
387
+ {isReasoningExpanded && (
388
+ <div
389
+ ref={reasoningContentRef}
390
+ style={reasoningContentStyle}
391
+ onScroll={handleReasoningScroll}
392
+ >
393
+ <div
394
+ style={reasoningTextStyle}
395
+ dangerouslySetInnerHTML={{ __html: reasoningHtml }}
396
+ />
397
+ </div>
398
+ )}
399
+ </div>
400
+ );
401
  };
402
 
403
  const renderContent = (): JSX.Element => {
404
  if (analysisState.error) {
405
  return (
406
  <div style={errorContainerStyle}>
407
+ <AlertCircle size={40} style={errorIconStyle} />
408
  <span>{analysisState.error}</span>
409
  </div>
410
  );
 
413
  if (analysisState.isLoading && !analysisState.reasoning && !analysisState.content) {
414
  return (
415
  <div style={loadingContainerStyle}>
416
+ <Sparkles size={40} style={loadingSpinnerStyle} />
417
+ <div style={loadingTextStyle}>
418
+ <span>{siteConfig.messages.analyzing}</span>
419
+ <span style={loadingDotsStyle}>
420
+ <span style={{ ...pulsingDotStyle, animationDelay: "0s" }} />
421
+ <span style={{ ...pulsingDotStyle, animationDelay: "0.2s" }} />
422
+ <span style={{ ...pulsingDotStyle, animationDelay: "0.4s" }} />
423
+ </span>
424
+ </div>
425
  </div>
426
  );
427
  }
428
 
429
+ const contentHtml = renderMarkdown(analysisState.content);
430
 
431
  return (
432
+ <div style={contentInnerStyle}>
433
+ {renderReasoningSection()}
 
 
 
 
434
  {analysisState.content && (
435
  <div
436
  style={analysisContentStyle}
437
+ dangerouslySetInnerHTML={{ __html: contentHtml }}
438
  />
439
  )}
440
  {analysisState.isLoading && analysisState.content && (
441
  <div style={streamingIndicatorStyle}>
442
+ <span style={streamingDotStyle} />
443
+ <span>{siteConfig.messages.analyzing}</span>
444
  </div>
445
  )}
446
+ </div>
447
  );
448
  };
449
 
450
  const modalContent = (
451
+ <div style={overlayStyle}>
452
+ <div style={panelStyle}>
453
  <div style={headerStyle}>
454
+ <div style={headerLeftStyle}>
455
+ <div style={headerIconContainerStyle}>
456
+ <Sparkles size={16} />
457
+ </div>
458
+ <div>
459
+ <div style={headerTitleStyle}>
460
+ {analysisState.isLoading ? siteConfig.messages.analyzing : siteConfig.messages.analysisComplete}
461
+ </div>
462
+ <div style={headerSubtitleStyle}>
463
+ {siteConfig.messages.analysisInfo}
464
+ </div>
465
+ </div>
466
  </div>
467
  <button
468
  type="button"
 
470
  style={closeButtonStyle}
471
  title={siteConfig.messages.closeAnalysis}
472
  >
473
+ <X size={18} />
474
  </button>
475
  </div>
476
+ <div
477
+ ref={contentRef}
478
+ style={contentContainerStyle}
479
+ onScroll={handleContentScroll}
480
+ >
481
  {renderContent()}
482
  </div>
483
  </div>
src/client/components/ai/AnalyzeButton.tsx DELETED
@@ -1,70 +0,0 @@
1
- //
2
- // SPDX-FileCopyrightText: Hadad <hadad@linuxmail.org>
3
- // SPDX-License-Identifier: Apache-2.0
4
- //
5
-
6
- import { Sparkles } from "lucide-react";
7
- import { siteConfig } from "@shared/config";
8
- import type { CSSProperties } from "react";
9
-
10
- interface AnalyzeButtonProps {
11
- onClick: () => void;
12
- isLoading: boolean;
13
- }
14
-
15
- const buttonContainerStyle: CSSProperties = {
16
- position: "fixed",
17
- bottom: "calc(var(--footer-height) + var(--spacing-lg))",
18
- left: "50%",
19
- transform: "translateX(-50%)",
20
- zIndex: 90,
21
- };
22
-
23
- const buttonStyle: CSSProperties = {
24
- display: "inline-flex",
25
- alignItems: "center",
26
- justifyContent: "center",
27
- gap: "var(--spacing-sm)",
28
- padding: "var(--spacing-sm) var(--spacing-lg)",
29
- borderRadius: "var(--border-radius-lg)",
30
- border: "1px solid var(--color-border)",
31
- backgroundColor: "rgba(var(--color-surface-rgb, 248, 249, 250), 0.85)",
32
- backdropFilter: "blur(8px)",
33
- color: "var(--color-text-primary)",
34
- fontSize: "var(--font-size-sm)",
35
- fontWeight: 500,
36
- cursor: "pointer",
37
- transition: "all var(--transition-fast)",
38
- boxShadow: "var(--shadow-md)",
39
- whiteSpace: "nowrap",
40
- };
41
-
42
- const buttonDisabledStyle: CSSProperties = {
43
- ...buttonStyle,
44
- opacity: 0.7,
45
- cursor: "not-allowed",
46
- };
47
-
48
- const iconStyle: CSSProperties = {
49
- color: "var(--color-accent)",
50
- };
51
-
52
- const spinnerStyle: CSSProperties = {
53
- animation: "spin 1s linear infinite",
54
- };
55
-
56
- export const AnalyzeButton = ({ onClick, isLoading }: AnalyzeButtonProps): JSX.Element => {
57
- return (
58
- <div style={buttonContainerStyle}>
59
- <button
60
- type="button"
61
- onClick={onClick}
62
- disabled={isLoading}
63
- style={isLoading ? buttonDisabledStyle : buttonStyle}
64
- >
65
- <Sparkles size={16} style={isLoading ? { ...iconStyle, ...spinnerStyle } : iconStyle} />
66
- {isLoading ? siteConfig.messages.analyzing : siteConfig.messages.analyzeWithAI}
67
- </button>
68
- </div>
69
- );
70
- };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/client/components/ai/ReasoningSection.tsx DELETED
@@ -1,143 +0,0 @@
1
- //
2
- // SPDX-FileCopyrightText: Hadad <hadad@linuxmail.org>
3
- // SPDX-License-Identifier: Apache-2.0
4
- //
5
-
6
- import { useState, useEffect } from "react";
7
- import { ChevronDown, ChevronRight, Brain } from "lucide-react";
8
- import { siteConfig } from "@shared/config";
9
- import type { CSSProperties } from "react";
10
-
11
- interface ReasoningSectionProps {
12
- reasoning: string;
13
- isReasoningComplete: boolean;
14
- isLoading: boolean;
15
- }
16
-
17
- const containerStyle: CSSProperties = {
18
- marginBottom: "var(--spacing-lg)",
19
- borderRadius: "var(--border-radius-md)",
20
- border: "1px solid var(--color-border)",
21
- backgroundColor: "var(--color-surface)",
22
- overflow: "hidden",
23
- };
24
-
25
- const headerStyle: CSSProperties = {
26
- display: "flex",
27
- alignItems: "center",
28
- gap: "var(--spacing-sm)",
29
- padding: "var(--spacing-md)",
30
- cursor: "pointer",
31
- userSelect: "none",
32
- backgroundColor: "var(--color-surface)",
33
- transition: "background-color var(--transition-fast)",
34
- };
35
-
36
- const headerIconStyle: CSSProperties = {
37
- color: "var(--color-accent)",
38
- flexShrink: 0,
39
- };
40
-
41
- const headerTitleStyle: CSSProperties = {
42
- flex: 1,
43
- fontSize: "var(--font-size-sm)",
44
- fontWeight: 600,
45
- color: "var(--color-text-primary)",
46
- display: "flex",
47
- alignItems: "center",
48
- gap: "var(--spacing-xs)",
49
- };
50
-
51
- const pulsingDotStyle: CSSProperties = {
52
- width: "8px",
53
- height: "8px",
54
- borderRadius: "50%",
55
- backgroundColor: "var(--color-accent)",
56
- animation: "pulse 1.5s ease-in-out infinite",
57
- };
58
-
59
- const chevronStyle: CSSProperties = {
60
- color: "var(--color-text-muted)",
61
- flexShrink: 0,
62
- transition: "transform var(--transition-fast)",
63
- };
64
-
65
- const contentContainerStyle: CSSProperties = {
66
- maxHeight: "300px",
67
- overflowY: "auto",
68
- padding: "var(--spacing-md)",
69
- borderTop: "1px solid var(--color-border)",
70
- };
71
-
72
- const contentTextStyle: CSSProperties = {
73
- fontSize: "var(--font-size-sm)",
74
- color: "var(--color-text-secondary)",
75
- lineHeight: 1.6,
76
- whiteSpace: "pre-wrap",
77
- wordBreak: "break-word",
78
- fontFamily: "var(--font-family-mono)",
79
- };
80
-
81
- export const ReasoningSection = ({
82
- reasoning,
83
- isReasoningComplete,
84
- isLoading,
85
- }: ReasoningSectionProps): JSX.Element | null => {
86
- const [isExpanded, setIsExpanded] = useState<boolean>(false);
87
- const [userToggled, setUserToggled] = useState<boolean>(false);
88
-
89
- useEffect((): void => {
90
- if (userToggled) {
91
- return;
92
- }
93
-
94
- if (isLoading && reasoning.length > 0 && !isReasoningComplete) {
95
- setIsExpanded(true);
96
- }
97
-
98
- if (isReasoningComplete && reasoning.length > 0) {
99
- setIsExpanded(false);
100
- }
101
- }, [isLoading, reasoning, isReasoningComplete, userToggled]);
102
-
103
- const handleToggle = (): void => {
104
- setUserToggled(true);
105
- setIsExpanded((previous) => !previous);
106
- };
107
-
108
- if (!reasoning) {
109
- return null;
110
- }
111
-
112
- return (
113
- <div style={containerStyle}>
114
- <div
115
- style={headerStyle}
116
- onClick={handleToggle}
117
- role="button"
118
- tabIndex={0}
119
- onKeyDown={(event): void => {
120
- if (event.key === "Enter" || event.key === " ") {
121
- handleToggle();
122
- }
123
- }}
124
- >
125
- <Brain size={18} style={headerIconStyle} />
126
- <span style={headerTitleStyle}>
127
- {siteConfig.messages.reasoning}
128
- {isLoading && !isReasoningComplete && <span style={pulsingDotStyle} />}
129
- </span>
130
- {isExpanded ? (
131
- <ChevronDown size={18} style={chevronStyle} />
132
- ) : (
133
- <ChevronRight size={18} style={chevronStyle} />
134
- )}
135
- </div>
136
- {isExpanded && (
137
- <div style={contentContainerStyle}>
138
- <div style={contentTextStyle}>{reasoning}</div>
139
- </div>
140
- )}
141
- </div>
142
- );
143
- };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/client/components/layout/Header.tsx CHANGED
@@ -7,7 +7,8 @@ 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 = {
@@ -113,6 +114,40 @@ const iconButtonStyle: CSSProperties = {
113
  textDecoration: "none",
114
  };
115
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
116
  const ThemeIcon = ({ theme }: { theme: "system" | "light" | "dark" }): JSX.Element => {
117
  if (theme === "light") {
118
  return <Sun size={18} />;
@@ -126,6 +161,7 @@ const ThemeIcon = ({ theme }: { theme: "system" | "light" | "dark" }): JSX.Eleme
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"];
@@ -135,6 +171,14 @@ export const Header = (): JSX.Element => {
135
  };
136
 
137
  const isOnPostPage = currentPostTitle !== null;
 
 
 
 
 
 
 
 
138
 
139
  return (
140
  <header style={headerStyle}>
@@ -166,6 +210,17 @@ export const Header = (): JSX.Element => {
166
  </div>
167
 
168
  <div style={rightSectionStyle}>
 
 
 
 
 
 
 
 
 
 
 
169
  {isOnPostPage && (
170
  <Link to="/" style={iconButtonStyle} title={siteConfig.messages.backToHome}>
171
  <Home size={18} />
 
7
  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 = {
 
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 = {
148
+ animation: "spin 1s linear infinite",
149
+ };
150
+
151
  const ThemeIcon = ({ theme }: { theme: "system" | "light" | "dark" }): JSX.Element => {
152
  if (theme === "light") {
153
  return <Sun size={18} />;
 
161
  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"];
 
171
  };
172
 
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) {
179
+ startAnalysis();
180
+ }
181
+ };
182
 
183
  return (
184
  <header style={headerStyle}>
 
210
  </div>
211
 
212
  <div style={rightSectionStyle}>
213
+ {showAnalyzeButton && (
214
+ <button
215
+ type="button"
216
+ onClick={handleAnalyzeClick}
217
+ disabled={!canAnalyze}
218
+ style={canAnalyze ? analyzeButtonStyle : analyzeButtonDisabledStyle}
219
+ title={siteConfig.messages.analyzeWithAI}
220
+ >
221
+ <Sparkles size={18} style={analysisState.isLoading ? spinnerIconStyle : undefined} />
222
+ </button>
223
+ )}
224
  {isOnPostPage && (
225
  <Link to="/" style={iconButtonStyle} title={siteConfig.messages.backToHome}>
226
  <Home size={18} />
src/client/components/layout/Layout.tsx CHANGED
@@ -17,13 +17,18 @@ const layoutStyle: CSSProperties = {
17
  display: "flex",
18
  flexDirection: "column",
19
  overflow: "hidden",
20
- transition: "filter var(--transition-fast)",
21
  };
22
 
23
  const layoutBlurredStyle: CSSProperties = {
24
- ...layoutStyle,
25
- filter: "blur(4px)",
 
 
 
 
26
  pointerEvents: "none",
 
27
  };
28
 
29
  const mainStyle: CSSProperties = {
 
17
  display: "flex",
18
  flexDirection: "column",
19
  overflow: "hidden",
20
+ transition: "filter var(--transition-base), transform var(--transition-base)",
21
  };
22
 
23
  const layoutBlurredStyle: CSSProperties = {
24
+ height: "100%",
25
+ display: "flex",
26
+ flexDirection: "column",
27
+ overflow: "hidden",
28
+ filter: "blur(20px) brightness(0.4) saturate(0.5)",
29
+ transform: "scale(0.95)",
30
  pointerEvents: "none",
31
+ transition: "filter var(--transition-base), transform var(--transition-base)",
32
  };
33
 
34
  const mainStyle: CSSProperties = {
src/client/contexts/AnalysisContext.tsx CHANGED
@@ -3,12 +3,27 @@
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 AnalysisContextValue {
10
  isBlurred: boolean;
11
- setIsBlurred: (value: boolean) => void;
 
 
 
 
 
12
  }
13
 
14
  const AnalysisContext = createContext<AnalysisContextValue | null>(null);
@@ -18,16 +33,186 @@ interface AnalysisProviderProps {
18
  }
19
 
20
  export const AnalysisProvider = ({ children }: AnalysisProviderProps): JSX.Element => {
21
- const [isBlurred, setIsBlurredState] = useState<boolean>(false);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
 
23
- const setIsBlurred = useCallback((value: boolean): void => {
24
- setIsBlurredState(value);
 
 
 
 
 
25
  }, []);
26
 
27
  const contextValue = useMemo((): AnalysisContextValue => ({
28
  isBlurred,
29
- setIsBlurred,
30
- }), [isBlurred, setIsBlurred]);
 
 
 
 
 
31
 
32
  return (
33
  <AnalysisContext.Provider value={contextValue}>
 
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 { AnalysisState, AIStreamChunk } from "@shared/types";
9
+
10
+ const initialAnalysisState: AnalysisState = {
11
+ isOpen: false,
12
+ isLoading: false,
13
+ reasoning: "",
14
+ content: "",
15
+ isReasoningComplete: false,
16
+ error: null,
17
+ };
18
 
19
  interface AnalysisContextValue {
20
  isBlurred: boolean;
21
+ analysisState: AnalysisState;
22
+ postContent: string | null;
23
+ postTitle: string | null;
24
+ setPostInfo: (content: string | null, title: string | null) => void;
25
+ startAnalysis: () => Promise<void>;
26
+ resetAnalysis: () => void;
27
  }
28
 
29
  const AnalysisContext = createContext<AnalysisContextValue | null>(null);
 
33
  }
34
 
35
  export const AnalysisProvider = ({ children }: AnalysisProviderProps): JSX.Element => {
36
+ const [analysisState, setAnalysisState] = useState<AnalysisState>(initialAnalysisState);
37
+ const [postContent, setPostContent] = useState<string | null>(null);
38
+ const [postTitle, setPostTitle] = useState<string | null>(null);
39
+ const abortControllerRef = useRef<AbortController | null>(null);
40
+
41
+ const isBlurred = analysisState.isOpen;
42
+
43
+ const setPostInfo = useCallback((content: string | null, title: string | null): void => {
44
+ setPostContent(content);
45
+ setPostTitle(title);
46
+ }, []);
47
+
48
+ const resetAnalysis = useCallback((): void => {
49
+ if (abortControllerRef.current) {
50
+ abortControllerRef.current.abort();
51
+ abortControllerRef.current = null;
52
+ }
53
+ setAnalysisState(initialAnalysisState);
54
+ }, []);
55
+
56
+ const startAnalysis = useCallback(async (): Promise<void> => {
57
+ if (!postContent || !postTitle) {
58
+ return;
59
+ }
60
+
61
+ if (abortControllerRef.current) {
62
+ abortControllerRef.current.abort();
63
+ }
64
+
65
+ abortControllerRef.current = new AbortController();
66
+
67
+ setAnalysisState({
68
+ isOpen: true,
69
+ isLoading: true,
70
+ reasoning: "",
71
+ content: "",
72
+ isReasoningComplete: false,
73
+ error: null,
74
+ });
75
+
76
+ try {
77
+ const response = await fetch("/api/analyze", {
78
+ method: "POST",
79
+ headers: {
80
+ "Content-Type": "application/json",
81
+ },
82
+ body: JSON.stringify({ content: postContent, title: postTitle }),
83
+ signal: abortControllerRef.current.signal,
84
+ });
85
+
86
+ if (!response.ok) {
87
+ throw new Error("Failed to start analysis");
88
+ }
89
+
90
+ const reader = response.body?.getReader();
91
+ if (!reader) {
92
+ throw new Error("No response stream");
93
+ }
94
+
95
+ const decoder = new TextDecoder("utf-8");
96
+ let buffer = "";
97
+ let hasReceivedContent = false;
98
+
99
+ while (true) {
100
+ const { done, value } = await reader.read();
101
+
102
+ if (done) {
103
+ break;
104
+ }
105
+
106
+ buffer += decoder.decode(value, { stream: true });
107
+ const lines = buffer.split("\n");
108
+ buffer = lines.pop() || "";
109
+
110
+ for (const line of lines) {
111
+ const trimmedLine = line.trim();
112
+
113
+ if (!trimmedLine || !trimmedLine.startsWith("data: ")) {
114
+ continue;
115
+ }
116
+
117
+ const dataContent = trimmedLine.slice(6);
118
+
119
+ try {
120
+ const chunk: AIStreamChunk = JSON.parse(dataContent);
121
+
122
+ if (chunk.type === "error") {
123
+ setAnalysisState((previous) => ({
124
+ ...previous,
125
+ isLoading: false,
126
+ error: chunk.content || "An error occurred",
127
+ }));
128
+ return;
129
+ }
130
+
131
+ if (chunk.type === "done") {
132
+ setAnalysisState((previous) => ({
133
+ ...previous,
134
+ isLoading: false,
135
+ isReasoningComplete: true,
136
+ }));
137
+ return;
138
+ }
139
+
140
+ if (chunk.type === "reasoning" && chunk.content) {
141
+ setAnalysisState((previous) => ({
142
+ ...previous,
143
+ reasoning: previous.reasoning + chunk.content,
144
+ }));
145
+ }
146
+
147
+ if (chunk.type === "content" && chunk.content) {
148
+ if (!hasReceivedContent) {
149
+ hasReceivedContent = true;
150
+ setAnalysisState((previous) => ({
151
+ ...previous,
152
+ isReasoningComplete: true,
153
+ }));
154
+ }
155
+ setAnalysisState((previous) => ({
156
+ ...previous,
157
+ content: previous.content + chunk.content,
158
+ }));
159
+ }
160
+ } catch {
161
+ continue;
162
+ }
163
+ }
164
+ }
165
+
166
+ setAnalysisState((previous) => ({
167
+ ...previous,
168
+ isLoading: false,
169
+ isReasoningComplete: true,
170
+ }));
171
+ } catch (error) {
172
+ if (error instanceof Error && error.name === "AbortError") {
173
+ return;
174
+ }
175
+
176
+ setAnalysisState((previous) => ({
177
+ ...previous,
178
+ isLoading: false,
179
+ error: error instanceof Error ? error.message : "An error occurred",
180
+ }));
181
+ }
182
+ }, [postContent, postTitle]);
183
+
184
+ useEffect((): (() => void) => {
185
+ const handlePopState = (): void => {
186
+ if (analysisState.isOpen) {
187
+ resetAnalysis();
188
+ }
189
+ };
190
+
191
+ window.addEventListener("popstate", handlePopState);
192
+
193
+ return (): void => {
194
+ window.removeEventListener("popstate", handlePopState);
195
+ };
196
+ }, [analysisState.isOpen, resetAnalysis]);
197
 
198
+ useEffect((): (() => void) => {
199
+ return (): void => {
200
+ if (abortControllerRef.current) {
201
+ abortControllerRef.current.abort();
202
+ abortControllerRef.current = null;
203
+ }
204
+ };
205
  }, []);
206
 
207
  const contextValue = useMemo((): AnalysisContextValue => ({
208
  isBlurred,
209
+ analysisState,
210
+ postContent,
211
+ postTitle,
212
+ setPostInfo,
213
+ startAnalysis,
214
+ resetAnalysis,
215
+ }), [isBlurred, analysisState, postContent, postTitle, setPostInfo, startAnalysis, resetAnalysis]);
216
 
217
  return (
218
  <AnalysisContext.Provider value={contextValue}>
src/client/hooks/useAIAnalysis.ts DELETED
@@ -1,178 +0,0 @@
1
- //
2
- // SPDX-FileCopyrightText: Hadad <hadad@linuxmail.org>
3
- // SPDX-License-Identifier: Apache-2.0
4
- //
5
-
6
- import { useState, useCallback, useRef } from "react";
7
- import type { AnalysisState, AIStreamChunk } from "@shared/types";
8
-
9
- interface UseAIAnalysisResult {
10
- analysisState: AnalysisState;
11
- startAnalysis: (content: string, title: string) => Promise<void>;
12
- resetAnalysis: () => void;
13
- abortAnalysis: () => void;
14
- }
15
-
16
- const initialState: AnalysisState = {
17
- isOpen: false,
18
- isLoading: false,
19
- reasoning: "",
20
- content: "",
21
- isReasoningComplete: false,
22
- error: null,
23
- };
24
-
25
- export const useAIAnalysis = (): UseAIAnalysisResult => {
26
- const [analysisState, setAnalysisState] = useState<AnalysisState>(initialState);
27
- const abortControllerRef = useRef<AbortController | null>(null);
28
-
29
- const resetAnalysis = useCallback((): void => {
30
- if (abortControllerRef.current) {
31
- abortControllerRef.current.abort();
32
- abortControllerRef.current = null;
33
- }
34
- setAnalysisState(initialState);
35
- }, []);
36
-
37
- const abortAnalysis = useCallback((): void => {
38
- if (abortControllerRef.current) {
39
- abortControllerRef.current.abort();
40
- abortControllerRef.current = null;
41
- }
42
- setAnalysisState((previous) => ({
43
- ...previous,
44
- isLoading: false,
45
- }));
46
- }, []);
47
-
48
- const startAnalysis = useCallback(async (content: string, title: string): Promise<void> => {
49
- if (abortControllerRef.current) {
50
- abortControllerRef.current.abort();
51
- }
52
-
53
- abortControllerRef.current = new AbortController();
54
-
55
- setAnalysisState({
56
- isOpen: true,
57
- isLoading: true,
58
- reasoning: "",
59
- content: "",
60
- isReasoningComplete: false,
61
- error: null,
62
- });
63
-
64
- try {
65
- const response = await fetch("/api/analyze", {
66
- method: "POST",
67
- headers: {
68
- "Content-Type": "application/json",
69
- },
70
- body: JSON.stringify({ content, title }),
71
- signal: abortControllerRef.current.signal,
72
- });
73
-
74
- if (!response.ok) {
75
- throw new Error("Failed to start analysis");
76
- }
77
-
78
- const reader = response.body?.getReader();
79
- if (!reader) {
80
- throw new Error("No response stream");
81
- }
82
-
83
- const decoder = new TextDecoder("utf-8");
84
- let buffer = "";
85
- let hasReceivedContent = false;
86
-
87
- while (true) {
88
- const { done, value } = await reader.read();
89
-
90
- if (done) {
91
- break;
92
- }
93
-
94
- buffer += decoder.decode(value, { stream: true });
95
- const lines = buffer.split("\n");
96
- buffer = lines.pop() || "";
97
-
98
- for (const line of lines) {
99
- const trimmedLine = line.trim();
100
-
101
- if (!trimmedLine || !trimmedLine.startsWith("data: ")) {
102
- continue;
103
- }
104
-
105
- const dataContent = trimmedLine.slice(6);
106
-
107
- try {
108
- const chunk: AIStreamChunk = JSON.parse(dataContent);
109
-
110
- if (chunk.type === "error") {
111
- setAnalysisState((previous) => ({
112
- ...previous,
113
- isLoading: false,
114
- error: chunk.content || "An error occurred",
115
- }));
116
- return;
117
- }
118
-
119
- if (chunk.type === "done") {
120
- setAnalysisState((previous) => ({
121
- ...previous,
122
- isLoading: false,
123
- isReasoningComplete: true,
124
- }));
125
- return;
126
- }
127
-
128
- if (chunk.type === "reasoning" && chunk.content) {
129
- setAnalysisState((previous) => ({
130
- ...previous,
131
- reasoning: previous.reasoning + chunk.content,
132
- }));
133
- }
134
-
135
- if (chunk.type === "content" && chunk.content) {
136
- if (!hasReceivedContent) {
137
- hasReceivedContent = true;
138
- setAnalysisState((previous) => ({
139
- ...previous,
140
- isReasoningComplete: true,
141
- }));
142
- }
143
- setAnalysisState((previous) => ({
144
- ...previous,
145
- content: previous.content + chunk.content,
146
- }));
147
- }
148
- } catch {
149
- continue;
150
- }
151
- }
152
- }
153
-
154
- setAnalysisState((previous) => ({
155
- ...previous,
156
- isLoading: false,
157
- isReasoningComplete: true,
158
- }));
159
- } catch (error) {
160
- if (error instanceof Error && error.name === "AbortError") {
161
- return;
162
- }
163
-
164
- setAnalysisState((previous) => ({
165
- ...previous,
166
- isLoading: false,
167
- error: error instanceof Error ? error.message : "An error occurred",
168
- }));
169
- }
170
- }, []);
171
-
172
- return {
173
- analysisState,
174
- startAnalysis,
175
- resetAnalysis,
176
- abortAnalysis,
177
- };
178
- };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/client/pages/PostPage.tsx CHANGED
@@ -4,13 +4,11 @@
4
  //
5
 
6
  import { useParams, Link } from "react-router-dom";
7
- import { useEffect, useCallback } from "react";
8
  import { usePost } from "../hooks/usePosts";
9
  import { usePageContext } from "../contexts/PageContext";
10
  import { useAnalysisContext } from "../contexts/AnalysisContext";
11
- import { useAIAnalysis } from "../hooks/useAIAnalysis";
12
  import { PostContent } from "../components/blog/PostContent";
13
- import { AnalyzeButton } from "../components/ai/AnalyzeButton";
14
  import { AnalysisModal } from "../components/ai/AnalysisModal";
15
  import { Head } from "../components/common/Head";
16
  import { siteConfig } from "@shared/config";
@@ -67,31 +65,27 @@ export const PostPage = (): JSX.Element => {
67
  const { slug } = useParams<{ slug: string }>();
68
  const { post, loading, error } = usePost(slug || "");
69
  const { setCurrentPostTitle } = usePageContext();
70
- const { setIsBlurred } = useAnalysisContext();
71
- const { analysisState, startAnalysis, resetAnalysis } = useAIAnalysis();
 
 
 
 
 
 
72
 
73
  useEffect((): (() => void) => {
74
  if (post) {
75
  setCurrentPostTitle(post.frontMatter.title);
 
76
  }
77
 
78
  return (): void => {
79
  setCurrentPostTitle(null);
 
 
80
  };
81
- }, [post, setCurrentPostTitle]);
82
-
83
- useEffect((): void => {
84
- setIsBlurred(analysisState.isOpen);
85
- }, [analysisState.isOpen, setIsBlurred]);
86
-
87
- const handleAnalyzeClick = useCallback((): void => {
88
- if (!post) {
89
- return;
90
- }
91
-
92
- const textContent = extractTextFromHtml(post.content);
93
- startAnalysis(textContent, post.frontMatter.title);
94
- }, [post, startAnalysis]);
95
 
96
  const handleCloseAnalysis = useCallback((): void => {
97
  resetAnalysis();
@@ -123,8 +117,6 @@ export const PostPage = (): JSX.Element => {
123
  );
124
  }
125
 
126
- const showAnalyzeButton = siteConfig.features.enableAIAnalysis && !analysisState.isOpen;
127
-
128
  return (
129
  <div style={containerStyle}>
130
  <Head
@@ -134,12 +126,6 @@ export const PostPage = (): JSX.Element => {
134
  type="article"
135
  />
136
  <PostContent post={post} />
137
- {showAnalyzeButton && (
138
- <AnalyzeButton
139
- onClick={handleAnalyzeClick}
140
- isLoading={analysisState.isLoading}
141
- />
142
- )}
143
  <AnalysisModal
144
  analysisState={analysisState}
145
  onClose={handleCloseAnalysis}
 
4
  //
5
 
6
  import { useParams, Link } from "react-router-dom";
7
+ 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";
 
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) {
72
+ return extractTextFromHtml(post.content);
73
+ }
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();
 
117
  );
118
  }
119
 
 
 
120
  return (
121
  <div style={containerStyle}>
122
  <Head
 
126
  type="article"
127
  />
128
  <PostContent post={post} />
 
 
 
 
 
 
129
  <AnalysisModal
130
  analysisState={analysisState}
131
  onClose={handleCloseAnalysis}
src/client/styles/global.css CHANGED
@@ -437,4 +437,24 @@ iframe {
437
  50% {
438
  opacity: 0.4;
439
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
440
  }
 
437
  50% {
438
  opacity: 0.4;
439
  }
440
+ }
441
+
442
+ @keyframes slideUp {
443
+ from {
444
+ transform: translateY(100%);
445
+ opacity: 0;
446
+ }
447
+ to {
448
+ transform: translateY(0);
449
+ opacity: 1;
450
+ }
451
+ }
452
+
453
+ @keyframes fadeIn {
454
+ from {
455
+ opacity: 0;
456
+ }
457
+ to {
458
+ opacity: 1;
459
+ }
460
  }
src/client/utils/markdownRenderer.ts ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //
2
+ // SPDX-FileCopyrightText: Hadad <hadad@linuxmail.org>
3
+ // SPDX-License-Identifier: Apache-2.0
4
+ //
5
+
6
+ import { Marked } from "marked";
7
+ import { markedHighlight } from "marked-highlight";
8
+ import hljs from "highlight.js";
9
+
10
+ const marked = new Marked(
11
+ markedHighlight({
12
+ langPrefix: "hljs language-",
13
+ highlight(code: string, lang: string): string {
14
+ const language = hljs.getLanguage(lang) ? lang : "plaintext";
15
+ return hljs.highlight(code, { language }).value;
16
+ },
17
+ })
18
+ );
19
+
20
+ marked.setOptions({
21
+ gfm: true,
22
+ breaks: true,
23
+ });
24
+
25
+ export const renderMarkdown = (content: string): string => {
26
+ if (!content) {
27
+ return "";
28
+ }
29
+ return marked.parse(content) as string;
30
+ };
src/shared/config.ts CHANGED
@@ -28,6 +28,7 @@ export const siteConfig = {
28
  next: "Next",
29
  analyzeWithAI: "Analyze this article",
30
  analyzing: "Analyzing...",
 
31
  analysisComplete: "Analysis Complete",
32
  reasoning: "Reasoning",
33
  closeAnalysis: "Close",
 
28
  next: "Next",
29
  analyzeWithAI: "Analyze this article",
30
  analyzing: "Analyzing...",
31
+ analysisInfo: "AI Analysis",
32
  analysisComplete: "Analysis Complete",
33
  reasoning: "Reasoning",
34
  closeAnalysis: "Close",
vite.config.ts CHANGED
@@ -20,6 +20,7 @@ export default defineConfig({
20
  },
21
 
22
  build: {
 
23
  outDir: "dist/client",
24
  emptyOutDir: true,
25
  },
 
20
  },
21
 
22
  build: {
23
+ chunkSizeWarningLimit: 1600,
24
  outDir: "dist/client",
25
  emptyOutDir: true,
26
  },