Compare commits

...

14 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
20 changed files with 803 additions and 139 deletions

10
global.d.ts vendored
View File

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

1
public/ads.txt Normal file
View File

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

View File

@@ -16,8 +16,8 @@ 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 + ": c" + chapter.number,
description: "Daily chapter release for " + chapter.book?.title,

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,5 +1,6 @@
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";
@@ -50,6 +51,7 @@ export default async function ChapterPage(props: { params: paramsType}) {
<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,10 +1,11 @@
import { fetchBookById } from "@/lib/api";
import { Book, Chapter } from "@/lib/types";
import { formatDateToMonthDayYear, markdownToHtml } from "@/lib/utils";
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',
@@ -25,63 +26,45 @@ export default async function BookPage(props: { params: paramsType }) {
);
}
const {title, author, description, chapters, cover, translator_note, glossary} = 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 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 (
<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 to read ahead!
</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: {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"
>
<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={sorted_chapters} bookId={bookId} />
</div>
{sorted_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"
@@ -95,19 +78,140 @@ export default async function BookPage(props: { params: paramsType }) {
</a>
</li>
))}
{glossary && (
<div className="mt-8">
<h2 className="text-3xl font-semibold">Glossary</h2>
<ul className="list-disc list-inside mt-4">
</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>
);
</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

@@ -35,18 +35,17 @@ export default function RootLayout({ children }: { children: React.ReactNode })
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>
<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: `
@@ -62,22 +61,33 @@ export default function RootLayout({ children }: { children: React.ReactNode })
`,
}}
/>
<Script
async
src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-1843060382170565"
<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"
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>
<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 />

View File

@@ -1,7 +1,7 @@
import { formatDateToMonthDayYear } from "@/lib/utils";
import { Chapter, Ad } from "@/lib/types";
import { fetchReleases } from "@/lib/api";
import Link from "next/link";
import { Countdown } from "@/components/Countdown";
export const metadata = {
@@ -43,14 +43,14 @@ export default async function ReleasePage() {
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>
<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 */}
@@ -63,17 +63,17 @@ export default async function ReleasePage() {
.sort(([titleA], [titleB]) => titleA.localeCompare(titleB))
.map(([bookTitle, chapters]) => (
<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">
{bookTitle}
</Link>
</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"
>
<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">
Chapter {chapter.number}: {chapter.title}
@@ -84,7 +84,7 @@ export default async function ReleasePage() {
new Date(chapter.release_datetime)
)}
</p>
</Link>
</a>
</li>
))}
</ul>
@@ -107,10 +107,10 @@ export default async function ReleasePage() {
.sort(([titleA], [titleB]) => titleA.localeCompare(titleB))
.map(([bookTitle, chapters]) => (
<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">
{bookTitle}
</Link>
</a>
<ul className="space-y-4">
{chapters.map((chapter) => (
<li
@@ -120,12 +120,9 @@ export default async function ReleasePage() {
<h4 className="text-md font-medium">
Chapter {chapter.number}: {chapter.title}
</h4>
<p className="text-sm">
Release date:{" "}
{formatDateToMonthDayYear(
new Date(chapter.release_datetime)
)}
</p>
<h3 className="text-md font-bold text-red-600 dark:text-red-400">
<Countdown release_datetime={chapter.release_datetime} />
</h3>
</li>
))}
</ul>

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

@@ -11,7 +11,7 @@ const KofiWidget = () => {
if (typeof window !== 'undefined' && window.kofiWidgetOverlay) {
window.kofiWidgetOverlay.draw('nulltranslationgroup', {
'type': 'floating-chat',
'floating-chat.donateButton.text': 'Support me',
'floating-chat.donateButton.text': '',
'floating-chat.donateButton.background-color': '#00b9fe',
'floating-chat.donateButton.text-color': '#fff',
});

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,28 +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={"/announcements"} className="hover:text-gray-400">
<a href={"/announcements"} className="hover:text-gray-400">
Announcements
</Link>
<Link href="/" className="hover:text-gray-400">
</a>
<a href="https://nulltranslation.com" className="hover:text-gray-400">
Books
</Link>
<Link href={"/releases"} className="hover:text-gray-400">
</a>
<a href={"/releases"} className="hover:text-gray-400">
Releases
</Link>
<Link href={Ad.patreon} className="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>
</nav>
{/* Mobile Menu Button */}
@@ -66,18 +66,21 @@ export default function Navbar() {
{/* Mobile Menu */}
{isMenuOpen && (
<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
</Link>
<Link href={"/releases"} className="block py-2 hover:text-gray-400">
</a>
<a href={"/releases"} className="block py-2 hover:text-gray-400">
Release
</Link>
<Link href="/" className="block py-2 hover:text-gray-400">
</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

@@ -4,6 +4,33 @@ 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;
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>(
endpoint: string,
options: RequestInit = {}
@@ -13,8 +40,9 @@ export async function fetchFromAPI<T>(
"Content-Type": "application/json",
};
const config: RequestInit = {
method: "GET", // Default method is GET
method: "GET",
headers,
...options,
};
@@ -52,12 +80,12 @@ 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<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;
}
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;
}
@@ -81,10 +109,21 @@ export async function fetchBookChapterLinks(bookId: string): Promise<Book> {
*/
export async function fetchBookById(bookId: string): Promise<Book> {
const currentDateTime = new Date().toISOString();
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]=${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;
@@ -112,11 +151,17 @@ export async function fetchEditors(): Promise<Editor[]> {
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, 3);
const next_week = addDays(current_datetime, 3);
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;
@@ -133,4 +178,38 @@ export async function fetchAnnouncementById(announcementId: string): Promise<Ann
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

@@ -65,6 +65,7 @@ export interface Book {
rating: number;
views: number;
readers: number;
release_rate: number;
}
export interface Announcement {

View File

@@ -28,4 +28,31 @@ export async function markdownToHtml(markdown: string): Promise<string> {
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*',
};