Compare commits

..

29 Commits

Author SHA1 Message Date
9f967bf9d7 Readjustment for linting 2025-03-12 14:22:55 -04:00
b1c3d9ef84 Linkvertise try number 2 2025-03-12 14:16:08 -04:00
5082135d62 Linkvertise addition 2025-03-12 13:52:49 -04:00
01dc9c9749 Change the way routing works, so ad will fit better for everyone, we gotta find away to refresh it so we can use <Link> but right now quick and dirty solution 2025-03-12 01:52:05 -04:00
b7f7fd1989 Changed adcess page to allow more access 2025-03-12 01:13:02 -04:00
906ad8a7a1 Added ad-walled support, with counters and changed a lot of the current pages look to see funnel people toward that path 2025-03-12 00:58:47 -04:00
9b186a462e Ads.txt file 2025-03-11 18:53:20 -04:00
4cbd28b396 Whoops accidentally swapped tags now we aint got any data 2025-03-09 20:32:51 -04:00
96ea6c446c Changed so that announcement and book do not show things that haven't been released yet ! 2025-03-08 23:49:44 -05:00
ffe356dd2a Changed the way the RSS feed works by sorting by release time, hopefully this will make sure the autoupdater doesnt crash and burn 2025-01-27 19:06:13 -05:00
406963f6bf Unused varibles and methods removal,
Change the way error handling works so I dont have explicit any type.
2025-01-24 13:39:11 -05:00
f192e51f3c Refactor the createReport to API like every other api calls, and added alert to tell users if they sucessfully submitted a report 2025-01-24 13:03:59 -05:00
ad66be5039 Report button support, all that jazz. 2025-01-24 12:54:05 -05:00
9c78ed022d Rate limiting for api so people can't mass report. 2025-01-24 12:53:24 -05:00
838e9730a4 Added chapter renderer and type for report 2025-01-24 00:47:50 -05:00
97e318ce6f The mystery of the glossary addition. I really gotta figure out why it refuses to populate glossary and cover with chapters at the same time. 2025-01-23 01:44:12 -05:00
554a6dff45 Announcement Page retooling, make it look better.
added Markdown support for rich text for all type.
Markdown support for books description page too to add Translator Notes.
Added a Rendering Pipeline for specialized Glossary Popup to work.
Added more info to book, from back and front end. Getting ready for ratings views and reader list.
2025-01-23 01:43:36 -05:00
2316fe68e1 Added robots.txt probably will help the novelupdate bot situation 2025-01-22 19:50:26 -05:00
1d66201478 Added Suspense to the entire layout, because our server sucks and sometime giving people the suspense loading page is a good look while the server is fetching and sending back the new page 2025-01-22 05:36:56 -05:00
1b274884e8 Changed the phone version of menu to make it fit better 2025-01-21 21:31:04 -05:00
879dc44284 Added announcement pages and api changes and types to fix announcements 2025-01-21 19:20:43 -05:00
c32020aa47 Added adsense 2025-01-21 04:08:25 -05:00
52b2301fc1 Added releases changed the way apis work underneath to give more room for caching, i.e remove unneeded crap when it is unneeded 2025-01-21 00:43:14 -05:00
9baa792111 Added google anal 2025-01-17 19:52:56 -05:00
2e876265b4 Added category hopefully novelupdates will auto pickup now 2025-01-15 23:29:23 -05:00
56f871f603 Added kofi support me 2025-01-15 20:51:08 -05:00
3fb1c6434a Added revalidate so it will actually pull the front page and update 2025-01-14 02:37:47 -05:00
89cff766bf More api fixes and page fixes 2025-01-13 22:27:32 -05:00
261589b025 Change for rss feed to actually get pick up. 2025-01-13 21:31:03 -05:00
30 changed files with 2638 additions and 163 deletions

View File

@@ -1,37 +1,44 @@
## Put my baby in a docker and let it rip
Frontend of a headless CMS running stripi in the back.
Frontend of a headless CMS running Strapi in the back.
Will host all LLM translation and edit for Foreign Langauges Novels.
Will host all LLM translation and editing for Foreign Language Novels.
Check the main site at https://NullTranslation.com
Check the main site: [NullTranslation](https://NullTranslation.com)
Check out our patreon at https://patreon.com/NullTranslationGroup
Support us on Patreon: [Null Translation Group](https://patreon.com/NullTranslationGroup)
**Books Currently Supporting**
### **TODO**
- ~~ADD MORE NOVEL SUPPORT~~
- STANDARDIZE THE ERROR LOGS (it will be here till the end of days)
- ADD ADS LATER (Added Analytics for ads and user tracking)
- SCHEDULE POSTS FOR PATREON READAHEAD (Lets be real a chron is a real chron in the booty)
- Releases Page for recent releases and schedule for releases (Needed so badly needed)
- Annoucement page for announcements (Gotta tell the 5 users apparently we support audio soon ?)
- AUDIO TM? TTS! TTS!! TTS!!! (I am not paying right ?)
- NextJS data cache, DID SOMEONE SAY PAGINATION ?
---
对弈江山 by 染夕遥 = "Fight of the Throne" by Ran Xiyao
### **Books Currently Supporting**
- **对弈江山** by 染夕遥 = *"Fight of the Throne"* by Ran Xiyao
- **医手遮天** by 慕璎珞 = *"Godly Talented Doctor"* by Mu Yingluo
- **MANY MORE**
医手遮天 by 慕璎珞 = "Godly Talented Doctor" by Mu Yingluo
This project acts as a learning experience for:
- **Dynamic Routing**: Using NEXT.js dynamic router.
- **SSR** vs **CSR** : Interesting experiment
- **SPA** vs **MPA** : Also kindda fund tomess with.
- **Frontend for a Headless CMS**: Powered by Strapi, at first kindda rough, now very easy to understand why CMS exists.
- **NEXTJS DATA CACHE**: 2mb ? what i that for ants ? pagination here we come
This project acts a learning experience for Dynamic Routing using NEXT JS dynamic router and a frontend for a headless CMS Stripi.
### **Conclusion**
*Somehow, dealing with Java Spring Boot nightmares feels easier than this. In the end, using the API directly for uploading data seemed more efficient for my needs, rather than relying on the CMS interface.*
**Conclusion:** Somehow Java Springboot nightmare is easier than this. In the end it seems utterly unncessary for my use.
I ended up pushing to the api instead of using the CMS interface for uploading data anyway...
Maybe CMS is more useful to make websites for customers with no experience in CS, but personally very not useful.
*Perhaps CMS is more useful for building websites for customers with little to no CS experience. For personal use, however, it currently feels unnecessary.*
*That said, CMS might become more useful later if the project grows. Features like shopping tiers and authentication could be easier to implement using a CMS framework.*
> **Okay, CMS is god—we believe in CMS now.**
**WHERE FROM HERE**
Site will be polish more later when/if any of the novels has traffic.
Things like shopping tiers and authentications seems like maybe something CMS would help with implementing.
We will see if then CMS is more useful later.
**TODO**
ADD MORE NOVEL SUPPORT
STANDARDIZE THE ERROR LOGS
ADS LATER
SCHEDULE POSTS FOR PATREON READAHEAD
Polish for the site will come when/if any of the novels gain significant traffic. For now, we'll keep iterating!

19
global.d.ts vendored
View File

@@ -1,6 +1,23 @@
import { link } from "fs";
declare namespace NodeJS {
interface ProcessEnv {
NEXT_PUBLIC_API_URL: string;
STRAPI_API_TOKEN: string;
}
}
}
declare global {
interface Window {
kofiWidgetOverlay: any;
dataLayer: any[];
googletag: {
cmd: any[];
pubads: () => {
refresh: () => void;
enableSingleRequest: () => void;
};
enableServices: () => void;
};
}
}
export {};

1267
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -16,10 +16,14 @@
"@tanstack/react-query-devtools": "^5.63.0",
"axios": "^1.7.9",
"date-fns": "^4.1.0",
"html-react-parser": "^5.2.2",
"next": "15.1.4",
"react": "^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": {
"@eslint/eslintrc": "^3",
@@ -27,6 +31,7 @@
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/rss": "^0.0.32",
"@types/sanitize-html": "^2.13.0",
"eslint": "^9",
"eslint-config-next": "15.1.4",
"postcss": "^8",

1
public/ads.txt Normal file
View File

@@ -0,0 +1 @@
google.com, pub-1843060382170565, DIRECT, f08c47fec0942fa0

3
public/robots.txt Normal file
View File

@@ -0,0 +1,3 @@
User-agent: *
Disallow: /books/*/chapters/
Allow: /

View File

@@ -0,0 +1,34 @@
import { fetchAnnouncementById } from "@/lib/api";
import { Announcement } from "@/lib/types";
import { formatDateToMonthDayYear, markdownToHtml } from "@/lib/utils";
export const metadata = {
title: 'Announcement Detail Page',
description: 'NullTranslationGroup Announcement page',
};
export type paramsType = Promise<{ announcementId: string }>;
export default async function AnnouncementDetailPage(props: {params: paramsType}) {
const { announcementId } = await props.params;
let announcement: Announcement;
try{
announcement = await fetchAnnouncementById(announcementId);
announcement.content = await markdownToHtml(announcement.content);
}
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> Announcement not found !</h1></center>' }}></div>
</div>
)
}
return (
<div className="prose dark:prose-invert mx-auto p-6 bg-white dark:bg-gray-800 shadow-md rounded-lg mt-4">
<h1>{announcement.title}</h1>
<h2>Release Date: {formatDateToMonthDayYear(new Date(announcement.datetime))}</h2>
<div dangerouslySetInnerHTML={{ __html: announcement.content }}></div>
</div>
)
}

View File

@@ -0,0 +1,68 @@
import { formatDateToMonthDayYear } from "@/lib/utils";
import { Announcement } from "@/lib/types";
import { fetchAnnouncements } from "@/lib/api";
import { markdownToHtml } from "@/lib/utils";
import Link from "next/link";
export const metadata = {
title: "Announcement Page",
description: "NullTranslationGroup Announcement Page",
};
export default async function AnnouncementPage() {
let announcements: Announcement[] = [];
try {
announcements = await fetchAnnouncements();
for (const announcement of announcements) {
announcement.content = await markdownToHtml(announcement.content);
}
} catch (error) {
console.error(error);
return (
<div className="text-center mt-10 text-red-500">
<p>Failed to load announcements. Please try again later.</p>
</div>
);
}
// 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 (
<div className="max-w-6xl mx-auto py-10 px-4">
<h1 className="text-3xl font-bold text-center mb-8">Announcements</h1>
{sorted_announcements.length === 0 ? (
<div className="text-center text-gray-500">
<p>No announcements available.</p>
</div>
) : (
<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>
)
})}
</div>
)}
</div>
);
}

View File

@@ -16,11 +16,12 @@ export async function GET(){
feed_url: `${BASE_URL}/api/feed`,
language: "en",
});
data.forEach((chapter: Chapter) => {
const data_sorted = data.sort((a: Chapter, b: Chapter) => { return new Date(a.release_datetime).getTime() - new Date(b.release_datetime).getTime(); });
data_sorted.forEach((chapter: Chapter) => {
feed.item({
title: chapter.book?.title + " - Chapter " + chapter.number + " " + chapter.title,
title: chapter.book?.title + ": c" + chapter.number,
description: "Daily chapter release for " + chapter.book?.title,
categories: [chapter.book?.title || "Unknown"],
url: `${BASE_URL}/books/${chapter.book?.documentId}/chapters/${chapter.documentId}`,
date: chapter.release_datetime,
});

View 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 });
}
}

View File

@@ -1,7 +1,10 @@
import React from "react";
import NavigationButtons from "@/components/NavigationButtons";
import { Book, Chapter } from "@/lib/types";
import { fetchBookById } from "@/lib/api";
import ReportButton from "@/components/ReportButton";
import ChapterRenderer from "@/components/ChapterContentRenderer";
import { Chapter } from "@/lib/types";
import { fetchChapterByBookId, fetchGlossaryByBookId } from "@/lib/api";
import { markdownToHtml } from "@/lib/utils";
export type paramsType = Promise<{ bookId: string; chapterId: string }>;
export const metadata = {
@@ -13,13 +16,12 @@ export const metadata = {
export default async function ChapterPage(props: { params: paramsType}) {
const { bookId, chapterId } = await props.params;
let book: Book;
let chapters: Chapter[];
try{
book = await fetchBookById(bookId);
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>
@@ -27,17 +29,17 @@ export default async function ChapterPage(props: { params: paramsType}) {
</div>
)
}
const chapters :Chapter[] = book.chapters;
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)?.documentId || "" : "";
const prev_chapter = current_chapter ? sorted_chapters.find((chapter) => chapter.number === current_chapter.number - 1)?.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 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: '<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={""} />
</div>
)
@@ -46,9 +48,10 @@ export default async function ChapterPage(props: { params: paramsType}) {
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" 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}/>
<ReportButton bookId={bookId} chapterId={chapterId} />
</div>
);
}

View File

@@ -1,14 +1,15 @@
import { fetchBookById } from "@/lib/api";
import { Book } from "@/lib/types";
import { formatDateToMonthDayYear } from "@/lib/utils";
import { Book, Chapter } from "@/lib/types";
import { encodeId, formatDateToMonthDayYear, markdownToHtml } from "@/lib/utils";
import ChapterDropdown from "@/components/ChapterDropdown";
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 = {
title: 'Null Translation Group',
description: 'Null Translatin Group book',
description: 'Null Translation Group book description page',
};
export default async function BookPage(props: { params: paramsType }) {
@@ -25,59 +26,45 @@ 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 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 recentChapters = chapters.length > 6 ? chapters.slice(chapters.length - 6, chapters.length) : chapters;
const recentChapters = sorted_chapters.length > 8 ? sorted_chapters.slice(sorted_chapters.length - 8, sorted_chapters.length - 2) : current_chapters;
return (
<div className="max-w-6xl mx-auto py-10 px-4">
<div className="flex flex-col items-center justify-center">
<img src={`${process.env.NEXT_PUBLIC_API_URL}${cover_media?.url}`}
alt={cover_media?.alternativeText || `Cover of ${book.title}`}
className="rounded-lg object-cover w-64 h-96"
/>
<h1 className="text-5xl pb-2 font-bold">{title}</h1>
<a
href={Ad.patreon}
target="_blank"
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"
>
Join Our Patreon for Unreleased Chapters
</a>
</div>
<div className="max-w-6xl mx-auto py-10 px-4">
<div className="flex flex-col items-center justify-center">
<img src={`${process.env.NEXT_PUBLIC_API_URL}${cover_media?.url}`}
alt={cover_media?.alternativeText || `Cover of ${book.title}`}
className="rounded-lg object-cover w-64 h-96"
/>
<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
href={Ad.patreon}
target="_blank"
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"
>
Join Our Patreon to read ahead!
</a>
</div>
<p className="text-lg text-gray-600 dark:text-gray-400 mb-4">
<strong>Author:</strong> {author}
<br></br>
<strong>Translator:</strong> Null Translation Group
</p>
<p className="mb-6">{description}</p>
<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">
{recentChapters.map((chapter) => (
<li key={chapter.id}>
<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"
>
<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>
</li>
))}
</ul>
<div className="flex items-center justify-between mb-4 pt-4">
<h2 className="text-3xl font-semibold">All Chapters</h2>
<ChapterDropdown chapters={chapters} bookId={bookId} />
</div>
{chapters.map((chapter) => (
<li key={chapter.id} className="mb-2 list-none">
<p className="text-lg text-gray-600 dark:text-gray-400 mb-4">
<strong>Author:</strong> {author}
<br></br>
<strong>Translator:</strong> Null Translation Group
</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>
<ul className="grid gap-6 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3">
{recentChapters.map((chapter) => (
<li key={chapter.id}>
<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"
@@ -91,7 +78,140 @@ export default async function BookPage(props: { params: paramsType }) {
</a>
</li>
))}
</ul>
<div className="flex items-center justify-between mb-4 pt-4">
<h2 className="text-3xl font-semibold">Next Chapters</h2>
</div>
);
{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">
<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"
>
<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>
</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>
);
}

View 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
View 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>
);
}

View File

@@ -18,4 +18,4 @@ body {
color: var(--foreground);
background: var(--background);
font-family: Arial, Helvetica, sans-serif;
}
}

View File

@@ -2,9 +2,11 @@
import "tailwindcss/tailwind.css";
import React, { useEffect, useState } from "react";
import Script from "next/script";
import React, { Suspense, useEffect, useState } from "react";
import NightModeToggle from "@/components/NightModeToggle";
import Navbar from "@/components/NavigationBar";
import KofiWidget from "@/components/KofiWidget";
export default function RootLayout({ children }: { children: React.ReactNode }) {
const [isDarkMode, setIsDarkMode] = useState(false);
@@ -31,9 +33,19 @@ export default function RootLayout({ children }: { children: React.ReactNode })
document.documentElement.classList.toggle("dark", !isDarkMode);
};
const Default_Fallback = () => (
<div className="flex items-center justify-center min-h-screen bg-gray-200 dark:bg-gray-800 text-gray-600 dark:text-gray-300">
<img
src="/logo.png" // Replace this with the actual path to your logo
alt="Loading..."
className="h-16 w-16 animate-bounce" // Add animation if desired
/>
<span className="ml-3 text-lg">Loading content...</span>
</div>
)
return (
<html lang="en" className={isDarkMode ? "dark" : ""} suppressHydrationWarning>
<head>
<head>
<script
dangerouslySetInnerHTML={{
__html: `
@@ -49,10 +61,40 @@ export default function RootLayout({ children }: { children: React.ReactNode })
`,
}}
/>
<Script
async
src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-1843060382170565"
crossOrigin="anonymous"></Script>
<Script
src="https://www.googletagmanager.com/gtag/js?id=G-6SXB46RSDE"
strategy="afterInteractive"
/>
<Script id="google-analytics" strategy="afterInteractive">
{`
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'G-6SXB46RSDE');
`}
</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>
<body className="bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-gray-100 min-h-screen">
<KofiWidget />
<Navbar />
<main className="relative">{children}</main>
<Suspense fallback={<Default_Fallback />}>
<main className="relative">{children}</main>
</Suspense>
<div className="fixed bottom-4 right-4">
<NightModeToggle isDarkMode={isDarkMode} toggleDarkMode={toggleDarkMode} />
</div>

View File

@@ -9,7 +9,6 @@ export const metadata = {
export default async function HomePage() {
let books: Book[] = [];
try {
books = await fetchBooks();
} catch (error) {

141
src/app/releases/page.tsx Normal file
View File

@@ -0,0 +1,141 @@
import { formatDateToMonthDayYear } from "@/lib/utils";
import { Chapter, Ad } from "@/lib/types";
import { fetchReleases } from "@/lib/api";
import { Countdown } from "@/components/Countdown";
export const metadata = {
title: 'Release page',
description: 'NullTranslationGroup Announcement page',
};
export default async function ReleasePage() {
let current_chapters: Chapter[] = [];
let future_chapters: Chapter[] = [];
try {
const releases = await fetchReleases();
current_chapters = releases.current_chapters
future_chapters = releases.future_chapters
} catch (error) {
console.error(error);
return (
<div className="text-center mt-10 text-red-500">
<p>Failed to load releases.</p>
</div>
);
}
const sorted_current_chapters = current_chapters.sort((a, b) => new Date(a.release_datetime).getTime() - new Date(b.release_datetime).getTime());
const sorted_future_chapters = future_chapters.sort((a, b) => new Date(a.release_datetime).getTime() - new Date(b.release_datetime).getTime());
const groupChaptersByNovel = (chapters: Chapter[]) => {
return chapters.reduce((acc, chapter) => {
const bookTitle = chapter.book?.title || "Unknown Title";
if (!acc[bookTitle]) {
acc[bookTitle] = [];
}
acc[bookTitle].push(chapter);
return acc;
}, {} as Record<string, Chapter[]>);
};
const groupedCurrentChapters = groupChaptersByNovel(sorted_current_chapters)
const groupedFutureChapters = groupChaptersByNovel(sorted_future_chapters);
return (
<div className="mx-auto p-6 bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-gray-100 min-h-screen">
<div className="hidden md:block bg-yellow-500 text-black py-2 px-4 rounded-lg hover:bg-yellow-600 transition duration-200 mb-6">
<a
href={Ad.patreon}
target="_blank"
rel="noopener noreferrer"
className="font-semibold text-center block"
>
WANT TO READ AHEAD OF SCHEDULE ? JOIN OUR PATREON !
</a>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
{/* Current Releases Section */}
<div>
<h2 className="text-2xl font-bold text-yellow-500 mb-6 border-b-2 border-yellow-500 pb-2">
Current Releases
</h2>
{Object.keys(groupedCurrentChapters).length > 0 ? (
Object.entries(groupedCurrentChapters)
.sort(([titleA], [titleB]) => titleA.localeCompare(titleB))
.map(([bookTitle, chapters]) => (
<div key={bookTitle} className="mb-6">
<a href={`/books/${chapters[0].book?.documentId}`}
className="text-lg font-semibold text-gray-700 dark:text-yellow-300 mb-4 hover:underline">
{bookTitle}
</a>
<ul className="space-y-4">
{chapters.map((chapter) => (
<li
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"
>
<a href={`books/${chapter.book?.documentId}/chapters/${chapter.documentId}`} className="block">
<h4 className="text-md font-medium">
Chapter {chapter.number}: {chapter.title}
</h4>
<p className="text-sm">
Released on:{" "}
{formatDateToMonthDayYear(
new Date(chapter.release_datetime)
)}
</p>
</a>
</li>
))}
</ul>
</div>
))
) : (
<p className="text-gray-600 dark:text-gray-400 italic">
No current releases available.
</p>
)}
</div>
{/* Future Releases Section */}
<div>
<h2 className="text-2xl font-bold text-blue-500 mb-6 border-b-2 border-blue-500 pb-2">
Future Releases
</h2>
{Object.keys(groupedFutureChapters).length > 0 ? (
Object.entries(groupedFutureChapters)
.sort(([titleA], [titleB]) => titleA.localeCompare(titleB))
.map(([bookTitle, chapters]) => (
<div key={bookTitle} className="mb-6">
<a href={`/books/${chapters[0].book?.documentId}`}
className="text-lg font-semibold text-blue-700 dark:text-blue-300 mb-4 hover:underline">
{bookTitle}
</a>
<ul className="space-y-4">
{chapters.map((chapter) => (
<li
key={chapter.id}
className="p-4 bg-blue-100 dark:bg-blue-800 text-gray-800 dark:text-gray-100 rounded-lg shadow-sm border border-blue-300 dark:border-blue-700 transition-transform transform hover:scale-105"
>
<h4 className="text-md font-medium">
Chapter {chapter.number}: {chapter.title}
</h4>
<h3 className="text-md font-bold text-red-600 dark:text-red-400">
<Countdown release_datetime={chapter.release_datetime} />
</h3>
</li>
))}
</ul>
</div>
))
) : (
<p className="text-gray-600 dark:text-gray-400 italic">
No future releases available.
</p>
)}
</div>
</div>
</div>
);
}

View 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;

View 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>
);
}

View 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;

View File

@@ -0,0 +1,31 @@
"use client"
import { useEffect } from 'react';
const KofiWidget = () => {
useEffect(() => {
// Dynamically create and append the script
const script = document.createElement('script');
script.src = 'https://storage.ko-fi.com/cdn/scripts/overlay-widget.js';
script.async = true;
script.onload = () => {
if (typeof window !== 'undefined' && window.kofiWidgetOverlay) {
window.kofiWidgetOverlay.draw('nulltranslationgroup', {
'type': 'floating-chat',
'floating-chat.donateButton.text': '',
'floating-chat.donateButton.background-color': '#00b9fe',
'floating-chat.donateButton.text-color': '#fff',
});
}
};
document.body.appendChild(script);
return () => {
// Clean up the script when the component unmounts
document.body.removeChild(script);
};
}, []);
return null; // No visible JSX; the widget is a floating overlay
};
export default KofiWidget;

View File

@@ -1,7 +1,6 @@
"use client";
import React, { useState } from "react";
import Image from "next/image";
import Link from "next/link";
import { Ad } from "@/lib/types";
export default function Navbar() {
const [isMenuOpen, setIsMenuOpen] = useState(false);
@@ -17,19 +16,29 @@ export default function Navbar() {
alt="Logo"
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
</Link>
</a>
</div>
{/* Navigation Links */}
<nav className="hidden md:flex space-x-6">
<Link href="/" className="hover:text-gray-400">
Book List
</Link>
<Link href={Ad.patreon} className="hover:text-gray-400">
<a href={"/announcements"} className="hover:text-gray-400">
Announcements
</a>
<a href="https://nulltranslation.com" className="hover:text-gray-400">
Books
</a>
<a href={"/releases"} className="hover:text-gray-400">
Releases
</a>
<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
</Link>
</a>
</nav>
{/* Mobile Menu Button */}
@@ -57,12 +66,21 @@ export default function Navbar() {
{/* Mobile Menu */}
{isMenuOpen && (
<div className="md:hidden bg-gray-700 px-6 pb-4">
<Link href="/" className="block py-2 hover:text-gray-400">
<a href={"/announcements"} className="block py-2 hover:text-gray-400">
Announcements
</a>
<a href={"/releases"} className="block py-2 hover:text-gray-400">
Release
</a>
<a href="https://nulltranslation.com" className="block py-2 hover:text-gray-400">
Book List
</Link>
<Link href={Ad.patreon} className="block py-2 hover:text-gray-400">
</a>
<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
</Link>
</a>
</div>
)}
</div>

View File

@@ -2,7 +2,8 @@
import React from "react";
import { useRouter } from "next/navigation";
import { Ad } from "@/lib/types";
import Link from "next/link";
import { encodeId } from "@/lib/utils";
interface NavigationButtonsProps {
bookId: string;
@@ -21,7 +22,6 @@ const NavigationButtons: React.FC<NavigationButtonsProps> = ({ bookId, prevChapt
router.push(`/books/${bookId}`);
};
return (
<div className="mt-2 flex justify-between">
<button
@@ -53,18 +53,16 @@ const NavigationButtons: React.FC<NavigationButtonsProps> = ({ bookId, prevChapt
Next Chapter
</button>
) : (
<a
href={Ad.patreon}
target="_blank"
rel="noopener noreferrer"
<Link
href={'/early?bookId=' + encodeId(bookId)}
className={`
bg-yellow-500 text-white py-2 px-4 rounded
hover:bg-yellow-600
transition duration-200
`}
>
Unreleased Chapters
</a>
Unlock Early Chapter
</Link>
)
}
</div>

View 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;

View 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;

View File

@@ -1,43 +1,78 @@
import { subDays } from "date-fns";
import { Book, Chapter, Editor } from "./types";
import { addDays, subDays } from "date-fns";
import { Book, Chapter, Editor, Announcement, Glossary } from "./types";
const API_URL = process.env.NEXT_PUBLIC_API_URL as string;
const API_TOKEN = process.env.STRAPI_API_TOKEN as string;
/**
* Centralized API fetch function with TypeScript support.
* Handles GET, POST, PUT, DELETE methods and includes headers by default.
*/
export async function fetchFromAPI<T>(
export async function createFromAPI<T>(
endpoint: string,
options: RequestInit = {}
payload: string,
options: RequestInit = {},
): Promise<T> {
const url = `${API_URL}${endpoint}`;
const headers: HeadersInit = {
Authorization: `Bearer ${API_TOKEN}`,
"Content-Type": "application/json",
};
const config: RequestInit = {
method: "GET", // Default method is GET
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>(
endpoint: string,
options: RequestInit = {}
): Promise<T[]> {
const headers: HeadersInit = {
Authorization: `Bearer ${API_TOKEN}`,
"Content-Type": "application/json",
};
const config: RequestInit = {
method: "GET",
headers,
...options,
};
let results: T[] = [];
let currentPage = 1;
let totalPages = 1;
try {
const response = await fetch(url, config);
if (!response.ok) {
const errorData = await response.json();
console.error(`Error fetching ${url}:`, errorData);
throw new Error(errorData.message || `API fetch error (status: ${response.status})`);
}
const responseJson = await response.json();
return responseJson;
do {
const url = `${API_URL}${endpoint}&pagination[page]=${currentPage}&pagination[pageSize]=25`;
const response = await fetch(url, { ...config, next: { revalidate: 30 } });
if (!response.ok) {
const errorData = await response.json();
console.error(`Error fetching ${url}:`, errorData);
throw new Error(errorData.message || `API fetch error (status: ${response.status})`);
}
const responseJson = await response.json();
results = results.concat(responseJson.data);
totalPages = responseJson.meta?.pagination?.pageCount;
currentPage += 1;
} while (currentPage <= totalPages)
} catch (error) {
console.error("Fetch error:", error);
throw error;
}
return results;
}
/**
@@ -45,22 +80,27 @@ export async function fetchFromAPI<T>(
* Populates optional fields like Chapters or Editors based on requirements.
*/
export async function fetchBooks(): Promise<Book[]> {
const data = await fetchFromAPI<{ data: Book[] }>("/api/books?populate=*");
return data.data;
const data = await fetchFromAPI<Book>(`/api/books?populate=cover&sort[title]=asc&filters[release_datetime][$lte]=${new Date().toISOString()}`);
return data;
}
export async function fetchAnnouncements(): Promise<Announcement[]> {
const data = await fetchFromAPI<Announcement>(`/api/announcements?filters[datetime][$lte]=${new Date().toISOString()}&sort[datetime]=desc`);
return data;
}
export async function fetchChaptersRSS(): Promise<Chapter[]> {
const currentDateTime = new Date()
const yesterday = subDays(currentDateTime, 1);
const data = await fetchFromAPI<{ data: Chapter[] }>
const data = await fetchFromAPI<Chapter>
(`/api/chapters?populate=book&filters[release_datetime][$lte]=${currentDateTime.toISOString()}&filters[release_datetime][$gte]=${yesterday.toISOString()}`);
return data.data;
return data;
}
export async function fetchBookChapterLinks(bookId: string): Promise<Book> {
const currentDateTime = new Date().toISOString();
const data = await fetchFromAPI<{ data: Book }>(`/api/books/${bookId}?populate[chapters][filters][release_datetime][$lte]=${currentDateTime}`);
return data.data
const data = await fetchFromAPI<Book>(`/api/books/${bookId}?populate[chapters][filters][release_datetime][$lte]=${currentDateTime}`);
return data[0]
}
/**
@@ -69,24 +109,107 @@ export async function fetchBookChapterLinks(bookId: string): Promise<Book> {
*/
export async function fetchBookById(bookId: string): Promise<Book> {
const currentDateTime = new Date().toISOString();
const data = await fetchFromAPI<{ data: Book }>(
`/api/books/${bookId}?populate[chapters][filters][release_datetime][$lte]=${currentDateTime}&populate=cover`
);
return data.data;
const nextday = addDays(new Date(), 1).toISOString();
//[chapters][filters][release_datetime][$lte]=${currentDateTime}
const data = await fetchFromAPI<Book>(
`/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);
return data[0];
}
/**
* Fetches a specific chapter by ID.
*/
export async function fetchChapterById(chapterId: string): Promise<Chapter> {
const data = await fetchFromAPI<{ data: Chapter }>(`/api/chapters/${chapterId}?populate[book][fields]=documentId`);
return data.data;
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 bookWithAllChapters = await fetchFromAPI<Book>(`/api/books/${bookId}?populate[chapters][filters][number][$gte]=${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`);
return bookWithAllChapters[0].chapters;
}
/**
* Fetches all editors.
*/
export async function fetchEditors(): Promise<Editor[]> {
const data = await fetchFromAPI<{ data: Editor[] }>("/api/editors");
return data.data;
const data = await fetchFromAPI<Editor>("/api/editors");
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 async function fetchReleases(): Promise<{ current_chapters: Chapter[], future_chapters: Chapter[] }> {
const current_datetime = new Date()
const previous_week = subDays(current_datetime, 1);
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 chapters: Chapter[] = data;
const future_chapters = chapters.filter(chapter => new Date(chapter.release_datetime) > new Date());
const current_chapters = chapters.filter(chapter => new Date(chapter.release_datetime) <= new Date());
return { current_chapters, future_chapters }
}
export async function fetchAnnouncementById(announcementId: string): Promise<Announcement> {
const data = await fetchFromAPI<Announcement>(`/api/announcements/${announcementId}?`);
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
}

View File

@@ -61,9 +61,37 @@ export interface Book {
release_datetime: string;
chapters: Chapter[];
glossary: Glossary;
translator_note: string;
rating: number;
views: number;
readers: number;
release_rate: number;
}
export interface Announcement {
id: number;
documentId: string;
title: string;
content: string;
datetime: string;
}
export interface Release {
book_title: string;
chapter: string;
datetime: string;
url: string;
}
export interface Report {
error_type: string;
details: string;
resolve: boolean;
}
export const Ad = {
patreon: "https://patreon.com/nulltranslationgroup/membership",
}

View File

@@ -1,3 +1,7 @@
import { remark } from 'remark';
import html from 'remark-html';
import sanitizeHtml from 'sanitize-html'
export function formatDateToMonthDayYear(date: Date): string {
return date.toLocaleDateString("en-US", {
month: "long",
@@ -7,4 +11,48 @@ export function formatDateToMonthDayYear(date: Date): string {
minute: "numeric",
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
View 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*',
};