|
1 | | -"use client"; |
2 | | - |
3 | | -import { notFound, useParams } from "next/navigation"; |
4 | | -import { sheetModules } from "@/data/sheet"; |
5 | | -import Link from "next/link"; |
6 | | -import { ArrowLeft, Download, Share2, Check } from "lucide-react"; |
7 | | -import { Badge } from "@/components/ui/badge"; |
8 | | -import { useState, useMemo, useEffect } from "react"; |
9 | | - |
10 | | -// Cache DOMPurify instance once loaded |
11 | | -let DOMPurifyInstance: typeof import("dompurify").default | null = null; |
12 | | -let DOMPurifyPromise: Promise<typeof import("dompurify").default> | null = null; |
13 | | - |
14 | | -// Helper function to get DOMPurify instance - loads asynchronously on first call |
15 | | -const getDOMPurify = async (): Promise< |
16 | | - typeof import("dompurify").default | null |
17 | | -> => { |
18 | | - if (typeof window === "undefined") { |
19 | | - return null; |
20 | | - } |
21 | | - |
22 | | - if (DOMPurifyInstance) { |
23 | | - return DOMPurifyInstance; |
24 | | - } |
25 | | - |
26 | | - if (DOMPurifyPromise) { |
27 | | - return DOMPurifyPromise; |
28 | | - } |
29 | | - |
30 | | - DOMPurifyPromise = import("dompurify").then((mod) => { |
31 | | - DOMPurifyInstance = mod.default; |
32 | | - return DOMPurifyInstance; |
33 | | - }); |
34 | | - |
35 | | - return DOMPurifyPromise; |
36 | | -}; |
37 | | - |
38 | | -// Synchronous sanitize function - returns unsanitized if DOMPurify not loaded yet |
39 | | -const sanitizeHTMLSync = ( |
40 | | - html: string, |
41 | | - options?: { ALLOWED_TAGS: string[] } |
42 | | -): string => { |
43 | | - if (typeof window === "undefined") { |
44 | | - return html; |
45 | | - } |
46 | | - |
47 | | - if (!DOMPurifyInstance) { |
48 | | - // Trigger async load but return unsanitized for now |
49 | | - getDOMPurify(); |
50 | | - return html; |
51 | | - } |
52 | | - |
53 | | - if (options) { |
54 | | - return DOMPurifyInstance.sanitize(html, options); |
55 | | - } |
56 | | - return DOMPurifyInstance.sanitize(html); |
57 | | -}; |
58 | | - |
59 | | -export default function ModuleDocPage() { |
60 | | - const params = useParams(); |
61 | | - const moduleId = params?.moduleId as string; |
62 | | - const [copied, setCopied] = useState(false); |
63 | | - const [isDOMPurifyLoaded, setIsDOMPurifyLoaded] = useState(false); |
64 | | - |
65 | | - const currentModule = sheetModules.find((m) => m.id === moduleId); |
66 | | - |
67 | | - // Load DOMPurify on mount |
68 | | - useEffect(() => { |
69 | | - if (typeof window !== "undefined") { |
70 | | - getDOMPurify().then(() => { |
71 | | - setIsDOMPurifyLoaded(true); |
72 | | - }); |
73 | | - } |
74 | | - }, []); |
75 | | - |
76 | | - const sanitizedDocContent = useMemo(() => { |
77 | | - if (!currentModule?.docContent) return ""; |
78 | | - return sanitizeHTMLSync(currentModule.docContent); |
79 | | - }, [currentModule?.docContent, isDOMPurifyLoaded]); |
80 | | - |
81 | | - const handleDownloadPDF = () => { |
82 | | - if (!currentModule) return; |
83 | | - |
84 | | - const printWindow = window.open("", "_blank"); |
85 | | - if (!printWindow) return; |
86 | | - |
87 | | - const sanitizedModuleName = sanitizeHTMLSync(currentModule.name, { |
88 | | - ALLOWED_TAGS: [], |
89 | | - }); |
90 | | - |
91 | | - const htmlContent = ` |
92 | | - <!DOCTYPE html> |
93 | | - <html> |
94 | | - <head> |
95 | | - <title>${sanitizedModuleName} - 30 days of Open Source sheet</title> |
96 | | - <style> |
97 | | - body { |
98 | | - font-family: 'Courier New', monospace; |
99 | | - padding: 20px; |
100 | | - background: white; |
101 | | - color: black; |
102 | | - max-width: 800px; |
103 | | - margin: 0 auto; |
104 | | - } |
105 | | - h1 { font-size: 24px; margin-bottom: 10px; } |
106 | | - .content { |
107 | | - line-height: 1.6; |
108 | | - } |
109 | | - .content h1 { font-size: 20px; margin-top: 20px; } |
110 | | - .content h2 { font-size: 18px; margin-top: 15px; } |
111 | | - .content p { margin-bottom: 10px; } |
112 | | - .content ul { margin-left: 20px; } |
113 | | - .content li { margin-bottom: 5px; } |
114 | | - .content pre { |
115 | | - background: #f5f5f5; |
116 | | - padding: 10px; |
117 | | - border-radius: 4px; |
118 | | - overflow-x: auto; |
119 | | - } |
120 | | - .content code { |
121 | | - background: #f5f5f5; |
122 | | - padding: 2px 4px; |
123 | | - border-radius: 2px; |
124 | | - } |
125 | | - </style> |
126 | | - </head> |
127 | | - <body> |
128 | | - <h1>${sanitizedModuleName}</h1> |
129 | | - <div class="content"> |
130 | | - ${sanitizedDocContent} |
131 | | - </div> |
132 | | - </body> |
133 | | - </html> |
134 | | - `; |
135 | | - |
136 | | - printWindow.document.write(htmlContent); |
137 | | - printWindow.document.close(); |
138 | | - printWindow.focus(); |
139 | | - setTimeout(() => { |
140 | | - printWindow.print(); |
141 | | - }, 250); |
142 | | - }; |
| 1 | +import { notFound } from "next/navigation"; |
| 2 | +import { getSheetModules } from "@/data/sheet"; |
| 3 | +import { SheetModuleHeader } from "@/components/sheet/SheetModuleHeader"; |
| 4 | +import { SheetContentRenderer } from "@/components/sheet/SheetContentRenderer"; |
| 5 | +import styles from "./sheet-content.module.css"; |
| 6 | + |
| 7 | +interface PageProps { |
| 8 | + params: Promise<{ moduleId: string }>; |
| 9 | +} |
143 | 10 |
|
144 | | - const handleShare = async () => { |
145 | | - const url = window.location.href; |
146 | | - try { |
147 | | - await navigator.clipboard.writeText(url); |
148 | | - setCopied(true); |
149 | | - setTimeout(() => setCopied(false), 2000); |
150 | | - } catch (clipboardErr) { |
151 | | - console.error("Failed to copy:", clipboardErr); |
152 | | - } |
153 | | - }; |
| 11 | +export default async function SheetModulePage({ params }: PageProps) { |
| 12 | + const { moduleId } = await params; |
| 13 | + const sheetModules = getSheetModules(); |
| 14 | + const sheetModule = sheetModules.find((m) => m.id === moduleId); |
154 | 15 |
|
155 | | - if (!currentModule) { |
| 16 | + if (!sheetModule) { |
156 | 17 | notFound(); |
157 | 18 | } |
158 | 19 |
|
159 | | - if (currentModule.comingSoon) { |
160 | | - return ( |
161 | | - <div className="min-h-screen bg-ox-header text-text-primary font-DMfont"> |
162 | | - <div className="max-w-4xl mx-auto px-6 py-8"> |
163 | | - <div className="mb-6"> |
164 | | - <Link |
165 | | - href="/dashboard/sheet" |
166 | | - className="inline-flex items-center gap-2 text-brand-purple-light hover:text-brand-purple transition-colors mb-4" |
167 | | - > |
168 | | - <ArrowLeft className="h-4 w-4" /> |
169 | | - <span>Back to Sheet</span> |
170 | | - </Link> |
171 | | - <h1 className="text-3xl font-bold text-text-primary mb-2"> |
172 | | - {currentModule.name} |
173 | | - </h1> |
174 | | - </div> |
175 | | - |
176 | | - <div className="bg-ox-content rounded-lg p-8 border border-ox-header text-center"> |
177 | | - <Badge className="bg-ox-purple/20 text-ox-purple border-ox-purple/30 mb-4"> |
178 | | - Soon |
179 | | - </Badge> |
180 | | - <p className="text-text-secondary text-lg"> |
181 | | - This module is coming very soon. Stay tuned! |
182 | | - </p> |
183 | | - </div> |
184 | | - </div> |
185 | | - </div> |
186 | | - ); |
187 | | - } |
188 | | - |
189 | 20 | return ( |
190 | | - <div className="min-h-screen bg-ox-header text-text-primary font-DMfont"> |
191 | | - <div className="max-w-4xl mx-auto px-6 py-8"> |
192 | | - {/* Header with back button and actions */} |
193 | | - <div className="mb-6"> |
194 | | - <div className="flex items-center justify-between mb-4 flex-wrap gap-4"> |
195 | | - <Link |
196 | | - href="/dashboard/sheet" |
197 | | - className="inline-flex items-center gap-2 text-brand-purple-light hover:text-brand-purple transition-colors" |
198 | | - > |
199 | | - <ArrowLeft className="h-4 w-4" /> |
200 | | - <span>Back to Sheet</span> |
201 | | - </Link> |
202 | | - <div className="flex items-center gap-3 flex-shrink-0"> |
203 | | - {copied && ( |
204 | | - <Badge className="bg-brand-purple text-text-primary border-0 flex items-center gap-1"> |
205 | | - <Check className="h-3 w-3" /> |
206 | | - Copied |
207 | | - </Badge> |
| 21 | + <> |
| 22 | + <div className="min-h-screen bg-surface-primary"> |
| 23 | + <div className="max-w-4xl mx-auto px-4 py-8 md:px-8"> |
| 24 | + <SheetModuleHeader |
| 25 | + moduleName={sheetModule.name} |
| 26 | + docContent={sheetModule.docContent} |
| 27 | + /> |
| 28 | + <article> |
| 29 | + <header className="mb-8"> |
| 30 | + <h1 className="text-3xl md:text-4xl font-bold text-text-primary mb-4"> |
| 31 | + {sheetModule.name} |
| 32 | + </h1> |
| 33 | + {sheetModule.videoUrl && ( |
| 34 | + <div className="mb-6"> |
| 35 | + <a |
| 36 | + href={sheetModule.videoUrl} |
| 37 | + target="_blank" |
| 38 | + rel="noopener noreferrer" |
| 39 | + className="inline-flex items-center gap-2 text-brand-purple-light hover:text-brand-purple transition-colors" |
| 40 | + > |
| 41 | + <svg |
| 42 | + className="w-5 h-5" |
| 43 | + fill="currentColor" |
| 44 | + viewBox="0 0 24 24" |
| 45 | + > |
| 46 | + <path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z" /> |
| 47 | + </svg> |
| 48 | + <span>watch video</span> |
| 49 | + </a> |
| 50 | + </div> |
208 | 51 | )} |
209 | | - <button |
210 | | - onClick={handleDownloadPDF} |
211 | | - className="p-2 text-text-primary hover:text-brand-purple transition-colors rounded-md hover:bg-ox-content/50" |
212 | | - title="Download as PDF" |
213 | | - aria-label="Download as PDF" |
214 | | - > |
215 | | - <Download className="h-5 w-5" /> |
216 | | - </button> |
217 | | - <button |
218 | | - onClick={handleShare} |
219 | | - className="p-2 text-text-primary hover:text-brand-purple transition-colors rounded-md hover:bg-ox-content/50" |
220 | | - title="Share module" |
221 | | - aria-label="Share module" |
222 | | - > |
223 | | - <Share2 className="h-5 w-5" /> |
224 | | - </button> |
225 | | - </div> |
226 | | - </div> |
227 | | - <h1 className="text-3xl font-bold text-text-primary mb-2"> |
228 | | - {currentModule.name} |
229 | | - </h1> |
230 | | - </div> |
| 52 | + </header> |
231 | 53 |
|
232 | | - {/* Content */} |
233 | | - <div className="bg-ox-content rounded-lg p-8 prose prose-invert max-w-none font-DMfont border border-ox-header"> |
234 | | - <div |
235 | | - // eslint-disable-next-line react/no-danger -- Safe: docContent is sanitized with DOMPurify before rendering |
236 | | - dangerouslySetInnerHTML={{ __html: sanitizedDocContent }} |
237 | | - className="text-text-primary [&_h1]:text-2xl [&_h1]:font-bold [&_h1]:mb-4 [&_h1]:mt-6 [&_h1]:text-text-primary [&_h2]:text-xl [&_h2]:font-semibold [&_h2]:mb-3 [&_h2]:mt-5 [&_h2]:text-text-primary [&_p]:text-text-secondary [&_p]:mb-4 [&_p]:leading-relaxed [&_ul]:list-disc [&_ul]:ml-6 [&_ul]:mb-4 [&_ul]:text-text-secondary [&_li]:mb-2 [&_pre]:bg-ox-sidebar [&_pre]:p-4 [&_pre]:rounded [&_pre]:overflow-x-auto [&_pre]:mb-4 [&_pre]:font-DMfont [&_pre]:border [&_pre]:border-ox-header [&_code]:text-brand-purple [&_code]:bg-ox-sidebar [&_code]:px-1 [&_code]:py-0.5 [&_code]:rounded [&_code]:font-DMfont [&_img]:max-w-full [&_img]:h-auto [&_img]:rounded-lg [&_img]:my-5 [&_img]:border [&_img]:border-ox-header" |
238 | | - /> |
| 54 | + <SheetContentRenderer |
| 55 | + className={styles.sheetContent} |
| 56 | + content={sheetModule.docContent} |
| 57 | + /> |
| 58 | + </article> |
239 | 59 | </div> |
240 | 60 | </div> |
241 | | - </div> |
| 61 | + </> |
242 | 62 | ); |
243 | 63 | } |
0 commit comments