Compare commits
	
		
			17 Commits
		
	
	
		
			2316fe68e1
			...
			master
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 9f967bf9d7 | |||
| b1c3d9ef84 | |||
| 5082135d62 | |||
| 01dc9c9749 | |||
| b7f7fd1989 | |||
| 906ad8a7a1 | |||
| 9b186a462e | |||
| 4cbd28b396 | |||
| 96ea6c446c | |||
| ffe356dd2a | |||
| 406963f6bf | |||
| f192e51f3c | |||
| ad66be5039 | |||
| 9c78ed022d | |||
| 838e9730a4 | |||
| 97e318ce6f | |||
| 554a6dff45 | 
							
								
								
									
										10
									
								
								global.d.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										10
									
								
								global.d.ts
									
									
									
									
										vendored
									
									
								
							| @@ -1,3 +1,5 @@ | |||||||
|  | import { link } from "fs"; | ||||||
|  |  | ||||||
| declare namespace NodeJS { | declare namespace NodeJS { | ||||||
|     interface ProcessEnv { |     interface ProcessEnv { | ||||||
|       NEXT_PUBLIC_API_URL: string; |       NEXT_PUBLIC_API_URL: string; | ||||||
| @@ -8,6 +10,14 @@ declare namespace NodeJS { | |||||||
|     interface Window { |     interface Window { | ||||||
|       kofiWidgetOverlay: any; |       kofiWidgetOverlay: any; | ||||||
|       dataLayer: any[]; |       dataLayer: any[]; | ||||||
|  |       googletag: { | ||||||
|  |         cmd: any[]; | ||||||
|  |         pubads: () => { | ||||||
|  |           refresh: () => void; | ||||||
|  |           enableSingleRequest: () => void; | ||||||
|  |         }; | ||||||
|  |         enableServices: () => void; | ||||||
|  |       }; | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
| export {}; | export {}; | ||||||
							
								
								
									
										1267
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										1267
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -16,10 +16,14 @@ | |||||||
|     "@tanstack/react-query-devtools": "^5.63.0", |     "@tanstack/react-query-devtools": "^5.63.0", | ||||||
|     "axios": "^1.7.9", |     "axios": "^1.7.9", | ||||||
|     "date-fns": "^4.1.0", |     "date-fns": "^4.1.0", | ||||||
|  |     "html-react-parser": "^5.2.2", | ||||||
|     "next": "15.1.4", |     "next": "15.1.4", | ||||||
|     "react": "^19.0.0", |     "react": "^19.0.0", | ||||||
|     "react-dom": "^19.0.0", |     "react-dom": "^19.0.0", | ||||||
|     "rss": "^1.2.2" |     "remark": "^15.0.1", | ||||||
|  |     "remark-html": "^16.0.1", | ||||||
|  |     "rss": "^1.2.2", | ||||||
|  |     "sanitize-html": "^2.14.0" | ||||||
|   }, |   }, | ||||||
|   "devDependencies": { |   "devDependencies": { | ||||||
|     "@eslint/eslintrc": "^3", |     "@eslint/eslintrc": "^3", | ||||||
| @@ -27,6 +31,7 @@ | |||||||
|     "@types/react": "^19", |     "@types/react": "^19", | ||||||
|     "@types/react-dom": "^19", |     "@types/react-dom": "^19", | ||||||
|     "@types/rss": "^0.0.32", |     "@types/rss": "^0.0.32", | ||||||
|  |     "@types/sanitize-html": "^2.13.0", | ||||||
|     "eslint": "^9", |     "eslint": "^9", | ||||||
|     "eslint-config-next": "15.1.4", |     "eslint-config-next": "15.1.4", | ||||||
|     "postcss": "^8", |     "postcss": "^8", | ||||||
|   | |||||||
							
								
								
									
										1
									
								
								public/ads.txt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								public/ads.txt
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1 @@ | |||||||
|  | google.com, pub-1843060382170565, DIRECT, f08c47fec0942fa0 | ||||||
| @@ -1,6 +1,6 @@ | |||||||
| import { fetchAnnouncementById } from "@/lib/api"; | import { fetchAnnouncementById } from "@/lib/api"; | ||||||
| import { Announcement } from "@/lib/types"; | import { Announcement } from "@/lib/types"; | ||||||
| import { formatDateToMonthDayYear } from "@/lib/utils"; | import { formatDateToMonthDayYear, markdownToHtml } from "@/lib/utils"; | ||||||
|  |  | ||||||
| export const metadata = { | export const metadata = { | ||||||
|     title: 'Announcement Detail Page', |     title: 'Announcement Detail Page', | ||||||
| @@ -14,6 +14,7 @@ export default async function AnnouncementDetailPage(props: {params: paramsType} | |||||||
|     let announcement: Announcement; |     let announcement: Announcement; | ||||||
|     try{ |     try{ | ||||||
|         announcement = await fetchAnnouncementById(announcementId); |         announcement = await fetchAnnouncementById(announcementId); | ||||||
|  |         announcement.content = await markdownToHtml(announcement.content); | ||||||
|     } |     } | ||||||
|     catch (error) { |     catch (error) { | ||||||
|         console.error(error); |         console.error(error); | ||||||
|   | |||||||
| @@ -1,41 +1,68 @@ | |||||||
| import { formatDateToMonthDayYear } from "@/lib/utils"; | import { formatDateToMonthDayYear } from "@/lib/utils"; | ||||||
| import { Announcement } from "@/lib/types"; | import { Announcement } from "@/lib/types"; | ||||||
| import { fetchAnnouncements } from "@/lib/api"; | import { fetchAnnouncements } from "@/lib/api"; | ||||||
|  | import { markdownToHtml } from "@/lib/utils"; | ||||||
| import Link from "next/link"; | import Link from "next/link"; | ||||||
|  |  | ||||||
|  |  | ||||||
| export const metadata = { | export const metadata = { | ||||||
|     title: 'Annoucement page', |     title: "Announcement Page", | ||||||
|     description: 'NullTranslationGroup Announcement page', |     description: "NullTranslationGroup Announcement Page", | ||||||
| }; | }; | ||||||
|  |  | ||||||
| export default async function AnnouncementPage() { | export default async function AnnouncementPage() { | ||||||
|     let announcements = []; |     let announcements: Announcement[] = []; | ||||||
|     try { |     try { | ||||||
|         announcements = await fetchAnnouncements(); |         announcements = await fetchAnnouncements(); | ||||||
|  |         for (const announcement of announcements) { | ||||||
|  |             announcement.content = await markdownToHtml(announcement.content); | ||||||
|  |           } | ||||||
|     } catch (error) { |     } catch (error) { | ||||||
|         console.error(error); |         console.error(error); | ||||||
|         return ( |         return ( | ||||||
|             <div className="text-center mt-10 text-red-500"> |             <div className="text-center mt-10 text-red-500"> | ||||||
|                 <p>Failed to load announcements.</p> |                 <p>Failed to load announcements. Please try again later.</p> | ||||||
|             </div> |             </div> | ||||||
|         ); |         ); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     const sorted_announcements:Announcement[] = announcements.sort((a, b) => new Date(a.datetime).getTime() -  new Date(b.datetime).getTime()); |     // Sort announcements by date (newest first) | ||||||
|  |     const sorted_announcements: Announcement[] = announcements.sort( | ||||||
|  |         (a, b) => new Date(b.datetime).getTime() - new Date(a.datetime).getTime() | ||||||
|  |     ); | ||||||
|     return ( |     return ( | ||||||
|         <div className="max-w-6xl mx-auto py-10 px-4"> |         <div className="max-w-6xl mx-auto py-10 px-4"> | ||||||
|                 {sorted_announcements.map((announcement) => ( |             <h1 className="text-3xl font-bold text-center mb-8">Announcements</h1> | ||||||
|                     <Link href = {`/announcements/${announcement.documentId}`} key={announcement.documentId}> |             {sorted_announcements.length === 0 ? ( | ||||||
|                         <li key={announcement.id} className="mb-2 list-none"> |                 <div className="text-center text-gray-500"> | ||||||
|                             <div className="text-lg font-semibold">{announcement.title}</div> |                     <p>No announcements available.</p> | ||||||
|                             <div className="text-sm text-gray-500">{formatDateToMonthDayYear(new Date(announcement.datetime))}</div> |                 </div> | ||||||
|                         </li> |             ) : ( | ||||||
|  |                 <div className="space-y-6"> | ||||||
|  |                     {sorted_announcements.map((announcement) => { | ||||||
|  |                         const contents = announcement.content.split("\n"); | ||||||
|  |                         announcement.content = contents.length > 3 ? contents.slice(0, 3).join("\n") : announcement.content; | ||||||
|  |                         return ( | ||||||
|  |                             <Link | ||||||
|  |                                 href={`/announcements/${announcement.documentId}`} | ||||||
|  |                                 key={announcement.documentId} | ||||||
|  |                             > | ||||||
|  |                                 <div className="prose dark:prose-invert max-w-none p-6 bg-white shadow-md rounded-lg cursor-pointer transition-colors duration-300 hover:shadow-lg dark:bg-gray-800 dark:shadow-gray-900"> | ||||||
|  |                                     <div className="text-sm text-gray-500 mb-4 dark:text-gray-400"> | ||||||
|  |                                         {formatDateToMonthDayYear(new Date(announcement.datetime))} | ||||||
|  |                                     </div> | ||||||
|  |                                     <div | ||||||
|  |                                         className="text-gray-700 mb-4 dark:text-gray-300 prose dark:prose-invert max-w-none" | ||||||
|  |                                         dangerouslySetInnerHTML={{ __html: announcement.content }} | ||||||
|  |                                     /> | ||||||
|  |                                     <div className="flex items-center text-blue-600 hover:text-blue-800 transition-colors duration-300 dark:text-blue-400 dark:hover:text-blue-300"> | ||||||
|  |                                         <span className="mr-2">Read More</span> | ||||||
|  |                                     </div> | ||||||
|  |                                 </div> | ||||||
|                             </Link> |                             </Link> | ||||||
|                 ))} |                         ) | ||||||
|  |                     })} | ||||||
|  |                 </div> | ||||||
|  |             )} | ||||||
|         </div> |         </div> | ||||||
|     ); |     ); | ||||||
|              |  | ||||||
| } | } | ||||||
| @@ -16,8 +16,8 @@ export async function GET(){ | |||||||
|         feed_url: `${BASE_URL}/api/feed`, |         feed_url: `${BASE_URL}/api/feed`, | ||||||
|         language: "en", |         language: "en", | ||||||
|     }); |     }); | ||||||
|      |     const data_sorted = data.sort((a: Chapter, b: Chapter) => { return new Date(a.release_datetime).getTime() - new Date(b.release_datetime).getTime(); }); | ||||||
|     data.forEach((chapter: Chapter) => { |     data_sorted.forEach((chapter: Chapter) => { | ||||||
|         feed.item({ |         feed.item({ | ||||||
|             title: chapter.book?.title + ": c" + chapter.number, |             title: chapter.book?.title + ": c" + chapter.number, | ||||||
|             description: "Daily chapter release for " + chapter.book?.title, |             description: "Daily chapter release for " + chapter.book?.title, | ||||||
|   | |||||||
							
								
								
									
										39
									
								
								src/app/api/reports/route.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										39
									
								
								src/app/api/reports/route.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,39 @@ | |||||||
|  |  | ||||||
|  | import { NextResponse } from 'next/server'; | ||||||
|  |  | ||||||
|  | export async function POST(request: Request) { | ||||||
|  |   try { | ||||||
|  |     const API_URL = process.env.NEXT_PUBLIC_API_URL; | ||||||
|  |     const API_TOKEN = process.env.STRAPI_API_TOKEN; | ||||||
|  |  | ||||||
|  |     const body = await request.json(); | ||||||
|  |  | ||||||
|  |     if (!API_URL || !API_TOKEN) { | ||||||
|  |       return NextResponse.json({ message: 'Server configuration error' }, {status: 500}); | ||||||
|  |     } | ||||||
|  |     try { | ||||||
|  |       const response = await fetch(`${API_URL}/api/reports`, { | ||||||
|  |         method: 'POST', | ||||||
|  |         headers: { | ||||||
|  |           Authorization: `Bearer ${API_TOKEN}`, | ||||||
|  |           'Content-Type': 'application/json', | ||||||
|  |         }, | ||||||
|  |         body: JSON.stringify(body), | ||||||
|  |       }); | ||||||
|  |  | ||||||
|  |       const data = await response.json(); | ||||||
|  |  | ||||||
|  |       if (!response.ok) { | ||||||
|  |         return NextResponse.json({ error: data }, { status: response.status }); | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       return NextResponse.json({data: data}, {status: response.status}); | ||||||
|  |     } catch (error) { | ||||||
|  |       console.error('Error handling request:', error); | ||||||
|  |       NextResponse.json({message: 'Going to server error'}, {status: 500}); | ||||||
|  |     } | ||||||
|  |   } catch (error) { | ||||||
|  |     console.error('Error handling request:', error); | ||||||
|  |     return NextResponse.json({ message: 'Internal Server Error'}, { status: 500 }); | ||||||
|  |   } | ||||||
|  | } | ||||||
| @@ -1,7 +1,10 @@ | |||||||
| import React from "react"; | import React from "react"; | ||||||
| import NavigationButtons from "@/components/NavigationButtons"; | import NavigationButtons from "@/components/NavigationButtons"; | ||||||
|  | import ReportButton from "@/components/ReportButton"; | ||||||
|  | import ChapterRenderer from "@/components/ChapterContentRenderer"; | ||||||
| import { Chapter } from "@/lib/types"; | import { Chapter } from "@/lib/types"; | ||||||
| import {fetchChapterByBookId } from "@/lib/api"; | import { fetchChapterByBookId, fetchGlossaryByBookId } from "@/lib/api"; | ||||||
|  | import { markdownToHtml } from "@/lib/utils"; | ||||||
| export type paramsType = Promise<{ bookId: string; chapterId: string }>; | export type paramsType = Promise<{ bookId: string; chapterId: string }>; | ||||||
|  |  | ||||||
| export const metadata = { | export const metadata = { | ||||||
| @@ -26,16 +29,17 @@ export default async function ChapterPage(props: { params: paramsType}) { | |||||||
|                 </div> |                 </div> | ||||||
|         ) |         ) | ||||||
|     } |     } | ||||||
|  |     const glossary_data = await fetchGlossaryByBookId(bookId); | ||||||
|  |     const english_glossary = glossary_data.english_english; | ||||||
|     const sorted_chapters:Chapter[] = chapters.sort((a, b) => a.number - b.number); |     const sorted_chapters:Chapter[] = chapters.sort((a, b) => a.number - b.number); | ||||||
|     const current_chapter = sorted_chapters.find((chapter) => chapter.documentId === chapterId) || null; |     const current_chapter = sorted_chapters.find((chapter) => chapter.documentId === chapterId) || null; | ||||||
|     const next_chapter = current_chapter ? sorted_chapters.find((chapter) => chapter.number === current_chapter.number + 1 && new Date(chapter.release_datetime).getTime() <= new Date().getTime())?.documentId || "" : ""; |     const next_chapter = current_chapter ? sorted_chapters.find((chapter) => chapter.number === current_chapter.number + 1 && new Date(chapter.release_datetime).getTime() <= new Date().getTime())?.documentId || "" : ""; | ||||||
|     const prev_chapter = current_chapter ? sorted_chapters.find((chapter) => chapter.number === current_chapter.number - 1 && new Date(chapter.release_datetime).getTime() <= new Date().getTime())?.documentId || "" : ""; |     const prev_chapter = current_chapter ? sorted_chapters.find((chapter) => chapter.number === current_chapter.number - 1 && new Date(chapter.release_datetime).getTime() <= new Date().getTime())?.documentId || "" : ""; | ||||||
|  |     const chapter_content_html = current_chapter ? await markdownToHtml(current_chapter.content) : ""; | ||||||
|     if(current_chapter === null){ |     if(current_chapter === null){ | ||||||
|         return (     |         return (     | ||||||
|                 <div className="prose dark:prose-invert mx-auto p-6 bg-white dark:bg-gray-800 shadow-md rounded-lg mt-4"> |                 <div className="prose dark:prose-invert mx-auto p-6 bg-white dark:bg-gray-800 shadow-md rounded-lg mt-4"> | ||||||
|                     <div dangerouslySetInnerHTML={{ __html: '<center><h1> Chapter not found !</h1></center>' }}></div> |                     <div dangerouslySetInnerHTML={{ __html: '<NavigationButtons bookId={bookId} documentId={chapterId} prevChapter={prev_chapter} nextChapter={next_chapter} /><center><NavigationButtons /><h1> Chapter not found !</h1></center>' }}></div> | ||||||
|                 <NavigationButtons bookId={bookId} documentId={chapterId} prevChapter={""} nextChapter={""} /> |                 <NavigationButtons bookId={bookId} documentId={chapterId} prevChapter={""} nextChapter={""} /> | ||||||
|                 </div> |                 </div> | ||||||
|         ) |         ) | ||||||
| @@ -44,9 +48,10 @@ export default async function ChapterPage(props: { params: paramsType}) { | |||||||
|     return ( |     return ( | ||||||
|             <div className="prose dark:prose-invert mx-auto p-6 bg-white dark:bg-gray-800 shadow-md rounded-lg mt-4"> |             <div className="prose dark:prose-invert mx-auto p-6 bg-white dark:bg-gray-800 shadow-md rounded-lg mt-4"> | ||||||
|             <NavigationButtons bookId={bookId} documentId={chapterId} prevChapter={prev_chapter} nextChapter={next_chapter} /> |             <NavigationButtons bookId={bookId} documentId={chapterId} prevChapter={prev_chapter} nextChapter={next_chapter} /> | ||||||
|                 <div className="pt-4" dangerouslySetInnerHTML={{ __html: current_chapter.content }}></div> |                 <div className="pt-4"></div> | ||||||
|          |                 <ChapterRenderer content={chapter_content_html} glossary={english_glossary} /> | ||||||
|                 <NavigationButtons bookId={bookId} documentId={chapterId} prevChapter={prev_chapter} nextChapter={next_chapter}/> |                 <NavigationButtons bookId={bookId} documentId={chapterId} prevChapter={prev_chapter} nextChapter={next_chapter}/> | ||||||
|  |                 <ReportButton bookId={bookId} chapterId={chapterId} /> | ||||||
|             </div> |             </div> | ||||||
|     ); |     ); | ||||||
| } | } | ||||||
| @@ -1,14 +1,15 @@ | |||||||
| import { fetchBookById } from "@/lib/api"; | import { fetchBookById } from "@/lib/api"; | ||||||
| import { Book, Chapter } from "@/lib/types"; | import { Book, Chapter } from "@/lib/types"; | ||||||
| import { formatDateToMonthDayYear } from "@/lib/utils"; | import { encodeId, formatDateToMonthDayYear, markdownToHtml } from "@/lib/utils"; | ||||||
| import ChapterDropdown from "@/components/ChapterDropdown"; | import ChapterDropdown from "@/components/ChapterDropdown"; | ||||||
| import { Ad } from "@/lib/types"; | import { Ad } from "@/lib/types"; | ||||||
|  | import { Countdown } from "@/components/Countdown"; | ||||||
|  |  | ||||||
| export type paramsType = Promise<{ bookId: string }>; | export type paramsType = Promise<{ bookId: string }>; | ||||||
|  |  | ||||||
| export const metadata = { | export const metadata = { | ||||||
|     title: 'Null Translation Group', |     title: 'Null Translation Group', | ||||||
|     description: 'Null Translatin Group book', |     description: 'Null Translation Group book description page', | ||||||
| }; | }; | ||||||
|  |  | ||||||
| export default async function BookPage(props: { params: paramsType }) { | export default async function BookPage(props: { params: paramsType }) { | ||||||
| @@ -25,12 +26,15 @@ export default async function BookPage(props: { params: paramsType }) { | |||||||
|         ); |         ); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     const { title, author, description, chapters, cover } = book; |     const { title, author, description, chapters, cover, translator_note, glossary } = book; | ||||||
|  |     const english_glossary = glossary?.english_english; | ||||||
|  |     const translator_note_html = await markdownToHtml(translator_note); | ||||||
|     const sorted_chapters: Chapter[] = chapters.sort((a, b) => a.number - b.number); |     const sorted_chapters: Chapter[] = chapters.sort((a, b) => a.number - b.number); | ||||||
|  |     const current_chapters = sorted_chapters.filter(chapter => new Date(chapter.release_datetime) < new Date()); | ||||||
|  |     const next_chapter = sorted_chapters.find((chapter) => new Date(chapter.release_datetime) > new Date()); | ||||||
|  |     const next_chapters = sorted_chapters.filter(chapter => new Date(chapter.release_datetime) > new Date()) | ||||||
|     const cover_media = cover?.at(0); |     const cover_media = cover?.at(0); | ||||||
|     const recentChapters = sorted_chapters.length > 6 ? sorted_chapters.slice(sorted_chapters.length - 6, sorted_chapters.length) : sorted_chapters; |     const recentChapters = sorted_chapters.length > 8 ? sorted_chapters.slice(sorted_chapters.length - 8, sorted_chapters.length - 2) : current_chapters; | ||||||
|  |  | ||||||
|     return ( |     return ( | ||||||
|         <div className="max-w-6xl mx-auto py-10 px-4"> |         <div className="max-w-6xl mx-auto py-10 px-4"> | ||||||
|             <div className="flex flex-col items-center justify-center"> |             <div className="flex flex-col items-center justify-center"> | ||||||
| @@ -39,13 +43,14 @@ export default async function BookPage(props: { params: paramsType }) { | |||||||
|                     className="rounded-lg object-cover w-64 h-96" |                     className="rounded-lg object-cover w-64 h-96" | ||||||
|                 /> |                 /> | ||||||
|                 <h1 className="text-5xl pb-2 font-bold">{title}</h1> |                 <h1 className="text-5xl pb-2 font-bold">{title}</h1> | ||||||
|  |                 <h3 className="text-2xl text-green-600 dark:text-green-400 font-bold pb-2"> {book.release_rate} chapters/day</h3> | ||||||
|                 <a |                 <a | ||||||
|                     href={Ad.patreon} |                     href={Ad.patreon} | ||||||
|                     target="_blank" |                     target="_blank" | ||||||
|                     rel="noopener noreferrer" |                     rel="noopener noreferrer" | ||||||
|                     className="ml-4 bg-yellow-500 text-white font-semibold py-2 px-4 rounded hover:bg-yellow-600 transition duration-200" |                     className="ml-4 bg-yellow-500 text-white font-semibold py-2 px-4 rounded hover:bg-yellow-600 transition duration-200" | ||||||
|                 > |                 > | ||||||
|                             Join Our Patreon for Unreleased Chapters |                     Join Our Patreon to read ahead! | ||||||
|                 </a> |                 </a> | ||||||
|             </div> |             </div> | ||||||
|  |  | ||||||
| @@ -54,8 +59,8 @@ export default async function BookPage(props: { params: paramsType }) { | |||||||
|                 <br></br> |                 <br></br> | ||||||
|                 <strong>Translator:</strong> Null Translation Group |                 <strong>Translator:</strong> Null Translation Group | ||||||
|             </p> |             </p> | ||||||
|                 <p className="mb-6">{description}</p> |             <p className="mb-6">Description: {description}</p> | ||||||
|  |             <div className="mb-6" dangerouslySetInnerHTML={{ __html: translator_note_html }} /> | ||||||
|             <h2 className="text-3xl font-semibold mb-4">Recent Chapters</h2> |             <h2 className="text-3xl font-semibold mb-4">Recent Chapters</h2> | ||||||
|             <ul className="grid gap-6 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3"> |             <ul className="grid gap-6 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3"> | ||||||
|                 {recentChapters.map((chapter) => ( |                 {recentChapters.map((chapter) => ( | ||||||
| @@ -75,10 +80,64 @@ export default async function BookPage(props: { params: paramsType }) { | |||||||
|                 ))} |                 ))} | ||||||
|             </ul> |             </ul> | ||||||
|             <div className="flex items-center justify-between mb-4 pt-4"> |             <div className="flex items-center justify-between mb-4 pt-4"> | ||||||
|                     <h2 className="text-3xl font-semibold">All Chapters</h2> |                 <h2 className="text-3xl font-semibold">Next Chapters</h2> | ||||||
|                     <ChapterDropdown chapters={sorted_chapters} bookId={bookId} /> |  | ||||||
|             </div> |             </div> | ||||||
|                 {sorted_chapters.map((chapter) => ( |             {next_chapters.map((chapter) => | ||||||
|  |                 ( | ||||||
|  |                     <li key={chapter.id+"next_chapters"} className="mb-2 list-none relative"> | ||||||
|  |                     <a | ||||||
|  |                         href={`/books/${bookId}/chapters/${chapter.documentId}`} | ||||||
|  |                         className="block bg-white dark:bg-gray-800 rounded-lg shadow p-4 hover:shadow-lg transition duration-200 relative opacity-50 pointer-events-none" | ||||||
|  |                     > | ||||||
|  |                         <h3 className="text-xl font-medium"> | ||||||
|  |                             Chapter {chapter.number}: {chapter.title} | ||||||
|  |                         </h3> | ||||||
|  |                         <p className="text-sm text-gray-500 dark:text-gray-400 mt-2"> | ||||||
|  |                             <strong>Release Date:</strong>{" "} | ||||||
|  |                             {formatDateToMonthDayYear(new Date(chapter.release_datetime))} | ||||||
|  |                         </p> | ||||||
|  |                     </a> | ||||||
|  |  | ||||||
|  |                     {/* Overlay */} | ||||||
|  |                     <div className="absolute inset-0 bg-gray-400 opacity-50 rounded-lg"></div> | ||||||
|  |                     <div className="absolute inset-0 flex items-center justify-center"> | ||||||
|  |                         <p className="text-xl font-bold text-red-500 bg-gray-800 bg-opacity-80 px-4 py-2 rounded-lg shadow-lg"> | ||||||
|  |                            <Countdown release_datetime={chapter.release_datetime} /> | ||||||
|  |                         </p> | ||||||
|  |                         {chapter.number === next_chapter?.number ? ( | ||||||
|  |                             <a | ||||||
|  |                                 href={'/early?bookId=' + encodeId(bookId)} | ||||||
|  |                                 className={` | ||||||
|  |                                 bg-red-500 text-white py-2 px-4 mx-2 rounded  | ||||||
|  |                                 hover:bg-red-600  | ||||||
|  |                                 transition duration-200 | ||||||
|  |                                 `} | ||||||
|  |                             > | ||||||
|  |                                 Unlock Early AdWalled Chapter | ||||||
|  |                             </a> | ||||||
|  |                             ) | ||||||
|  |                          : ( | ||||||
|  |                             <a | ||||||
|  |                                 href={Ad.patreon} | ||||||
|  |                                 className={` | ||||||
|  |                                 bg-yellow-500 text-white py-2 px-4 mx-2 rounded  | ||||||
|  |                                 hover:bg-yellow-600  | ||||||
|  |                                 transition duration-200 | ||||||
|  |                                 `} | ||||||
|  |                             > | ||||||
|  |                                 Join Patreon for More Early Chapters | ||||||
|  |                             </a> | ||||||
|  |                         )} | ||||||
|  |                     </div> | ||||||
|  |                 </li> | ||||||
|  |                 ) | ||||||
|  |             )} | ||||||
|  |             <div className="flex items-center justify-between mb-4 pt-4"> | ||||||
|  |                 <h2 className="text-3xl font-semibold">All Chapters</h2> | ||||||
|  |                 <ChapterDropdown chapters={current_chapters} bookId={bookId} /> | ||||||
|  |             </div> | ||||||
|  |             {sorted_chapters.map((chapter) => | ||||||
|  |                 new Date(chapter.release_datetime) < new Date() ? ( | ||||||
|                     <li key={chapter.id} className="mb-2 list-none"> |                     <li key={chapter.id} className="mb-2 list-none"> | ||||||
|                         <a |                         <a | ||||||
|                             href={`/books/${bookId}/chapters/${chapter.documentId}`} |                             href={`/books/${bookId}/chapters/${chapter.documentId}`} | ||||||
| @@ -88,11 +147,70 @@ export default async function BookPage(props: { params: paramsType }) { | |||||||
|                                 Chapter {chapter.number}: {chapter.title} |                                 Chapter {chapter.number}: {chapter.title} | ||||||
|                             </h3> |                             </h3> | ||||||
|                             <p className="text-sm text-gray-500 dark:text-gray-400 mt-2"> |                             <p className="text-sm text-gray-500 dark:text-gray-400 mt-2"> | ||||||
|                                 <strong>Release Date:</strong> {formatDateToMonthDayYear(new Date(chapter.release_datetime))} |                                 <strong>Release Date:</strong>{" "} | ||||||
|  |                                 {formatDateToMonthDayYear(new Date(chapter.release_datetime))} | ||||||
|                             </p> |                             </p> | ||||||
|                         </a> |                         </a> | ||||||
|                     </li> |                     </li> | ||||||
|  |                 ) : <li key={chapter.id} className="mb-2 list-none relative"> | ||||||
|  |                     <a | ||||||
|  |                         href={`/books/${bookId}/chapters/${chapter.documentId}`} | ||||||
|  |                         className="block bg-white dark:bg-gray-800 rounded-lg shadow p-4 hover:shadow-lg transition duration-200 relative opacity-50 pointer-events-none" | ||||||
|  |                     > | ||||||
|  |                         <h3 className="text-xl font-medium"> | ||||||
|  |                             Chapter {chapter.number}: {chapter.title} | ||||||
|  |                         </h3> | ||||||
|  |                         <p className="text-sm text-gray-500 dark:text-gray-400 mt-2"> | ||||||
|  |                             <strong>Release Date:</strong>{" "} | ||||||
|  |                             {formatDateToMonthDayYear(new Date(chapter.release_datetime))} | ||||||
|  |                         </p> | ||||||
|  |                     </a> | ||||||
|  |  | ||||||
|  |                     {/* Overlay */} | ||||||
|  |                     <div className="absolute inset-0 bg-gray-400 opacity-50 rounded-lg"></div> | ||||||
|  |                     <div className="absolute inset-0 flex items-center justify-center"> | ||||||
|  |                         <p className="text-xl font-bold text-red-500 bg-gray-800 bg-opacity-80 px-4 py-2 rounded-lg shadow-lg"> | ||||||
|  |                            <Countdown release_datetime={chapter.release_datetime} /> | ||||||
|  |                         </p> | ||||||
|  |                         {chapter.number === next_chapter?.number ? ( | ||||||
|  |                             <a | ||||||
|  |                                 href={'/early?bookId=' + encodeId(bookId)} | ||||||
|  |                                 className={` | ||||||
|  |                                 bg-red-500 text-white py-2 px-4 mx-2 rounded  | ||||||
|  |                                 hover:bg-red-600  | ||||||
|  |                                 transition duration-200 | ||||||
|  |                                 `} | ||||||
|  |                             > | ||||||
|  |                                 Unlock Early Ad-Walled Chapter | ||||||
|  |                             </a> | ||||||
|  |                             ) | ||||||
|  |                          : ( | ||||||
|  |                             <a | ||||||
|  |                                 href={Ad.patreon} | ||||||
|  |                                 className={` | ||||||
|  |                                 bg-yellow-500 text-white py-2 px-4 mx-2 rounded  | ||||||
|  |                                 hover:bg-yellow-600  | ||||||
|  |                                 transition duration-200 | ||||||
|  |                                 `} | ||||||
|  |                             > | ||||||
|  |                                 Join Patreon for More Early Chapters | ||||||
|  |                             </a> | ||||||
|  |                         )} | ||||||
|  |                     </div> | ||||||
|  |                 </li> | ||||||
|  |             )} | ||||||
|  |             {glossary && ( | ||||||
|  |                 <div className="mt-8"> | ||||||
|  |                     <h2 className="text-3xl font-semibold">Glossary</h2> | ||||||
|  |                     <ul className="list-disc list-inside mt-4"> | ||||||
|  |                         {Object.entries(english_glossary).map(([term, definition]) => ( | ||||||
|  |                             <li key={term} className="mb-2"> | ||||||
|  |                                 <strong>{term}:</strong> {definition} | ||||||
|  |                             </li> | ||||||
|                         ))} |                         ))} | ||||||
|  |                     </ul> | ||||||
|  |                 </div> | ||||||
|  |             )} | ||||||
|         </div> |         </div> | ||||||
|     ); |     ); | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										58
									
								
								src/app/early/[bookId]/[chapterId]/page.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										58
									
								
								src/app/early/[bookId]/[chapterId]/page.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,58 @@ | |||||||
|  | import React from "react"; | ||||||
|  | import NavigationButtons from "@/components/NavigationButtons"; | ||||||
|  | import ReportButton from "@/components/ReportButton"; | ||||||
|  | import ChapterRenderer from "@/components/ChapterContentRenderer"; | ||||||
|  | import { Chapter } from "@/lib/types"; | ||||||
|  | import { fetchChapterByBookId, fetchGlossaryByBookId } from "@/lib/api"; | ||||||
|  | import { decodeId, markdownToHtml } from "@/lib/utils"; | ||||||
|  | export type paramsType = Promise<{ bookId: string; chapterId: string }>; | ||||||
|  |  | ||||||
|  | export const metadata = { | ||||||
|  |     title: 'Null Translation Group', | ||||||
|  |     description: 'This is the chapter page default description', | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | // Dynamic page component | ||||||
|  | export default async function ChapterPage(props: { params: paramsType}) { | ||||||
|  |     let { bookId, chapterId } = await props.params; | ||||||
|  |     bookId = decodeId(bookId); | ||||||
|  |     chapterId = decodeId(chapterId); | ||||||
|  |     let chapters: Chapter[]; | ||||||
|  |     try{ | ||||||
|  |         chapters = await fetchChapterByBookId(bookId, chapterId); | ||||||
|  |     } | ||||||
|  |     catch (error) { | ||||||
|  |         console.error(error); | ||||||
|  |         return (     | ||||||
|  |                 <div className="prose dark:prose-invert mx-auto p-6 bg-white dark:bg-gray-800 shadow-md rounded-lg mt-4"> | ||||||
|  |                     <div dangerouslySetInnerHTML={{ __html: '<center><h1> Chapter not found !</h1></center>' }}></div> | ||||||
|  |                 <NavigationButtons bookId={bookId} documentId={chapterId} prevChapter={""} nextChapter={""} /> | ||||||
|  |                 </div> | ||||||
|  |         ) | ||||||
|  |     } | ||||||
|  |     const glossary_data = await fetchGlossaryByBookId(bookId); | ||||||
|  |     const english_glossary = glossary_data.english_english; | ||||||
|  |     const sorted_chapters:Chapter[] = chapters.sort((a, b) => a.number - b.number); | ||||||
|  |     const current_chapter = sorted_chapters.find((chapter) => chapter.documentId === chapterId) || null; | ||||||
|  |     const next_chapter = current_chapter ? sorted_chapters.find((chapter) => chapter.number === current_chapter.number + 1 && new Date(chapter.release_datetime).getTime() <= new Date().getTime())?.documentId || "" : ""; | ||||||
|  |     const prev_chapter = current_chapter ? sorted_chapters.find((chapter) => chapter.number === current_chapter.number - 1 && new Date(chapter.release_datetime).getTime() <= new Date().getTime())?.documentId || "" : ""; | ||||||
|  |     const chapter_content_html = current_chapter ? await markdownToHtml(current_chapter.content) : ""; | ||||||
|  |     if(current_chapter === null){ | ||||||
|  |         return (     | ||||||
|  |                 <div className="prose dark:prose-invert mx-auto p-6 bg-white dark:bg-gray-800 shadow-md rounded-lg mt-4"> | ||||||
|  |                     <div dangerouslySetInnerHTML={{ __html: '<NavigationButtons bookId={bookId} documentId={chapterId} prevChapter={prev_chapter} nextChapter={next_chapter} /><center><NavigationButtons /><h1> Chapter not found !</h1></center>' }}></div> | ||||||
|  |                 <NavigationButtons bookId={bookId} documentId={chapterId} prevChapter={""} nextChapter={""} /> | ||||||
|  |                 </div> | ||||||
|  |         ) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     return ( | ||||||
|  |             <div className="prose dark:prose-invert mx-auto p-6 bg-white dark:bg-gray-800 shadow-md rounded-lg mt-4"> | ||||||
|  |             <NavigationButtons bookId={bookId} documentId={chapterId} prevChapter={prev_chapter} nextChapter={next_chapter} /> | ||||||
|  |                 <div className="pt-4"></div> | ||||||
|  |                 <ChapterRenderer content={chapter_content_html} glossary={english_glossary} /> | ||||||
|  |                 <NavigationButtons bookId={bookId} documentId={chapterId} prevChapter={prev_chapter} nextChapter={next_chapter}/> | ||||||
|  |                 <ReportButton bookId={bookId} chapterId={chapterId} /> | ||||||
|  |             </div> | ||||||
|  |     ); | ||||||
|  | } | ||||||
							
								
								
									
										112
									
								
								src/app/early/page.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										112
									
								
								src/app/early/page.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,112 @@ | |||||||
|  | import { fetchEarlyRelease } from "@/lib/api"; | ||||||
|  | import { Chapter } from "@/lib/types"; | ||||||
|  | import { encodeId, linkvertise } from "@/lib/utils"; | ||||||
|  | import { Ad } from "@/lib/types"; | ||||||
|  | export type searchParams = Promise<{ bookId: string }>; | ||||||
|  |  | ||||||
|  | export const metadata = { | ||||||
|  |     title: 'Null Translation Group', | ||||||
|  |     description: 'Null Translation Group Early Adcess Page', | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export default async function BookPage({ searchParams }: { searchParams: searchParams }) { | ||||||
|  |     const bookId = (await searchParams).bookId; | ||||||
|  |     let early_chapters: Chapter[] = [] | ||||||
|  |     try { | ||||||
|  |         early_chapters = await fetchEarlyRelease(); | ||||||
|  |     } | ||||||
|  |     catch { | ||||||
|  |         console.error("Error fetching early chapters") | ||||||
|  |         return ( | ||||||
|  |             <div className="prose dark:prose-invert mx-auto p-6 bg-white dark:bg-gray-800 shadow-md rounded-lg mt-4"> | ||||||
|  |                 <div dangerouslySetInnerHTML={{ __html: '<center><h1> Error fetching early chapters</h1></center>' }}></div> | ||||||
|  |             </div> | ||||||
|  |         ) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     const by_book_title = early_chapters.reduce<{ [key: string]: Chapter[] }>((acc, chapter) => { | ||||||
|  |         if (chapter.book === undefined) { | ||||||
|  |             return acc; | ||||||
|  |         } | ||||||
|  |         else if (!acc[chapter.book.title!]) { | ||||||
|  |             acc[chapter.book.title!] = []; | ||||||
|  |         } | ||||||
|  |         acc[chapter.book.title!].push(chapter); | ||||||
|  |         return acc; | ||||||
|  |     } | ||||||
|  |         , {}); | ||||||
|  |     for (const book of Object.keys(by_book_title)) { | ||||||
|  |         by_book_title[book].sort((a, b) => a.number - b.number); | ||||||
|  |         by_book_title[book] = by_book_title[book].slice(0, 1); | ||||||
|  |     } | ||||||
|  |     early_chapters = Array.from(Object.values(by_book_title)).flat() | ||||||
|  |     early_chapters.sort((a, b) => a.book?.title.localeCompare(b.book?.title || "") || a.number - b.number); | ||||||
|  |     const early_chapter_from_params = early_chapters.find((chapter) => encodeId(chapter.book?.documentId || "") === bookId) || null; | ||||||
|  |     return ( | ||||||
|  |         <div className="max-w-6xl mx-auto py-12 px-6"> | ||||||
|  |             <div className="flex flex-col items-center text-center space-y-6"> | ||||||
|  |                 <h1 className="text-4xl font-bold">Early Adcess Page</h1> | ||||||
|  |                 <p className="text-lg text-gray-700 dark:text-gray-300"> | ||||||
|  |                     Get early access to chapters by completing Linkvertise and Google Offerwall tasks. Read ahead by <span className="font-bold">1 chapter !</span> | ||||||
|  |                 </p> | ||||||
|  |                 <p className="text-lg text-gray-700 dark:text-gray-300"> | ||||||
|  |                     Tasks vary from watching an ad video to completing surveys and more. | ||||||
|  |                 </p> | ||||||
|  |                 <p className="text-lg text-red-700 dark:text-red-300"> | ||||||
|  |                     This is AdHell, and its meant to be that way. If you hate it support us on Patreon instead. | ||||||
|  |                 </p> | ||||||
|  |                 <p className="text-lg text-gray-700 dark:text-gray-300"> | ||||||
|  |                     Want to read even further? Join our Patreon to unlock even more chapters! | ||||||
|  |                 </p> | ||||||
|  |  | ||||||
|  |                 <a | ||||||
|  |                     href={Ad.patreon} | ||||||
|  |                     target="_blank" | ||||||
|  |                     rel="noopener noreferrer" | ||||||
|  |                     className="bg-yellow-500 text-white font-semibold py-3 px-6 rounded-lg shadow-md hover:bg-yellow-600 transition duration-300" | ||||||
|  |                 > | ||||||
|  |                     Join Our Patreon – Read More Ahead! | ||||||
|  |                 </a> | ||||||
|  |             </div> | ||||||
|  |             {early_chapter_from_params && <div className="mt-10 mb-6 text-center"> | ||||||
|  |                 <a | ||||||
|  |                     href={linkvertise(1318261,`https://nulltranslation.com/early/${encodeId(early_chapter_from_params.book?.documentId || "")}/${encodeId(early_chapter_from_params.documentId)}`)} | ||||||
|  |                     className="block bg-white dark:bg-gray-800 rounded-xl shadow-lg p-6 hover:shadow-xl transition duration-300" | ||||||
|  |                 > | ||||||
|  |                     <h3 className="text-xl font-semibold text-gray-900 dark:text-white truncate"> | ||||||
|  |                         {early_chapter_from_params.book?.title} | ||||||
|  |                     </h3> | ||||||
|  |                     <h4 className="text-lg font-medium text-gray-700 dark:text-gray-300 truncate"> | ||||||
|  |                         Chapter {early_chapter_from_params.number}: {early_chapter_from_params.title} | ||||||
|  |                     </h4> | ||||||
|  |                     <p className="text-md text-red-600 dark:text-red-400 mt-3 font-semibold"> | ||||||
|  |                         Click to Unlock Chapter | ||||||
|  |                     </p> | ||||||
|  |                 </a> | ||||||
|  |             </div> | ||||||
|  |             } | ||||||
|  |             {/* Chapters Grid */} | ||||||
|  |             <ul className="grid gap-6 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3 mt-10"> | ||||||
|  |                 {early_chapters.map((chapter) => ( | ||||||
|  |                     <li key={chapter.id}> | ||||||
|  |                         <a | ||||||
|  |                             href={linkvertise(1318261,`https://nulltranslation.com/early/${encodeId(chapter.book?.documentId || "")}/${encodeId(chapter.documentId)}`)} | ||||||
|  |                             className="block bg-white dark:bg-gray-800 rounded-xl shadow-lg p-6 hover:shadow-xl transition duration-300" | ||||||
|  |                         > | ||||||
|  |                             <h3 className="text-xl font-semibold text-gray-900 dark:text-white truncate"> | ||||||
|  |                                 {chapter.book?.title} | ||||||
|  |                             </h3> | ||||||
|  |                             <h4 className="text-lg font-medium text-gray-700 dark:text-gray-300 truncate"> | ||||||
|  |                                 Chapter {chapter.number}: {chapter.title} | ||||||
|  |                             </h4> | ||||||
|  |                             <p className="text-md text-red-600 dark:text-red-400 mt-3 font-semibold"> | ||||||
|  |                                 Click to Unlock Chapter | ||||||
|  |                             </p> | ||||||
|  |                         </a> | ||||||
|  |                     </li> | ||||||
|  |                 ))} | ||||||
|  |             </ul> | ||||||
|  |         </div> | ||||||
|  |     ); | ||||||
|  |  | ||||||
|  | } | ||||||
| @@ -43,7 +43,6 @@ export default function RootLayout({ children }: { children: React.ReactNode }) | |||||||
|       <span className="ml-3 text-lg">Loading content...</span> |       <span className="ml-3 text-lg">Loading content...</span> | ||||||
|     </div> |     </div> | ||||||
|   ) |   ) | ||||||
|  |  | ||||||
|   return ( |   return ( | ||||||
|     <html lang="en" className={isDarkMode ? "dark" : ""} suppressHydrationWarning> |     <html lang="en" className={isDarkMode ? "dark" : ""} suppressHydrationWarning> | ||||||
|       <head> |       <head> | ||||||
| @@ -78,6 +77,17 @@ export default function RootLayout({ children }: { children: React.ReactNode }) | |||||||
|           gtag('config', 'G-6SXB46RSDE'); |           gtag('config', 'G-6SXB46RSDE'); | ||||||
|         `} |         `} | ||||||
|         </Script> |         </Script> | ||||||
|  |         <Script | ||||||
|  |           strategy="afterInteractive" | ||||||
|  |           src="https://securepubads.g.doubleclick.net/tag/js/gpt.js" | ||||||
|  |           onLoad={() => { | ||||||
|  |             window.googletag = window.googletag || { cmd: [] }; | ||||||
|  |             window.googletag.cmd.push(() => { | ||||||
|  |               window.googletag.pubads().enableSingleRequest(); | ||||||
|  |               window.googletag.enableServices(); | ||||||
|  |             }); | ||||||
|  |           }} | ||||||
|  |         /> | ||||||
|       </head> |       </head> | ||||||
|       <body className="bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-gray-100 min-h-screen"> |       <body className="bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-gray-100 min-h-screen"> | ||||||
|         <KofiWidget /> |         <KofiWidget /> | ||||||
|   | |||||||
| @@ -1,7 +1,7 @@ | |||||||
| import { formatDateToMonthDayYear } from "@/lib/utils"; | import { formatDateToMonthDayYear } from "@/lib/utils"; | ||||||
| import { Chapter, Ad } from "@/lib/types"; | import { Chapter, Ad } from "@/lib/types"; | ||||||
| import { fetchReleases } from "@/lib/api"; | import { fetchReleases } from "@/lib/api"; | ||||||
| import Link from "next/link"; | import { Countdown } from "@/components/Countdown"; | ||||||
|  |  | ||||||
|  |  | ||||||
| export const metadata = { | export const metadata = { | ||||||
| @@ -63,17 +63,17 @@ export default async function ReleasePage() { | |||||||
|                             .sort(([titleA], [titleB]) => titleA.localeCompare(titleB)) |                             .sort(([titleA], [titleB]) => titleA.localeCompare(titleB)) | ||||||
|                             .map(([bookTitle, chapters]) => ( |                             .map(([bookTitle, chapters]) => ( | ||||||
|                                 <div key={bookTitle} className="mb-6"> |                                 <div key={bookTitle} className="mb-6"> | ||||||
|                                     <Link href={`/books/${chapters[0].book?.documentId}`} |                                     <a href={`/books/${chapters[0].book?.documentId}`} | ||||||
|                                         className="text-lg font-semibold text-gray-700 dark:text-yellow-300 mb-4 hover:underline"> |                                         className="text-lg font-semibold text-gray-700 dark:text-yellow-300 mb-4 hover:underline"> | ||||||
|                                         {bookTitle} |                                         {bookTitle} | ||||||
|                                     </Link> |                                     </a> | ||||||
|                                     <ul className="space-y-4"> |                                     <ul className="space-y-4"> | ||||||
|                                         {chapters.map((chapter) => ( |                                         {chapters.map((chapter) => ( | ||||||
|                                             <li |                                             <li | ||||||
|                                                 key={chapter.id} |                                                 key={chapter.id} | ||||||
|                                                 className="p-4 bg-yellow-100 dark:bg-yellow-800 text-gray-800 dark:text-gray-100 rounded-lg shadow-sm border border-yellow-300 dark:border-yellow-700 transition-transform transform hover:scale-105" |                                                 className="p-4 bg-yellow-100 dark:bg-yellow-800 text-gray-800 dark:text-gray-100 rounded-lg shadow-sm border border-yellow-300 dark:border-yellow-700 transition-transform transform hover:scale-105" | ||||||
|                                             > |                                             > | ||||||
|                                                 <Link href={`books/${chapter.book?.documentId}/chapters/${chapter.documentId}`} className="block"> |                                                 <a href={`books/${chapter.book?.documentId}/chapters/${chapter.documentId}`} className="block"> | ||||||
|  |  | ||||||
|                                                     <h4 className="text-md font-medium"> |                                                     <h4 className="text-md font-medium"> | ||||||
|                                                         Chapter {chapter.number}: {chapter.title} |                                                         Chapter {chapter.number}: {chapter.title} | ||||||
| @@ -84,7 +84,7 @@ export default async function ReleasePage() { | |||||||
|                                                             new Date(chapter.release_datetime) |                                                             new Date(chapter.release_datetime) | ||||||
|                                                         )} |                                                         )} | ||||||
|                                                     </p> |                                                     </p> | ||||||
|                                                 </Link> |                                                 </a> | ||||||
|                                             </li> |                                             </li> | ||||||
|                                         ))} |                                         ))} | ||||||
|                                     </ul> |                                     </ul> | ||||||
| @@ -107,10 +107,10 @@ export default async function ReleasePage() { | |||||||
|                             .sort(([titleA], [titleB]) => titleA.localeCompare(titleB)) |                             .sort(([titleA], [titleB]) => titleA.localeCompare(titleB)) | ||||||
|                             .map(([bookTitle, chapters]) => ( |                             .map(([bookTitle, chapters]) => ( | ||||||
|                                 <div key={bookTitle} className="mb-6"> |                                 <div key={bookTitle} className="mb-6"> | ||||||
|                                     <Link href={`/books/${chapters[0].book?.documentId}`} |                                     <a href={`/books/${chapters[0].book?.documentId}`} | ||||||
|                                         className="text-lg font-semibold text-blue-700 dark:text-blue-300 mb-4 hover:underline"> |                                         className="text-lg font-semibold text-blue-700 dark:text-blue-300 mb-4 hover:underline"> | ||||||
|                                         {bookTitle} |                                         {bookTitle} | ||||||
|                                     </Link> |                                     </a> | ||||||
|                                     <ul className="space-y-4"> |                                     <ul className="space-y-4"> | ||||||
|                                         {chapters.map((chapter) => ( |                                         {chapters.map((chapter) => ( | ||||||
|                                             <li |                                             <li | ||||||
| @@ -120,12 +120,9 @@ export default async function ReleasePage() { | |||||||
|                                                 <h4 className="text-md font-medium"> |                                                 <h4 className="text-md font-medium"> | ||||||
|                                                     Chapter {chapter.number}: {chapter.title} |                                                     Chapter {chapter.number}: {chapter.title} | ||||||
|                                                 </h4> |                                                 </h4> | ||||||
|                                                 <p className="text-sm"> |                                                 <h3 className="text-md font-bold text-red-600 dark:text-red-400"> | ||||||
|                                                     Release date:{" "} |                                                     <Countdown release_datetime={chapter.release_datetime} /> | ||||||
|                                                     {formatDateToMonthDayYear( |                                                 </h3> | ||||||
|                                                             new Date(chapter.release_datetime) |  | ||||||
|                                                     )} |  | ||||||
|                                                 </p> |  | ||||||
|                                             </li> |                                             </li> | ||||||
|                                         ))} |                                         ))} | ||||||
|                                     </ul> |                                     </ul> | ||||||
|   | |||||||
							
								
								
									
										55
									
								
								src/components/ChapterContentRenderer.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										55
									
								
								src/components/ChapterContentRenderer.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,55 @@ | |||||||
|  | import React from "react"; | ||||||
|  | import GlossaryPopup from "./GlossaryPopup"; | ||||||
|  | import parse, { DOMNode, Element } from "html-react-parser"; | ||||||
|  |  | ||||||
|  | interface ChapterRendererProps { | ||||||
|  |   content: string; | ||||||
|  |   glossary: JSON; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | const ChapterRenderer: React.FC<ChapterRendererProps> = ({ content, glossary }) => { | ||||||
|  |   // Split content into lines | ||||||
|  |   const content_lines = content.split("\n"); | ||||||
|  |  | ||||||
|  |   // Process each line for glossary replacement | ||||||
|  |   const processed_lines = content_lines.map((line) => { | ||||||
|  |     // Replace glossary terms in the line | ||||||
|  |     Object.entries(glossary).forEach(([term, definition]) => { | ||||||
|  |       const termRegex = new RegExp(`\\b${term}\\b`, "gi"); // Match whole word (case-insensitive) | ||||||
|  |       line = line.replace(termRegex, `<glossarypopup term="${term}" definition="${definition}"></glossarypopup>`); | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     line = line.replace(/<p>/g, "<div>").replace(/<\/p>/g, "</div>"); | ||||||
|  |  | ||||||
|  |     return line; | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   return ( | ||||||
|  |     <div> | ||||||
|  |       {processed_lines.map((line, index) => { | ||||||
|  |         if (!line.includes("<glossarypopup")) { | ||||||
|  |           return <div key={index} className="mb-4">{parse(line)}</div>; | ||||||
|  |         } | ||||||
|  |         return ( | ||||||
|  |         <div key={index} className="mb-4"> | ||||||
|  |           {parse(line, { | ||||||
|  |             replace: (domNode: DOMNode) => { | ||||||
|  |               if (domNode instanceof Element && domNode.tagName === "glossarypopup") { | ||||||
|  |                 return ( | ||||||
|  |                   <GlossaryPopup | ||||||
|  |                     term={domNode.attribs.term} | ||||||
|  |                     definition={domNode.attribs.definition} | ||||||
|  |                   /> | ||||||
|  |                 ); | ||||||
|  |               } | ||||||
|  |               return domNode; | ||||||
|  |             }, | ||||||
|  |           })} | ||||||
|  |         </div> | ||||||
|  |         ) | ||||||
|  |     })} | ||||||
|  |     </div> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export default ChapterRenderer; | ||||||
							
								
								
									
										35
									
								
								src/components/Countdown.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								src/components/Countdown.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,35 @@ | |||||||
|  | "use client"; | ||||||
|  |  | ||||||
|  | import {useState, useEffect} from 'react'; | ||||||
|  |  | ||||||
|  | export function Countdown({release_datetime}: {release_datetime: string}) { | ||||||
|  |     const calculateTimeLeft = () => { | ||||||
|  |         const now = new Date().getTime(); | ||||||
|  |         const releaseTime = new Date(release_datetime).getTime(); | ||||||
|  |         const difference = releaseTime - now; | ||||||
|  |      | ||||||
|  |         if (difference <= 0) { | ||||||
|  |           return "Released!"; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         const hours = Math.floor((difference / (1000 * 60 * 60)) % 24); | ||||||
|  |         const minutes = Math.floor((difference / (1000 * 60)) % 60); | ||||||
|  |         const seconds = Math.floor((difference / 1000) % 60); | ||||||
|  |      | ||||||
|  |         return `${hours}h ${minutes}m ${seconds}s`; | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     const [timeLeft, setTimeLeft] = useState<string>(calculateTimeLeft()); | ||||||
|  |  | ||||||
|  |  | ||||||
|  |     useEffect(() => { | ||||||
|  |         const timer = setTimeout(() => { | ||||||
|  |             setTimeLeft(calculateTimeLeft()); | ||||||
|  |         }, 1000); | ||||||
|  |         return () => clearTimeout(timer); | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     return ( | ||||||
|  |         <span>{timeLeft}</span> | ||||||
|  |     ); | ||||||
|  | } | ||||||
							
								
								
									
										46
									
								
								src/components/GlossaryPopup.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										46
									
								
								src/components/GlossaryPopup.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,46 @@ | |||||||
|  | "use client" | ||||||
|  | import React, { useState, useEffect, useRef } from "react"; | ||||||
|  | interface GlossaryPopupProps { | ||||||
|  |     term: string; | ||||||
|  |     definition: string; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  | const GlossaryPopup: React.FC<GlossaryPopupProps> = ({ term, definition }) => { | ||||||
|  |     const [isOpen, setIsOpen] = useState(false); | ||||||
|  |     const popupRef = useRef<HTMLDivElement>(null); | ||||||
|  |     useEffect(() => { | ||||||
|  |         const handleClickOutside = (event: MouseEvent) => { | ||||||
|  |             if (popupRef.current && !popupRef.current.contains(event.target as Node)) { | ||||||
|  |                 setIsOpen(false); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |         document.addEventListener("mousedown", handleClickOutside); | ||||||
|  |      | ||||||
|  |         return() => { | ||||||
|  |             document.removeEventListener("mousedown", handleClickOutside); | ||||||
|  |         } | ||||||
|  |     }, []); | ||||||
|  |     return ( | ||||||
|  |     <span className="relative"> | ||||||
|  |         {term} | ||||||
|  |         <sup | ||||||
|  |             className="text-blue-600 cursor-pointer" | ||||||
|  |             onClick={() => setIsOpen(!isOpen)} | ||||||
|  |             > | ||||||
|  |                 [-] | ||||||
|  |         </sup> | ||||||
|  |         {isOpen && ( | ||||||
|  |             <div | ||||||
|  |                 ref={popupRef} | ||||||
|  |                 className="absolute left-0 top-full mt-1 w-64 bg-white border border-gray-300 rounded shadow-lg p-1 z-50" | ||||||
|  |             > | ||||||
|  |                 <p className="text-gray-900 font-bold px-2 m-2">{term}</p> | ||||||
|  |                 <p className="text-gray-700 text-sm px-2 m-2">{definition}</p> | ||||||
|  |             </div> | ||||||
|  |         )} | ||||||
|  |     </span> | ||||||
|  |     ) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export default GlossaryPopup; | ||||||
|  |      | ||||||
| @@ -11,7 +11,7 @@ const KofiWidget = () => { | |||||||
|       if (typeof window !== 'undefined' && window.kofiWidgetOverlay) { |       if (typeof window !== 'undefined' && window.kofiWidgetOverlay) { | ||||||
|         window.kofiWidgetOverlay.draw('nulltranslationgroup', { |         window.kofiWidgetOverlay.draw('nulltranslationgroup', { | ||||||
|           'type': 'floating-chat', |           'type': 'floating-chat', | ||||||
|           'floating-chat.donateButton.text': 'Support me', |           'floating-chat.donateButton.text': '', | ||||||
|           'floating-chat.donateButton.background-color': '#00b9fe', |           'floating-chat.donateButton.background-color': '#00b9fe', | ||||||
|           'floating-chat.donateButton.text-color': '#fff', |           'floating-chat.donateButton.text-color': '#fff', | ||||||
|         }); |         }); | ||||||
|   | |||||||
| @@ -1,7 +1,6 @@ | |||||||
| "use client"; | "use client"; | ||||||
| import React, { useState } from "react"; | import React, { useState } from "react"; | ||||||
| import Image from "next/image"; | import Image from "next/image"; | ||||||
| import Link from "next/link"; |  | ||||||
| import { Ad } from "@/lib/types"; | import { Ad } from "@/lib/types"; | ||||||
| export default function Navbar() { | export default function Navbar() { | ||||||
|   const [isMenuOpen, setIsMenuOpen] = useState(false); |   const [isMenuOpen, setIsMenuOpen] = useState(false); | ||||||
| @@ -17,28 +16,29 @@ export default function Navbar() { | |||||||
|             alt="Logo" |             alt="Logo" | ||||||
|             className="h-8 w-8" |             className="h-8 w-8" | ||||||
|           /> |           /> | ||||||
|           <Link href="/" className="text-2xl font-bold"> |           <a href="https://nulltranslation.com" className="text-2xl font-bold"> | ||||||
|             Null Translation Group |             Null Translation Group | ||||||
|           </Link> |           </a> | ||||||
|         </div> |         </div> | ||||||
|  |  | ||||||
|         {/* Navigation Links */} |         {/* Navigation Links */} | ||||||
|  |  | ||||||
|         <nav className="hidden md:flex space-x-6"> |         <nav className="hidden md:flex space-x-6"> | ||||||
|           <Link href={"/announcements"} className="hover:text-gray-400"> |           <a href={"/announcements"} className="hover:text-gray-400"> | ||||||
|             Announcements |             Announcements | ||||||
|           </Link> |           </a> | ||||||
|           <Link href="/" className="hover:text-gray-400"> |           <a href="https://nulltranslation.com" className="hover:text-gray-400"> | ||||||
|             Books |             Books | ||||||
|           </Link> |           </a> | ||||||
|           <Link href={"/releases"} className="hover:text-gray-400"> |           <a href={"/releases"} className="hover:text-gray-400"> | ||||||
|             Releases |             Releases | ||||||
|           </Link> |           </a> | ||||||
|           <Link href={Ad.patreon} className="hover:text-gray-400"> |           <a href={"/early"} className="hover:text-gray-400"> | ||||||
|  |             Early Access | ||||||
|  |           </a> | ||||||
|  |           <a href={Ad.patreon} className="hover:text-yellow-400 text-yellow-200 font-semibold"> | ||||||
|             Patreon |             Patreon | ||||||
|           </Link> |           </a> | ||||||
|  |  | ||||||
|  |  | ||||||
|         </nav> |         </nav> | ||||||
|  |  | ||||||
|         {/* Mobile Menu Button */} |         {/* Mobile Menu Button */} | ||||||
| @@ -66,18 +66,21 @@ export default function Navbar() { | |||||||
|       {/* Mobile Menu */} |       {/* Mobile Menu */} | ||||||
|       {isMenuOpen && ( |       {isMenuOpen && ( | ||||||
|         <div className="md:hidden bg-gray-700 px-6 pb-4"> |         <div className="md:hidden bg-gray-700 px-6 pb-4"> | ||||||
|           <Link href={"/announcements"} className="block py-2 hover:text-gray-400"> |           <a href={"/announcements"} className="block py-2 hover:text-gray-400"> | ||||||
|             Announcements |             Announcements | ||||||
|           </Link> |           </a> | ||||||
|           <Link href={"/releases"} className="block py-2 hover:text-gray-400"> |           <a href={"/releases"} className="block py-2 hover:text-gray-400"> | ||||||
|             Release |             Release | ||||||
|           </Link> |           </a> | ||||||
|           <Link href="/" className="block py-2 hover:text-gray-400"> |           <a href="https://nulltranslation.com" className="block py-2 hover:text-gray-400"> | ||||||
|             Book List |             Book List | ||||||
|           </Link> |           </a> | ||||||
|           <Link href={Ad.patreon} className="block py-2 hover:text-gray-400"> |           <a href={"/early"} className="hover:text-gray-400"> | ||||||
|  |             Early Access | ||||||
|  |           </a> | ||||||
|  |           <a href={Ad.patreon} className="hover:text-yellow-400 text-yellow-200 font-semibold"> | ||||||
|             Patreon |             Patreon | ||||||
|           </Link> |           </a> | ||||||
|         </div> |         </div> | ||||||
|       )} |       )} | ||||||
|     </div> |     </div> | ||||||
|   | |||||||
| @@ -2,7 +2,8 @@ | |||||||
|  |  | ||||||
| import React from "react"; | import React from "react"; | ||||||
| import { useRouter } from "next/navigation"; | import { useRouter } from "next/navigation"; | ||||||
| import { Ad } from "@/lib/types"; | import Link from "next/link"; | ||||||
|  | import { encodeId } from "@/lib/utils"; | ||||||
|  |  | ||||||
| interface NavigationButtonsProps { | interface NavigationButtonsProps { | ||||||
|   bookId: string; |   bookId: string; | ||||||
| @@ -21,7 +22,6 @@ const NavigationButtons: React.FC<NavigationButtonsProps> = ({ bookId, prevChapt | |||||||
|     router.push(`/books/${bookId}`); |     router.push(`/books/${bookId}`); | ||||||
|   }; |   }; | ||||||
|  |  | ||||||
|  |  | ||||||
|   return ( |   return ( | ||||||
|     <div className="mt-2 flex justify-between"> |     <div className="mt-2 flex justify-between"> | ||||||
|       <button |       <button | ||||||
| @@ -53,18 +53,16 @@ const NavigationButtons: React.FC<NavigationButtonsProps> = ({ bookId, prevChapt | |||||||
|           Next Chapter |           Next Chapter | ||||||
|         </button> |         </button> | ||||||
|       ) : ( |       ) : ( | ||||||
|         <a |         <Link | ||||||
|           href={Ad.patreon} |           href={'/early?bookId=' + encodeId(bookId)} | ||||||
|           target="_blank" |  | ||||||
|           rel="noopener noreferrer" |  | ||||||
|           className={` |           className={` | ||||||
|             bg-yellow-500 text-white py-2 px-4 rounded  |             bg-yellow-500 text-white py-2 px-4 rounded  | ||||||
|             hover:bg-yellow-600  |             hover:bg-yellow-600  | ||||||
|             transition duration-200 |             transition duration-200 | ||||||
|           `} |           `} | ||||||
|         > |         > | ||||||
|           Unreleased Chapters |           Unlock Early Chapter | ||||||
|         </a> |         </Link> | ||||||
|       ) |       ) | ||||||
|       } |       } | ||||||
|     </div> |     </div> | ||||||
|   | |||||||
							
								
								
									
										125
									
								
								src/components/ReportButton.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										125
									
								
								src/components/ReportButton.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,125 @@ | |||||||
|  | "use client" | ||||||
|  | import React, { useState, useRef } from "react"; | ||||||
|  | import { createReport | ||||||
|  |  | ||||||
|  |  } from "@/lib/api"; | ||||||
|  | interface ReportButtonProps { | ||||||
|  |     bookId: string; | ||||||
|  |     chapterId: string; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | const ReportButton: React.FC<ReportButtonProps> = ({ bookId, chapterId }) => { | ||||||
|  |     const modalRef = useRef<HTMLDivElement>(null) | ||||||
|  |     const [isOpen, setIsOpen] = useState(false) | ||||||
|  |     const [errorType, setErrorType] = useState('') | ||||||
|  |     const [details, setDetails] = useState('') | ||||||
|  |     const [email, setEmail] = useState('') | ||||||
|  |     const handleSubmitReport = async (event: React.FormEvent<HTMLFormElement>) => { | ||||||
|  |         // Implement report submission here | ||||||
|  |         event.preventDefault() | ||||||
|  |         const response = await createReport(errorType,details,bookId,chapterId,email) | ||||||
|  |         //Linting be linting | ||||||
|  |         if (response.status === 201){ | ||||||
|  |             alert('Report submitted successfully') | ||||||
|  |         } | ||||||
|  |         else{ | ||||||
|  |             alert('Failed to submit report') | ||||||
|  |         } | ||||||
|  |         setErrorType('') | ||||||
|  |         setDetails('')  | ||||||
|  |         setEmail('') | ||||||
|  |         setIsOpen(false) | ||||||
|  |     } | ||||||
|  |     const handleExit = (event: React.MouseEvent) => {         | ||||||
|  |         setErrorType('') | ||||||
|  |         setDetails('') | ||||||
|  |         setEmail('') | ||||||
|  |         if (modalRef.current && !modalRef.current.contains(event.target as Node)) { | ||||||
|  |             setIsOpen(false) | ||||||
|  |         } | ||||||
|  |         event.stopPropagation() | ||||||
|  |     } | ||||||
|  |     return ( | ||||||
|  |         <div className="flex justify-center pt-4" | ||||||
|  |             onClick={handleExit}> | ||||||
|  |             <button onClick={() => setIsOpen(true)} className="px-4 py-2 bg-red-600 text-white font-semibold rounded-lg shadow-md hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-400 focus:ring-offset-2"> Report </button> | ||||||
|  |             {isOpen && (<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50"> | ||||||
|  |                 <div | ||||||
|  |                     className="dark:bg-gray-800 bg-white w-11/12 md:w-1/2 lg:w-1/3 p-6 rounded-lg shadow-lg relative" | ||||||
|  |                     ref={modalRef} | ||||||
|  |                     onClick={(e) => e.stopPropagation()}> | ||||||
|  |                     <div className="flex justify-between items-center border-b pb-3"> | ||||||
|  |                         <h3 className="text-lg font-semibold ">Report Chapter</h3> | ||||||
|  |                     </div> | ||||||
|  |                     <div className="mt-4"> | ||||||
|  |                         <form className="p-6 rounded-lg max-w-md mx-auto space-y-4" | ||||||
|  |                             onSubmit={handleSubmitReport}> | ||||||
|  |                             <div> | ||||||
|  |                                 <label className="block text-md font-semibold mb-1" htmlFor="report_email"> | ||||||
|  |                                     Email : | ||||||
|  |                                 </label> | ||||||
|  |                                 <input  | ||||||
|  |                                 id="report_email"  | ||||||
|  |                                 className="block w-full border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 rounded-lg p-2 focus:outline-none focus:ring-2 focus:ring-blue-500"  | ||||||
|  |                                 type="email"  | ||||||
|  |                                 placeholder="If you want credit/update" | ||||||
|  |                                 value={email} | ||||||
|  |                                 onChange={(e) => setEmail(e.target.value)}> | ||||||
|  |                             </input> | ||||||
|  |                             </div> | ||||||
|  |                             <div> | ||||||
|  |                                 <label className="block text-md font-semibold mb-1 dark:text-gray-300" htmlFor="error-type"> | ||||||
|  |                                     Select Error Type : | ||||||
|  |                                 </label> | ||||||
|  |                                 <select | ||||||
|  |                                     id="error-type" | ||||||
|  |                                     className="block w-full border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 rounded-lg p-2 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"                                | ||||||
|  |                                     value={errorType} | ||||||
|  |                                     onChange={(e)=> setErrorType(e.target.value)} | ||||||
|  |                                     > | ||||||
|  |                                     <option value="">Select your Type</option> | ||||||
|  |                                     <option value="Spelling Error">Spelling Error</option> | ||||||
|  |                                     <option value="Pronoun Error">Pronoun Error</option> | ||||||
|  |                                     <option value="Formatting Error">Formatting Error</option> | ||||||
|  |                                     <option value="Missing Content">Missing Content</option> | ||||||
|  |                                 </select> | ||||||
|  |                             </div> | ||||||
|  |  | ||||||
|  |                             <div> | ||||||
|  |                                 <label className="block text-md font-semibold mb-1" htmlFor="details"> | ||||||
|  |                                     Additional Details : | ||||||
|  |                                 </label> | ||||||
|  |                                 <textarea | ||||||
|  |                                     id="details" | ||||||
|  |                                     placeholder="Provide additional details" | ||||||
|  |                                     value={details} | ||||||
|  |                                     onChange={(e) => setDetails(e.target.value)} | ||||||
|  |                                     className="block w-full h-24 border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 rounded-lg p-2 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 resize-none overflow-y-auto"/> | ||||||
|  |                             </div> | ||||||
|  |  | ||||||
|  |                              | ||||||
|  |                             <div className="flex justify-between"> | ||||||
|  |                                 <button | ||||||
|  |                                     type="submit" | ||||||
|  |                                     className="px-4 py-2 bg-green-500 text-white font-medium rounded-lg hover:bg-green-600 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-offset-2" | ||||||
|  |                                 > | ||||||
|  |                                     Submit | ||||||
|  |                                 </button> | ||||||
|  |                                 <button | ||||||
|  |                                     onClick={handleExit} | ||||||
|  |                                     className="px-4 py-2 dark:bg-gray-500 bg-gray-200 rounded-lg hover:bg-gray-300" | ||||||
|  |                                 > | ||||||
|  |                                     Close | ||||||
|  |                                 </button> | ||||||
|  |                             </div> | ||||||
|  |                         </form> | ||||||
|  |  | ||||||
|  |                     </div> | ||||||
|  |                 </div> | ||||||
|  |             </div> | ||||||
|  |             )} | ||||||
|  |         </div> | ||||||
|  |     ); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export default ReportButton; | ||||||
							
								
								
									
										32
									
								
								src/hooks/useRefreshAds.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								src/hooks/useRefreshAds.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,32 @@ | |||||||
|  | "use client" | ||||||
|  |  | ||||||
|  | import { useEffect } from 'react'; | ||||||
|  | import { useRouter } from 'next/router'; | ||||||
|  |  | ||||||
|  | const useRefreshAds = () => { | ||||||
|  |   const router = useRouter(); | ||||||
|  |  | ||||||
|  |   useEffect(() => { | ||||||
|  |     if (typeof window !== 'undefined') { | ||||||
|  |       console.log("Initializing Ad Refresh..."); | ||||||
|  |       window.googletag = window.googletag || { cmd: [] }; | ||||||
|  |  | ||||||
|  |       router.events.on('routeChangeComplete', () => { | ||||||
|  |         console.log("Refreshing Ads..."); | ||||||
|  |         window.googletag.cmd.push(() => { | ||||||
|  |           if (window.googletag?.pubads) { | ||||||
|  |             window.googletag.pubads().refresh(); | ||||||
|  |           } | ||||||
|  |         }); | ||||||
|  |       }); | ||||||
|  |  | ||||||
|  |       return () => { | ||||||
|  |         router.events.off('routeChangeComplete', () => {}); | ||||||
|  |       }; | ||||||
|  |     } | ||||||
|  |   }, [router]); | ||||||
|  |  | ||||||
|  |   return null; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export default useRefreshAds; | ||||||
							
								
								
									
										108
									
								
								src/lib/api.tsx
									
									
									
									
									
								
							
							
						
						
									
										108
									
								
								src/lib/api.tsx
									
									
									
									
									
								
							| @@ -1,9 +1,36 @@ | |||||||
| import { addDays, subDays } from "date-fns"; | import { addDays, subDays } from "date-fns"; | ||||||
| import { Book, Chapter, Editor, Announcement } from "./types"; | import { Book, Chapter, Editor, Announcement, Glossary } from "./types"; | ||||||
|  |  | ||||||
| const API_URL = process.env.NEXT_PUBLIC_API_URL as string; | const API_URL = process.env.NEXT_PUBLIC_API_URL as string; | ||||||
| const API_TOKEN = process.env.STRAPI_API_TOKEN as string; | const API_TOKEN = process.env.STRAPI_API_TOKEN as string; | ||||||
|  |  | ||||||
|  | export async function createFromAPI<T>( | ||||||
|  |   endpoint: string, | ||||||
|  |   payload: string, | ||||||
|  |   options: RequestInit = {}, | ||||||
|  | ): Promise<T> { | ||||||
|  |  | ||||||
|  |   const headers: HeadersInit = { | ||||||
|  |     Authorization: `Bearer ${API_TOKEN}`, | ||||||
|  |     "Content-Type": "application/json", | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |  | ||||||
|  |   const config: RequestInit = { | ||||||
|  |     method: "POST", | ||||||
|  |     headers, | ||||||
|  |     body: payload, | ||||||
|  |     ...options, | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |  | ||||||
|  |   const response = await fetch(`${API_URL}${endpoint}`, config) | ||||||
|  |   if (!response.ok) { | ||||||
|  |     throw new Error(`API request failed with status ${response.status}`) | ||||||
|  |   } | ||||||
|  |   return response.json() | ||||||
|  | } | ||||||
|  |  | ||||||
| export async function fetchFromAPI<T>( | export async function fetchFromAPI<T>( | ||||||
|   endpoint: string, |   endpoint: string, | ||||||
|   options: RequestInit = {} |   options: RequestInit = {} | ||||||
| @@ -13,8 +40,9 @@ export async function fetchFromAPI<T>( | |||||||
|     "Content-Type": "application/json", |     "Content-Type": "application/json", | ||||||
|   }; |   }; | ||||||
|  |  | ||||||
|  |  | ||||||
|   const config: RequestInit = { |   const config: RequestInit = { | ||||||
|     method: "GET", // Default method is GET |     method: "GET", | ||||||
|     headers, |     headers, | ||||||
|     ...options, |     ...options, | ||||||
|   }; |   }; | ||||||
| @@ -52,12 +80,12 @@ export async function fetchFromAPI<T>( | |||||||
|  * Populates optional fields like Chapters or Editors based on requirements. |  * Populates optional fields like Chapters or Editors based on requirements. | ||||||
|  */ |  */ | ||||||
| export async function fetchBooks(): Promise<Book[]> { | export async function fetchBooks(): Promise<Book[]> { | ||||||
|   const data = await fetchFromAPI<Book>("/api/books?populate=cover&sort[title]=asc"); |   const data = await fetchFromAPI<Book>(`/api/books?populate=cover&sort[title]=asc&filters[release_datetime][$lte]=${new Date().toISOString()}`); | ||||||
|   return data; |   return data; | ||||||
| } | } | ||||||
|  |  | ||||||
| export async function fetchAnnouncements(): Promise<Announcement[]> { | export async function fetchAnnouncements(): Promise<Announcement[]> { | ||||||
|   const data = await fetchFromAPI<Announcement>("/api/announcements?"); |   const data = await fetchFromAPI<Announcement>(`/api/announcements?filters[datetime][$lte]=${new Date().toISOString()}&sort[datetime]=desc`); | ||||||
|   return data; |   return data; | ||||||
| } | } | ||||||
|  |  | ||||||
| @@ -81,9 +109,24 @@ export async function fetchBookChapterLinks(bookId: string): Promise<Book> { | |||||||
|  */ |  */ | ||||||
| export async function fetchBookById(bookId: string): Promise<Book> { | export async function fetchBookById(bookId: string): Promise<Book> { | ||||||
|   const currentDateTime = new Date().toISOString(); |   const currentDateTime = new Date().toISOString(); | ||||||
|  |   const nextday = addDays(new Date(), 1).toISOString(); | ||||||
|  |   //[chapters][filters][release_datetime][$lte]=${currentDateTime} | ||||||
|   const data = await fetchFromAPI<Book>( |   const data = await fetchFromAPI<Book>( | ||||||
|       `/api/books/${bookId}?populate[chapters][filters][release_datetime][$lte]=${currentDateTime}&populate=cover` |     `/api/books/${bookId}?populate[chapters][filters][release_datetime][$lte]=${nextday}&populate=cover` | ||||||
|   ); |   ); | ||||||
|  |   const stripped_data = [] | ||||||
|  |   for (const chapter of data[0].chapters) { | ||||||
|  |     const stripped_chapter = chapter; | ||||||
|  |     if (new Date(chapter.release_datetime).toISOString() > currentDateTime) { | ||||||
|  |       stripped_chapter.content = ""; | ||||||
|  |       stripped_chapter.documentId = ""; | ||||||
|  |     } | ||||||
|  |     stripped_data.push(stripped_chapter) | ||||||
|  |   } | ||||||
|  |   data[0].chapters = stripped_data; | ||||||
|  |   //I do not know why the hell it refuse to populate glossary only 1 field is allow to be populated after ???????? | ||||||
|  |   const glossary = await fetchGlossaryByBookId(bookId); | ||||||
|  |   data[0].glossary = glossary; | ||||||
|   data[0].chapters = data[0].chapters.sort((a, b) => a.number - b.number); |   data[0].chapters = data[0].chapters.sort((a, b) => a.number - b.number); | ||||||
|   return data[0]; |   return data[0]; | ||||||
| } | } | ||||||
| @@ -93,10 +136,8 @@ export async function fetchBookById(bookId: string): Promise<Book> { | |||||||
|  */ |  */ | ||||||
| export async function fetchChapterByBookId(bookId: string, chapterId: string): Promise<Chapter[]> { | export async function fetchChapterByBookId(bookId: string, chapterId: string): Promise<Chapter[]> { | ||||||
|   const currentChapter = await fetchFromAPI<Chapter>(`/api/chapters/${chapterId}?populate[book][fields][0]=title&filters[book][documentId]=${bookId}`); |   const currentChapter = await fetchFromAPI<Chapter>(`/api/chapters/${chapterId}?populate[book][fields][0]=title&filters[book][documentId]=${bookId}`); | ||||||
|   const bookWithAllChapters = await fetchFromAPI<Book>( `/api/books/${bookId}?populate[chapters][filters][number][$gte]=${ |   const bookWithAllChapters = await fetchFromAPI<Book>(`/api/books/${bookId}?populate[chapters][filters][number][$gte]=${currentChapter[0].number - 1 | ||||||
|     currentChapter[0].number - 1 |     }&populate[chapters][filters][number][$lte]=${currentChapter[0].number + 1 | ||||||
|   }&populate[chapters][filters][number][$lte]=${ |  | ||||||
|     currentChapter[0].number + 1 |  | ||||||
|     }`); |     }`); | ||||||
|   //const nextChapter = await fetchFromAPI<Chapter>(`/api/chapters?populate[book]&filters[book][id]=${bookId}&sort[number]=asc`); |   //const nextChapter = await fetchFromAPI<Chapter>(`/api/chapters?populate[book]&filters[book][id]=${bookId}&sort[number]=asc`); | ||||||
|   return bookWithAllChapters[0].chapters; |   return bookWithAllChapters[0].chapters; | ||||||
| @@ -110,11 +151,17 @@ export async function fetchEditors(): Promise<Editor[]> { | |||||||
|   return data; |   return data; | ||||||
| } | } | ||||||
|  |  | ||||||
|  | export async function fetchEarlyRelease(): Promise<Chapter[]> { | ||||||
|  |   const current_datetime = new Date() | ||||||
|  |   const data = await fetchFromAPI<Chapter>(`/api/chapters/?populate[book][fields][0]=title&fields[0]=number&fields[1]=title&fields[2]=release_datetime&filters[release_datetime][$gte]=${current_datetime.toISOString()}&filters[release_datetime][$lte]=${addDays(current_datetime, 1).toISOString()}`); | ||||||
|  |   return data; | ||||||
|  | } | ||||||
|  |  | ||||||
| export type ChapterRelease = { current_chapters: Chapter[], future_chapters: Chapter[] } | export type ChapterRelease = { current_chapters: Chapter[], future_chapters: Chapter[] } | ||||||
| export async function fetchReleases(): Promise<{ current_chapters: Chapter[], future_chapters: Chapter[] }> { | export async function fetchReleases(): Promise<{ current_chapters: Chapter[], future_chapters: Chapter[] }> { | ||||||
|   const current_datetime = new Date() |   const current_datetime = new Date() | ||||||
|   const previous_week = subDays(current_datetime, 3); |   const previous_week = subDays(current_datetime, 1); | ||||||
|   const next_week = addDays(current_datetime, 3); |   const next_week = addDays(current_datetime, 1); | ||||||
|  |  | ||||||
|   const data = await fetchFromAPI<Chapter>(`/api/chapters/?populate[book][fields][0]=title&fields[0]=number&fields[1]=title&fields[2]=release_datetime&filters[release_datetime][$gte]=${previous_week.toISOString()}&filters[release_datetime][$lte]=${next_week.toISOString()}`); |   const data = await fetchFromAPI<Chapter>(`/api/chapters/?populate[book][fields][0]=title&fields[0]=number&fields[1]=title&fields[2]=release_datetime&filters[release_datetime][$gte]=${previous_week.toISOString()}&filters[release_datetime][$lte]=${next_week.toISOString()}`); | ||||||
|   const chapters: Chapter[] = data; |   const chapters: Chapter[] = data; | ||||||
| @@ -127,3 +174,42 @@ export async function fetchAnnouncementById(announcementId: string): Promise<Ann | |||||||
|   const data = await fetchFromAPI<Announcement>(`/api/announcements/${announcementId}?`); |   const data = await fetchFromAPI<Announcement>(`/api/announcements/${announcementId}?`); | ||||||
|   return data[0]; |   return data[0]; | ||||||
| } | } | ||||||
|  |  | ||||||
|  | export async function fetchGlossaryByBookId(bookId: string): Promise<Glossary> { | ||||||
|  |   const data = await fetchFromAPI<Glossary>(`/api/glossaries?filters[book][documentId]=${bookId}`); | ||||||
|  |   return data[0]; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export async function createReport( | ||||||
|  |   error_type: string, | ||||||
|  |   details: string, | ||||||
|  |   book_id: string, | ||||||
|  |   chapter_id: string, | ||||||
|  |   email: string | ||||||
|  | ) { | ||||||
|  |   const payload = { | ||||||
|  |     data: { | ||||||
|  |       error_type: error_type, | ||||||
|  |       details: details, | ||||||
|  |       book: book_id, | ||||||
|  |       chapter: chapter_id, | ||||||
|  |       report_email: email | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   const response = await fetch( | ||||||
|  |     '/api/reports', | ||||||
|  |     { | ||||||
|  |       method: 'POST', | ||||||
|  |       headers: { | ||||||
|  |         'Content-Type': 'application/json', | ||||||
|  |       }, | ||||||
|  |       body: JSON.stringify(payload) | ||||||
|  |     } | ||||||
|  |   ) | ||||||
|  |  | ||||||
|  |   if (!response.ok) { | ||||||
|  |     throw new Error(`API request failed with status ${response.status}`) | ||||||
|  |   } | ||||||
|  |   return response | ||||||
|  | } | ||||||
| @@ -61,6 +61,11 @@ export interface Book { | |||||||
|     release_datetime: string; |     release_datetime: string; | ||||||
|     chapters: Chapter[]; |     chapters: Chapter[]; | ||||||
|     glossary: Glossary; |     glossary: Glossary; | ||||||
|  |     translator_note: string; | ||||||
|  |     rating: number; | ||||||
|  |     views: number; | ||||||
|  |     readers: number; | ||||||
|  |     release_rate: number; | ||||||
|   } |   } | ||||||
|  |  | ||||||
| export interface Announcement { | export interface Announcement { | ||||||
| @@ -77,7 +82,16 @@ export interface Release { | |||||||
|     datetime: string; |     datetime: string; | ||||||
|     url: string; |     url: string; | ||||||
| } | } | ||||||
|  |  | ||||||
|  | export interface Report { | ||||||
|  |     error_type: string; | ||||||
|  |     details: string; | ||||||
|  |     resolve: boolean; | ||||||
|  | } | ||||||
|  |  | ||||||
| export const Ad = { | export const Ad = { | ||||||
|     patreon: "https://patreon.com/nulltranslationgroup/membership", |     patreon: "https://patreon.com/nulltranslationgroup/membership", | ||||||
| } | } | ||||||
|  |  | ||||||
|  |  | ||||||
|  |    | ||||||
| @@ -1,3 +1,7 @@ | |||||||
|  | import { remark } from 'remark'; | ||||||
|  | import html from 'remark-html'; | ||||||
|  | import sanitizeHtml from 'sanitize-html' | ||||||
|  |  | ||||||
| export function formatDateToMonthDayYear(date: Date): string { | export function formatDateToMonthDayYear(date: Date): string { | ||||||
|   return date.toLocaleDateString("en-US", { |   return date.toLocaleDateString("en-US", { | ||||||
|     month: "long", |     month: "long", | ||||||
| @@ -8,3 +12,47 @@ export function formatDateToMonthDayYear(date: Date): string { | |||||||
|     timeZoneName: "short", |     timeZoneName: "short", | ||||||
|   }); |   }); | ||||||
| } | } | ||||||
|  |  | ||||||
|  | export async function markdownToHtml(markdown: string): Promise<string> { | ||||||
|  |  | ||||||
|  |   const result = await remark().use(html).process(markdown) | ||||||
|  |   const sanitizedHtml = sanitizeHtml(result.toString(), { | ||||||
|  |     allowedTags: sanitizeHtml.defaults.allowedTags.concat(['img', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'code', 'pre']), | ||||||
|  |     allowedAttributes: { | ||||||
|  |       ...sanitizeHtml.defaults.allowedAttributes, | ||||||
|  |       img: ['src', 'alt'], | ||||||
|  |     }, | ||||||
|  |   }) | ||||||
|  |   if(sanitizedHtml == ""){ | ||||||
|  |     //Already html | ||||||
|  |     return markdown | ||||||
|  |   } | ||||||
|  |   return sanitizedHtml | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export function encodeId(documentId: string): string { | ||||||
|  |   const salt = "salty_aff" | ||||||
|  |   return Buffer.from(documentId + salt).toString('base64') | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export function decodeId(encodedId: string): string { | ||||||
|  |   const salt = "salty_aff" | ||||||
|  |   return Buffer.from(encodedId, 'base64').toString().replace(salt, '') | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export function btoa(str: string): string { | ||||||
|  |   let buffer: Buffer; | ||||||
|  |  | ||||||
|  |   if (Buffer.isBuffer(str)) { | ||||||
|  |     buffer = str; | ||||||
|  |   } else { | ||||||
|  |     buffer = Buffer.from(str, "binary"); | ||||||
|  |   } | ||||||
|  |   return buffer.toString("base64"); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export function linkvertise(userid: number, link: string): string { | ||||||
|  |   const base_url = `https://link-to.net/${userid}/${Math.floor(Math.random() * 1000)}/dynamic`; | ||||||
|  |   const href = `${base_url}?r=${btoa(encodeURI(link))}`; | ||||||
|  |   return href; | ||||||
|  | } | ||||||
							
								
								
									
										31
									
								
								src/middleware.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								src/middleware.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,31 @@ | |||||||
|  | import { NextResponse } from 'next/server'; | ||||||
|  |  | ||||||
|  | const rateLimitCache = new Map<string, { timestamp: number; count: number }>(); | ||||||
|  | const RATE_LIMIT_WINDOW_MS = 60000;  | ||||||
|  | const RATE_LIMIT_MAX_REQUESTS = 5;  | ||||||
|  |  | ||||||
|  | export function middleware(request: Request) { | ||||||
|  |   const ip = request.headers.get('x-forwarded-for') || '127.0.0.1'; | ||||||
|  |   const now = Date.now(); | ||||||
|  |  | ||||||
|  |   const userRateLimit = rateLimitCache.get(ip) || { count: 0, timestamp: now }; | ||||||
|  |  | ||||||
|  |   if (now - userRateLimit.timestamp > RATE_LIMIT_WINDOW_MS) { | ||||||
|  |     rateLimitCache.set(ip, { count: 1, timestamp: now }); | ||||||
|  |   } else if (userRateLimit.count >= RATE_LIMIT_MAX_REQUESTS) { | ||||||
|  |     return NextResponse.json( | ||||||
|  |       { message: 'Rate limit exceeded. Please wait a moment.' }, | ||||||
|  |       { status: 429 } | ||||||
|  |     ); | ||||||
|  |   } else { | ||||||
|  |     userRateLimit.count += 1; | ||||||
|  |     rateLimitCache.set(ip, userRateLimit); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   return NextResponse.next();  | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // Apply middleware only to API routes | ||||||
|  | export const config = { | ||||||
|  |   matcher: '/api/:path*', | ||||||
|  | }; | ||||||
		Reference in New Issue
	
	Block a user