Compare commits
29 Commits
c82089bb15
...
master
Author | SHA1 | Date | |
---|---|---|---|
9f967bf9d7 | |||
b1c3d9ef84 | |||
5082135d62 | |||
01dc9c9749 | |||
b7f7fd1989 | |||
906ad8a7a1 | |||
9b186a462e | |||
4cbd28b396 | |||
96ea6c446c | |||
ffe356dd2a | |||
406963f6bf | |||
f192e51f3c | |||
ad66be5039 | |||
9c78ed022d | |||
838e9730a4 | |||
97e318ce6f | |||
554a6dff45 | |||
2316fe68e1 | |||
1d66201478 | |||
1b274884e8 | |||
879dc44284 | |||
c32020aa47 | |||
52b2301fc1 | |||
9baa792111 | |||
2e876265b4 | |||
56f871f603 | |||
3fb1c6434a | |||
89cff766bf | |||
261589b025 |
57
README.md
57
README.md
@@ -1,37 +1,44 @@
|
|||||||
## Put my baby in a docker and let it rip
|
## 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.
|
*Perhaps CMS is more useful for building websites for customers with little to no CS experience. For personal use, however, it currently feels unnecessary.*
|
||||||
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.
|
*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!
|
||||||
|
17
global.d.ts
vendored
17
global.d.ts
vendored
@@ -1,6 +1,23 @@
|
|||||||
|
import { link } from "fs";
|
||||||
|
|
||||||
declare namespace NodeJS {
|
declare namespace NodeJS {
|
||||||
interface ProcessEnv {
|
interface ProcessEnv {
|
||||||
NEXT_PUBLIC_API_URL: string;
|
NEXT_PUBLIC_API_URL: string;
|
||||||
STRAPI_API_TOKEN: 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
1267
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -16,10 +16,14 @@
|
|||||||
"@tanstack/react-query-devtools": "^5.63.0",
|
"@tanstack/react-query-devtools": "^5.63.0",
|
||||||
"axios": "^1.7.9",
|
"axios": "^1.7.9",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
|
"html-react-parser": "^5.2.2",
|
||||||
"next": "15.1.4",
|
"next": "15.1.4",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"rss": "^1.2.2"
|
"remark": "^15.0.1",
|
||||||
|
"remark-html": "^16.0.1",
|
||||||
|
"rss": "^1.2.2",
|
||||||
|
"sanitize-html": "^2.14.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3",
|
"@eslint/eslintrc": "^3",
|
||||||
@@ -27,6 +31,7 @@
|
|||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"@types/rss": "^0.0.32",
|
"@types/rss": "^0.0.32",
|
||||||
|
"@types/sanitize-html": "^2.13.0",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "15.1.4",
|
"eslint-config-next": "15.1.4",
|
||||||
"postcss": "^8",
|
"postcss": "^8",
|
||||||
|
1
public/ads.txt
Normal file
1
public/ads.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
google.com, pub-1843060382170565, DIRECT, f08c47fec0942fa0
|
3
public/robots.txt
Normal file
3
public/robots.txt
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
User-agent: *
|
||||||
|
Disallow: /books/*/chapters/
|
||||||
|
Allow: /
|
34
src/app/announcements/[announcementId]/page.tsx
Normal file
34
src/app/announcements/[announcementId]/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
68
src/app/announcements/page.tsx
Normal file
68
src/app/announcements/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
@@ -16,11 +16,12 @@ export async function GET(){
|
|||||||
feed_url: `${BASE_URL}/api/feed`,
|
feed_url: `${BASE_URL}/api/feed`,
|
||||||
language: "en",
|
language: "en",
|
||||||
});
|
});
|
||||||
|
const data_sorted = data.sort((a: Chapter, b: Chapter) => { return new Date(a.release_datetime).getTime() - new Date(b.release_datetime).getTime(); });
|
||||||
data.forEach((chapter: Chapter) => {
|
data_sorted.forEach((chapter: Chapter) => {
|
||||||
feed.item({
|
feed.item({
|
||||||
title: chapter.book?.title + " - Chapter " + chapter.number + " " + chapter.title,
|
title: chapter.book?.title + ": c" + chapter.number,
|
||||||
description: "Daily chapter release for " + chapter.book?.title,
|
description: "Daily chapter release for " + chapter.book?.title,
|
||||||
|
categories: [chapter.book?.title || "Unknown"],
|
||||||
url: `${BASE_URL}/books/${chapter.book?.documentId}/chapters/${chapter.documentId}`,
|
url: `${BASE_URL}/books/${chapter.book?.documentId}/chapters/${chapter.documentId}`,
|
||||||
date: chapter.release_datetime,
|
date: chapter.release_datetime,
|
||||||
});
|
});
|
||||||
|
39
src/app/api/reports/route.ts
Normal file
39
src/app/api/reports/route.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const API_URL = process.env.NEXT_PUBLIC_API_URL;
|
||||||
|
const API_TOKEN = process.env.STRAPI_API_TOKEN;
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
|
||||||
|
if (!API_URL || !API_TOKEN) {
|
||||||
|
return NextResponse.json({ message: 'Server configuration error' }, {status: 500});
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_URL}/api/reports`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${API_TOKEN}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return NextResponse.json({ error: data }, { status: response.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({data: data}, {status: response.status});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error handling request:', error);
|
||||||
|
NextResponse.json({message: 'Going to server error'}, {status: 500});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error handling request:', error);
|
||||||
|
return NextResponse.json({ message: 'Internal Server Error'}, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
@@ -1,7 +1,10 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import NavigationButtons from "@/components/NavigationButtons";
|
import NavigationButtons from "@/components/NavigationButtons";
|
||||||
import { Book, Chapter } from "@/lib/types";
|
import ReportButton from "@/components/ReportButton";
|
||||||
import { fetchBookById } from "@/lib/api";
|
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 type paramsType = Promise<{ bookId: string; chapterId: string }>;
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
@@ -13,13 +16,12 @@ export const metadata = {
|
|||||||
export default async function ChapterPage(props: { params: paramsType}) {
|
export default async function ChapterPage(props: { params: paramsType}) {
|
||||||
const { bookId, chapterId } = await props.params;
|
const { bookId, chapterId } = await props.params;
|
||||||
|
|
||||||
let book: Book;
|
let chapters: Chapter[];
|
||||||
try{
|
try{
|
||||||
book = await fetchBookById(bookId);
|
chapters = await fetchChapterByBookId(bookId, chapterId);
|
||||||
}
|
}
|
||||||
catch (error) {
|
catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="prose dark:prose-invert mx-auto p-6 bg-white dark:bg-gray-800 shadow-md rounded-lg mt-4">
|
<div className="prose dark:prose-invert mx-auto p-6 bg-white dark:bg-gray-800 shadow-md rounded-lg mt-4">
|
||||||
<div dangerouslySetInnerHTML={{ __html: '<center><h1> Chapter not found !</h1></center>' }}></div>
|
<div dangerouslySetInnerHTML={{ __html: '<center><h1> Chapter not found !</h1></center>' }}></div>
|
||||||
@@ -27,17 +29,17 @@ export default async function ChapterPage(props: { params: paramsType}) {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
const glossary_data = await fetchGlossaryByBookId(bookId);
|
||||||
const chapters :Chapter[] = book.chapters;
|
const english_glossary = glossary_data.english_english;
|
||||||
const sorted_chapters:Chapter[] = chapters.sort((a, b) => a.number - b.number);
|
const sorted_chapters:Chapter[] = chapters.sort((a, b) => a.number - b.number);
|
||||||
const current_chapter = sorted_chapters.find((chapter) => chapter.documentId === chapterId) || null;
|
const current_chapter = sorted_chapters.find((chapter) => chapter.documentId === chapterId) || null;
|
||||||
const next_chapter = current_chapter ? sorted_chapters.find((chapter) => chapter.number === current_chapter.number + 1)?.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)?.documentId || "" : "";
|
const prev_chapter = current_chapter ? sorted_chapters.find((chapter) => chapter.number === current_chapter.number - 1 && new Date(chapter.release_datetime).getTime() <= new Date().getTime())?.documentId || "" : "";
|
||||||
|
const chapter_content_html = current_chapter ? await markdownToHtml(current_chapter.content) : "";
|
||||||
if(current_chapter === null){
|
if(current_chapter === null){
|
||||||
return (
|
return (
|
||||||
<div className="prose dark:prose-invert mx-auto p-6 bg-white dark:bg-gray-800 shadow-md rounded-lg mt-4">
|
<div className="prose dark:prose-invert mx-auto p-6 bg-white dark:bg-gray-800 shadow-md rounded-lg mt-4">
|
||||||
<div dangerouslySetInnerHTML={{ __html: '<center><h1> Chapter not found !</h1></center>' }}></div>
|
<div dangerouslySetInnerHTML={{ __html: '<NavigationButtons bookId={bookId} documentId={chapterId} prevChapter={prev_chapter} nextChapter={next_chapter} /><center><NavigationButtons /><h1> Chapter not found !</h1></center>' }}></div>
|
||||||
<NavigationButtons bookId={bookId} documentId={chapterId} prevChapter={""} nextChapter={""} />
|
<NavigationButtons bookId={bookId} documentId={chapterId} prevChapter={""} nextChapter={""} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -46,9 +48,10 @@ export default async function ChapterPage(props: { params: paramsType}) {
|
|||||||
return (
|
return (
|
||||||
<div className="prose dark:prose-invert mx-auto p-6 bg-white dark:bg-gray-800 shadow-md rounded-lg mt-4">
|
<div className="prose dark:prose-invert mx-auto p-6 bg-white dark:bg-gray-800 shadow-md rounded-lg mt-4">
|
||||||
<NavigationButtons bookId={bookId} documentId={chapterId} prevChapter={prev_chapter} nextChapter={next_chapter} />
|
<NavigationButtons bookId={bookId} documentId={chapterId} prevChapter={prev_chapter} nextChapter={next_chapter} />
|
||||||
<div className="pt-4" dangerouslySetInnerHTML={{ __html: current_chapter.content }}></div>
|
<div className="pt-4"></div>
|
||||||
|
<ChapterRenderer content={chapter_content_html} glossary={english_glossary} />
|
||||||
<NavigationButtons bookId={bookId} documentId={chapterId} prevChapter={prev_chapter} nextChapter={next_chapter}/>
|
<NavigationButtons bookId={bookId} documentId={chapterId} prevChapter={prev_chapter} nextChapter={next_chapter}/>
|
||||||
|
<ReportButton bookId={bookId} chapterId={chapterId} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
@@ -1,14 +1,15 @@
|
|||||||
import { fetchBookById } from "@/lib/api";
|
import { fetchBookById } from "@/lib/api";
|
||||||
import { Book } from "@/lib/types";
|
import { Book, Chapter } from "@/lib/types";
|
||||||
import { formatDateToMonthDayYear } from "@/lib/utils";
|
import { encodeId, formatDateToMonthDayYear, markdownToHtml } from "@/lib/utils";
|
||||||
import ChapterDropdown from "@/components/ChapterDropdown";
|
import ChapterDropdown from "@/components/ChapterDropdown";
|
||||||
import { Ad } from "@/lib/types";
|
import { Ad } from "@/lib/types";
|
||||||
|
import { Countdown } from "@/components/Countdown";
|
||||||
|
|
||||||
export type paramsType = Promise<{ bookId: string}>;
|
export type paramsType = Promise<{ bookId: string }>;
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: 'Null Translation Group',
|
title: 'Null Translation Group',
|
||||||
description: 'Null Translatin Group book',
|
description: 'Null Translation Group book description page',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function BookPage(props: { params: paramsType }) {
|
export default async function BookPage(props: { params: paramsType }) {
|
||||||
@@ -25,10 +26,15 @@ export default async function BookPage(props: { params: paramsType }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { title, author, description, chapters, cover } = book;
|
const { title, author, description, chapters, cover, translator_note, glossary } = book;
|
||||||
|
const english_glossary = glossary?.english_english;
|
||||||
|
const translator_note_html = await markdownToHtml(translator_note);
|
||||||
|
const sorted_chapters: Chapter[] = chapters.sort((a, b) => a.number - b.number);
|
||||||
|
const current_chapters = sorted_chapters.filter(chapter => new Date(chapter.release_datetime) < new Date());
|
||||||
|
const next_chapter = sorted_chapters.find((chapter) => new Date(chapter.release_datetime) > new Date());
|
||||||
|
const next_chapters = sorted_chapters.filter(chapter => new Date(chapter.release_datetime) > new Date())
|
||||||
const cover_media = cover?.at(0);
|
const cover_media = cover?.at(0);
|
||||||
const recentChapters = 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 (
|
return (
|
||||||
<div className="max-w-6xl mx-auto py-10 px-4">
|
<div className="max-w-6xl mx-auto py-10 px-4">
|
||||||
<div className="flex flex-col items-center justify-center">
|
<div className="flex flex-col items-center justify-center">
|
||||||
@@ -37,13 +43,14 @@ export default async function BookPage(props: { params: paramsType }) {
|
|||||||
className="rounded-lg object-cover w-64 h-96"
|
className="rounded-lg object-cover w-64 h-96"
|
||||||
/>
|
/>
|
||||||
<h1 className="text-5xl pb-2 font-bold">{title}</h1>
|
<h1 className="text-5xl pb-2 font-bold">{title}</h1>
|
||||||
|
<h3 className="text-2xl text-green-600 dark:text-green-400 font-bold pb-2"> {book.release_rate} chapters/day</h3>
|
||||||
<a
|
<a
|
||||||
href={Ad.patreon}
|
href={Ad.patreon}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="ml-4 bg-yellow-500 text-white font-semibold py-2 px-4 rounded hover:bg-yellow-600 transition duration-200"
|
className="ml-4 bg-yellow-500 text-white font-semibold py-2 px-4 rounded hover:bg-yellow-600 transition duration-200"
|
||||||
>
|
>
|
||||||
Join Our Patreon for Unreleased Chapters
|
Join Our Patreon to read ahead!
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -52,8 +59,8 @@ export default async function BookPage(props: { params: paramsType }) {
|
|||||||
<br></br>
|
<br></br>
|
||||||
<strong>Translator:</strong> Null Translation Group
|
<strong>Translator:</strong> Null Translation Group
|
||||||
</p>
|
</p>
|
||||||
<p className="mb-6">{description}</p>
|
<p className="mb-6">Description: {description}</p>
|
||||||
|
<div className="mb-6" dangerouslySetInnerHTML={{ __html: translator_note_html }} />
|
||||||
<h2 className="text-3xl font-semibold mb-4">Recent Chapters</h2>
|
<h2 className="text-3xl font-semibold mb-4">Recent Chapters</h2>
|
||||||
<ul className="grid gap-6 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3">
|
<ul className="grid gap-6 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3">
|
||||||
{recentChapters.map((chapter) => (
|
{recentChapters.map((chapter) => (
|
||||||
@@ -73,10 +80,64 @@ export default async function BookPage(props: { params: paramsType }) {
|
|||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
<div className="flex items-center justify-between mb-4 pt-4">
|
<div className="flex items-center justify-between mb-4 pt-4">
|
||||||
<h2 className="text-3xl font-semibold">All Chapters</h2>
|
<h2 className="text-3xl font-semibold">Next Chapters</h2>
|
||||||
<ChapterDropdown chapters={chapters} bookId={bookId} />
|
|
||||||
</div>
|
</div>
|
||||||
{chapters.map((chapter) => (
|
{next_chapters.map((chapter) =>
|
||||||
|
(
|
||||||
|
<li key={chapter.id+"next_chapters"} className="mb-2 list-none relative">
|
||||||
|
<a
|
||||||
|
href={`/books/${bookId}/chapters/${chapter.documentId}`}
|
||||||
|
className="block bg-white dark:bg-gray-800 rounded-lg shadow p-4 hover:shadow-lg transition duration-200 relative opacity-50 pointer-events-none"
|
||||||
|
>
|
||||||
|
<h3 className="text-xl font-medium">
|
||||||
|
Chapter {chapter.number}: {chapter.title}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400 mt-2">
|
||||||
|
<strong>Release Date:</strong>{" "}
|
||||||
|
{formatDateToMonthDayYear(new Date(chapter.release_datetime))}
|
||||||
|
</p>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{/* Overlay */}
|
||||||
|
<div className="absolute inset-0 bg-gray-400 opacity-50 rounded-lg"></div>
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<p className="text-xl font-bold text-red-500 bg-gray-800 bg-opacity-80 px-4 py-2 rounded-lg shadow-lg">
|
||||||
|
<Countdown release_datetime={chapter.release_datetime} />
|
||||||
|
</p>
|
||||||
|
{chapter.number === next_chapter?.number ? (
|
||||||
|
<a
|
||||||
|
href={'/early?bookId=' + encodeId(bookId)}
|
||||||
|
className={`
|
||||||
|
bg-red-500 text-white py-2 px-4 mx-2 rounded
|
||||||
|
hover:bg-red-600
|
||||||
|
transition duration-200
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
Unlock Early AdWalled Chapter
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<a
|
||||||
|
href={Ad.patreon}
|
||||||
|
className={`
|
||||||
|
bg-yellow-500 text-white py-2 px-4 mx-2 rounded
|
||||||
|
hover:bg-yellow-600
|
||||||
|
transition duration-200
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
Join Patreon for More Early Chapters
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
<div className="flex items-center justify-between mb-4 pt-4">
|
||||||
|
<h2 className="text-3xl font-semibold">All Chapters</h2>
|
||||||
|
<ChapterDropdown chapters={current_chapters} bookId={bookId} />
|
||||||
|
</div>
|
||||||
|
{sorted_chapters.map((chapter) =>
|
||||||
|
new Date(chapter.release_datetime) < new Date() ? (
|
||||||
<li key={chapter.id} className="mb-2 list-none">
|
<li key={chapter.id} className="mb-2 list-none">
|
||||||
<a
|
<a
|
||||||
href={`/books/${bookId}/chapters/${chapter.documentId}`}
|
href={`/books/${bookId}/chapters/${chapter.documentId}`}
|
||||||
@@ -86,11 +147,70 @@ export default async function BookPage(props: { params: paramsType }) {
|
|||||||
Chapter {chapter.number}: {chapter.title}
|
Chapter {chapter.number}: {chapter.title}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-2">
|
<p className="text-sm text-gray-500 dark:text-gray-400 mt-2">
|
||||||
<strong>Release Date:</strong> {formatDateToMonthDayYear(new Date(chapter.release_datetime))}
|
<strong>Release Date:</strong>{" "}
|
||||||
|
{formatDateToMonthDayYear(new Date(chapter.release_datetime))}
|
||||||
</p>
|
</p>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
) : <li key={chapter.id} className="mb-2 list-none relative">
|
||||||
|
<a
|
||||||
|
href={`/books/${bookId}/chapters/${chapter.documentId}`}
|
||||||
|
className="block bg-white dark:bg-gray-800 rounded-lg shadow p-4 hover:shadow-lg transition duration-200 relative opacity-50 pointer-events-none"
|
||||||
|
>
|
||||||
|
<h3 className="text-xl font-medium">
|
||||||
|
Chapter {chapter.number}: {chapter.title}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400 mt-2">
|
||||||
|
<strong>Release Date:</strong>{" "}
|
||||||
|
{formatDateToMonthDayYear(new Date(chapter.release_datetime))}
|
||||||
|
</p>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{/* Overlay */}
|
||||||
|
<div className="absolute inset-0 bg-gray-400 opacity-50 rounded-lg"></div>
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<p className="text-xl font-bold text-red-500 bg-gray-800 bg-opacity-80 px-4 py-2 rounded-lg shadow-lg">
|
||||||
|
<Countdown release_datetime={chapter.release_datetime} />
|
||||||
|
</p>
|
||||||
|
{chapter.number === next_chapter?.number ? (
|
||||||
|
<a
|
||||||
|
href={'/early?bookId=' + encodeId(bookId)}
|
||||||
|
className={`
|
||||||
|
bg-red-500 text-white py-2 px-4 mx-2 rounded
|
||||||
|
hover:bg-red-600
|
||||||
|
transition duration-200
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
Unlock Early Ad-Walled Chapter
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<a
|
||||||
|
href={Ad.patreon}
|
||||||
|
className={`
|
||||||
|
bg-yellow-500 text-white py-2 px-4 mx-2 rounded
|
||||||
|
hover:bg-yellow-600
|
||||||
|
transition duration-200
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
Join Patreon for More Early Chapters
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
{glossary && (
|
||||||
|
<div className="mt-8">
|
||||||
|
<h2 className="text-3xl font-semibold">Glossary</h2>
|
||||||
|
<ul className="list-disc list-inside mt-4">
|
||||||
|
{Object.entries(english_glossary).map(([term, definition]) => (
|
||||||
|
<li key={term} className="mb-2">
|
||||||
|
<strong>{term}:</strong> {definition}
|
||||||
|
</li>
|
||||||
))}
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
58
src/app/early/[bookId]/[chapterId]/page.tsx
Normal file
58
src/app/early/[bookId]/[chapterId]/page.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import React from "react";
|
||||||
|
import NavigationButtons from "@/components/NavigationButtons";
|
||||||
|
import ReportButton from "@/components/ReportButton";
|
||||||
|
import ChapterRenderer from "@/components/ChapterContentRenderer";
|
||||||
|
import { Chapter } from "@/lib/types";
|
||||||
|
import { fetchChapterByBookId, fetchGlossaryByBookId } from "@/lib/api";
|
||||||
|
import { decodeId, markdownToHtml } from "@/lib/utils";
|
||||||
|
export type paramsType = Promise<{ bookId: string; chapterId: string }>;
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: 'Null Translation Group',
|
||||||
|
description: 'This is the chapter page default description',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Dynamic page component
|
||||||
|
export default async function ChapterPage(props: { params: paramsType}) {
|
||||||
|
let { bookId, chapterId } = await props.params;
|
||||||
|
bookId = decodeId(bookId);
|
||||||
|
chapterId = decodeId(chapterId);
|
||||||
|
let chapters: Chapter[];
|
||||||
|
try{
|
||||||
|
chapters = await fetchChapterByBookId(bookId, chapterId);
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
return (
|
||||||
|
<div className="prose dark:prose-invert mx-auto p-6 bg-white dark:bg-gray-800 shadow-md rounded-lg mt-4">
|
||||||
|
<div dangerouslySetInnerHTML={{ __html: '<center><h1> Chapter not found !</h1></center>' }}></div>
|
||||||
|
<NavigationButtons bookId={bookId} documentId={chapterId} prevChapter={""} nextChapter={""} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const glossary_data = await fetchGlossaryByBookId(bookId);
|
||||||
|
const english_glossary = glossary_data.english_english;
|
||||||
|
const sorted_chapters:Chapter[] = chapters.sort((a, b) => a.number - b.number);
|
||||||
|
const current_chapter = sorted_chapters.find((chapter) => chapter.documentId === chapterId) || null;
|
||||||
|
const next_chapter = current_chapter ? sorted_chapters.find((chapter) => chapter.number === current_chapter.number + 1 && new Date(chapter.release_datetime).getTime() <= new Date().getTime())?.documentId || "" : "";
|
||||||
|
const prev_chapter = current_chapter ? sorted_chapters.find((chapter) => chapter.number === current_chapter.number - 1 && new Date(chapter.release_datetime).getTime() <= new Date().getTime())?.documentId || "" : "";
|
||||||
|
const chapter_content_html = current_chapter ? await markdownToHtml(current_chapter.content) : "";
|
||||||
|
if(current_chapter === null){
|
||||||
|
return (
|
||||||
|
<div className="prose dark:prose-invert mx-auto p-6 bg-white dark:bg-gray-800 shadow-md rounded-lg mt-4">
|
||||||
|
<div dangerouslySetInnerHTML={{ __html: '<NavigationButtons bookId={bookId} documentId={chapterId} prevChapter={prev_chapter} nextChapter={next_chapter} /><center><NavigationButtons /><h1> Chapter not found !</h1></center>' }}></div>
|
||||||
|
<NavigationButtons bookId={bookId} documentId={chapterId} prevChapter={""} nextChapter={""} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="prose dark:prose-invert mx-auto p-6 bg-white dark:bg-gray-800 shadow-md rounded-lg mt-4">
|
||||||
|
<NavigationButtons bookId={bookId} documentId={chapterId} prevChapter={prev_chapter} nextChapter={next_chapter} />
|
||||||
|
<div className="pt-4"></div>
|
||||||
|
<ChapterRenderer content={chapter_content_html} glossary={english_glossary} />
|
||||||
|
<NavigationButtons bookId={bookId} documentId={chapterId} prevChapter={prev_chapter} nextChapter={next_chapter}/>
|
||||||
|
<ReportButton bookId={bookId} chapterId={chapterId} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
112
src/app/early/page.tsx
Normal file
112
src/app/early/page.tsx
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import { fetchEarlyRelease } from "@/lib/api";
|
||||||
|
import { Chapter } from "@/lib/types";
|
||||||
|
import { encodeId, linkvertise } from "@/lib/utils";
|
||||||
|
import { Ad } from "@/lib/types";
|
||||||
|
export type searchParams = Promise<{ bookId: string }>;
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: 'Null Translation Group',
|
||||||
|
description: 'Null Translation Group Early Adcess Page',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function BookPage({ searchParams }: { searchParams: searchParams }) {
|
||||||
|
const bookId = (await searchParams).bookId;
|
||||||
|
let early_chapters: Chapter[] = []
|
||||||
|
try {
|
||||||
|
early_chapters = await fetchEarlyRelease();
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
console.error("Error fetching early chapters")
|
||||||
|
return (
|
||||||
|
<div className="prose dark:prose-invert mx-auto p-6 bg-white dark:bg-gray-800 shadow-md rounded-lg mt-4">
|
||||||
|
<div dangerouslySetInnerHTML={{ __html: '<center><h1> Error fetching early chapters</h1></center>' }}></div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const by_book_title = early_chapters.reduce<{ [key: string]: Chapter[] }>((acc, chapter) => {
|
||||||
|
if (chapter.book === undefined) {
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
else if (!acc[chapter.book.title!]) {
|
||||||
|
acc[chapter.book.title!] = [];
|
||||||
|
}
|
||||||
|
acc[chapter.book.title!].push(chapter);
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
, {});
|
||||||
|
for (const book of Object.keys(by_book_title)) {
|
||||||
|
by_book_title[book].sort((a, b) => a.number - b.number);
|
||||||
|
by_book_title[book] = by_book_title[book].slice(0, 1);
|
||||||
|
}
|
||||||
|
early_chapters = Array.from(Object.values(by_book_title)).flat()
|
||||||
|
early_chapters.sort((a, b) => a.book?.title.localeCompare(b.book?.title || "") || a.number - b.number);
|
||||||
|
const early_chapter_from_params = early_chapters.find((chapter) => encodeId(chapter.book?.documentId || "") === bookId) || null;
|
||||||
|
return (
|
||||||
|
<div className="max-w-6xl mx-auto py-12 px-6">
|
||||||
|
<div className="flex flex-col items-center text-center space-y-6">
|
||||||
|
<h1 className="text-4xl font-bold">Early Adcess Page</h1>
|
||||||
|
<p className="text-lg text-gray-700 dark:text-gray-300">
|
||||||
|
Get early access to chapters by completing Linkvertise and Google Offerwall tasks. Read ahead by <span className="font-bold">1 chapter !</span>
|
||||||
|
</p>
|
||||||
|
<p className="text-lg text-gray-700 dark:text-gray-300">
|
||||||
|
Tasks vary from watching an ad video to completing surveys and more.
|
||||||
|
</p>
|
||||||
|
<p className="text-lg text-red-700 dark:text-red-300">
|
||||||
|
This is AdHell, and its meant to be that way. If you hate it support us on Patreon instead.
|
||||||
|
</p>
|
||||||
|
<p className="text-lg text-gray-700 dark:text-gray-300">
|
||||||
|
Want to read even further? Join our Patreon to unlock even more chapters!
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href={Ad.patreon}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="bg-yellow-500 text-white font-semibold py-3 px-6 rounded-lg shadow-md hover:bg-yellow-600 transition duration-300"
|
||||||
|
>
|
||||||
|
Join Our Patreon – Read More Ahead!
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{early_chapter_from_params && <div className="mt-10 mb-6 text-center">
|
||||||
|
<a
|
||||||
|
href={linkvertise(1318261,`https://nulltranslation.com/early/${encodeId(early_chapter_from_params.book?.documentId || "")}/${encodeId(early_chapter_from_params.documentId)}`)}
|
||||||
|
className="block bg-white dark:bg-gray-800 rounded-xl shadow-lg p-6 hover:shadow-xl transition duration-300"
|
||||||
|
>
|
||||||
|
<h3 className="text-xl font-semibold text-gray-900 dark:text-white truncate">
|
||||||
|
{early_chapter_from_params.book?.title}
|
||||||
|
</h3>
|
||||||
|
<h4 className="text-lg font-medium text-gray-700 dark:text-gray-300 truncate">
|
||||||
|
Chapter {early_chapter_from_params.number}: {early_chapter_from_params.title}
|
||||||
|
</h4>
|
||||||
|
<p className="text-md text-red-600 dark:text-red-400 mt-3 font-semibold">
|
||||||
|
Click to Unlock Chapter
|
||||||
|
</p>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
{/* Chapters Grid */}
|
||||||
|
<ul className="grid gap-6 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3 mt-10">
|
||||||
|
{early_chapters.map((chapter) => (
|
||||||
|
<li key={chapter.id}>
|
||||||
|
<a
|
||||||
|
href={linkvertise(1318261,`https://nulltranslation.com/early/${encodeId(chapter.book?.documentId || "")}/${encodeId(chapter.documentId)}`)}
|
||||||
|
className="block bg-white dark:bg-gray-800 rounded-xl shadow-lg p-6 hover:shadow-xl transition duration-300"
|
||||||
|
>
|
||||||
|
<h3 className="text-xl font-semibold text-gray-900 dark:text-white truncate">
|
||||||
|
{chapter.book?.title}
|
||||||
|
</h3>
|
||||||
|
<h4 className="text-lg font-medium text-gray-700 dark:text-gray-300 truncate">
|
||||||
|
Chapter {chapter.number}: {chapter.title}
|
||||||
|
</h4>
|
||||||
|
<p className="text-md text-red-600 dark:text-red-400 mt-3 font-semibold">
|
||||||
|
Click to Unlock Chapter
|
||||||
|
</p>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
}
|
@@ -2,9 +2,11 @@
|
|||||||
|
|
||||||
import "tailwindcss/tailwind.css";
|
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 NightModeToggle from "@/components/NightModeToggle";
|
||||||
import Navbar from "@/components/NavigationBar";
|
import Navbar from "@/components/NavigationBar";
|
||||||
|
import KofiWidget from "@/components/KofiWidget";
|
||||||
|
|
||||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
const [isDarkMode, setIsDarkMode] = useState(false);
|
const [isDarkMode, setIsDarkMode] = useState(false);
|
||||||
@@ -31,6 +33,16 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
|||||||
document.documentElement.classList.toggle("dark", !isDarkMode);
|
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 (
|
return (
|
||||||
<html lang="en" className={isDarkMode ? "dark" : ""} suppressHydrationWarning>
|
<html lang="en" className={isDarkMode ? "dark" : ""} suppressHydrationWarning>
|
||||||
<head>
|
<head>
|
||||||
@@ -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>
|
</head>
|
||||||
<body className="bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-gray-100 min-h-screen">
|
<body className="bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-gray-100 min-h-screen">
|
||||||
|
<KofiWidget />
|
||||||
<Navbar />
|
<Navbar />
|
||||||
|
<Suspense fallback={<Default_Fallback />}>
|
||||||
<main className="relative">{children}</main>
|
<main className="relative">{children}</main>
|
||||||
|
</Suspense>
|
||||||
<div className="fixed bottom-4 right-4">
|
<div className="fixed bottom-4 right-4">
|
||||||
<NightModeToggle isDarkMode={isDarkMode} toggleDarkMode={toggleDarkMode} />
|
<NightModeToggle isDarkMode={isDarkMode} toggleDarkMode={toggleDarkMode} />
|
||||||
</div>
|
</div>
|
||||||
|
@@ -9,7 +9,6 @@ export const metadata = {
|
|||||||
|
|
||||||
export default async function HomePage() {
|
export default async function HomePage() {
|
||||||
let books: Book[] = [];
|
let books: Book[] = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
books = await fetchBooks();
|
books = await fetchBooks();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
141
src/app/releases/page.tsx
Normal file
141
src/app/releases/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
|
||||||
|
}
|
55
src/components/ChapterContentRenderer.tsx
Normal file
55
src/components/ChapterContentRenderer.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import React from "react";
|
||||||
|
import GlossaryPopup from "./GlossaryPopup";
|
||||||
|
import parse, { DOMNode, Element } from "html-react-parser";
|
||||||
|
|
||||||
|
interface ChapterRendererProps {
|
||||||
|
content: string;
|
||||||
|
glossary: JSON;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChapterRenderer: React.FC<ChapterRendererProps> = ({ content, glossary }) => {
|
||||||
|
// Split content into lines
|
||||||
|
const content_lines = content.split("\n");
|
||||||
|
|
||||||
|
// Process each line for glossary replacement
|
||||||
|
const processed_lines = content_lines.map((line) => {
|
||||||
|
// Replace glossary terms in the line
|
||||||
|
Object.entries(glossary).forEach(([term, definition]) => {
|
||||||
|
const termRegex = new RegExp(`\\b${term}\\b`, "gi"); // Match whole word (case-insensitive)
|
||||||
|
line = line.replace(termRegex, `<glossarypopup term="${term}" definition="${definition}"></glossarypopup>`);
|
||||||
|
});
|
||||||
|
|
||||||
|
line = line.replace(/<p>/g, "<div>").replace(/<\/p>/g, "</div>");
|
||||||
|
|
||||||
|
return line;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{processed_lines.map((line, index) => {
|
||||||
|
if (!line.includes("<glossarypopup")) {
|
||||||
|
return <div key={index} className="mb-4">{parse(line)}</div>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div key={index} className="mb-4">
|
||||||
|
{parse(line, {
|
||||||
|
replace: (domNode: DOMNode) => {
|
||||||
|
if (domNode instanceof Element && domNode.tagName === "glossarypopup") {
|
||||||
|
return (
|
||||||
|
<GlossaryPopup
|
||||||
|
term={domNode.attribs.term}
|
||||||
|
definition={domNode.attribs.definition}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return domNode;
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ChapterRenderer;
|
35
src/components/Countdown.tsx
Normal file
35
src/components/Countdown.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {useState, useEffect} from 'react';
|
||||||
|
|
||||||
|
export function Countdown({release_datetime}: {release_datetime: string}) {
|
||||||
|
const calculateTimeLeft = () => {
|
||||||
|
const now = new Date().getTime();
|
||||||
|
const releaseTime = new Date(release_datetime).getTime();
|
||||||
|
const difference = releaseTime - now;
|
||||||
|
|
||||||
|
if (difference <= 0) {
|
||||||
|
return "Released!";
|
||||||
|
}
|
||||||
|
|
||||||
|
const hours = Math.floor((difference / (1000 * 60 * 60)) % 24);
|
||||||
|
const minutes = Math.floor((difference / (1000 * 60)) % 60);
|
||||||
|
const seconds = Math.floor((difference / 1000) % 60);
|
||||||
|
|
||||||
|
return `${hours}h ${minutes}m ${seconds}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [timeLeft, setTimeLeft] = useState<string>(calculateTimeLeft());
|
||||||
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setTimeLeft(calculateTimeLeft());
|
||||||
|
}, 1000);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span>{timeLeft}</span>
|
||||||
|
);
|
||||||
|
}
|
46
src/components/GlossaryPopup.tsx
Normal file
46
src/components/GlossaryPopup.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
"use client"
|
||||||
|
import React, { useState, useEffect, useRef } from "react";
|
||||||
|
interface GlossaryPopupProps {
|
||||||
|
term: string;
|
||||||
|
definition: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const GlossaryPopup: React.FC<GlossaryPopupProps> = ({ term, definition }) => {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const popupRef = useRef<HTMLDivElement>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (popupRef.current && !popupRef.current.contains(event.target as Node)) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
|
|
||||||
|
return() => {
|
||||||
|
document.removeEventListener("mousedown", handleClickOutside);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
return (
|
||||||
|
<span className="relative">
|
||||||
|
{term}
|
||||||
|
<sup
|
||||||
|
className="text-blue-600 cursor-pointer"
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
>
|
||||||
|
[-]
|
||||||
|
</sup>
|
||||||
|
{isOpen && (
|
||||||
|
<div
|
||||||
|
ref={popupRef}
|
||||||
|
className="absolute left-0 top-full mt-1 w-64 bg-white border border-gray-300 rounded shadow-lg p-1 z-50"
|
||||||
|
>
|
||||||
|
<p className="text-gray-900 font-bold px-2 m-2">{term}</p>
|
||||||
|
<p className="text-gray-700 text-sm px-2 m-2">{definition}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default GlossaryPopup;
|
||||||
|
|
31
src/components/KofiWidget.tsx
Normal file
31
src/components/KofiWidget.tsx
Normal 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;
|
@@ -1,7 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
|
||||||
import { Ad } from "@/lib/types";
|
import { Ad } from "@/lib/types";
|
||||||
export default function Navbar() {
|
export default function Navbar() {
|
||||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||||
@@ -17,19 +16,29 @@ export default function Navbar() {
|
|||||||
alt="Logo"
|
alt="Logo"
|
||||||
className="h-8 w-8"
|
className="h-8 w-8"
|
||||||
/>
|
/>
|
||||||
<Link href="/" className="text-2xl font-bold">
|
<a href="https://nulltranslation.com" className="text-2xl font-bold">
|
||||||
Null Translation Group
|
Null Translation Group
|
||||||
</Link>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Navigation Links */}
|
{/* Navigation Links */}
|
||||||
|
|
||||||
<nav className="hidden md:flex space-x-6">
|
<nav className="hidden md:flex space-x-6">
|
||||||
<Link href="/" className="hover:text-gray-400">
|
<a href={"/announcements"} className="hover:text-gray-400">
|
||||||
Book List
|
Announcements
|
||||||
</Link>
|
</a>
|
||||||
<Link href={Ad.patreon} className="hover:text-gray-400">
|
<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
|
Patreon
|
||||||
</Link>
|
</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Mobile Menu Button */}
|
{/* Mobile Menu Button */}
|
||||||
@@ -57,12 +66,21 @@ export default function Navbar() {
|
|||||||
{/* Mobile Menu */}
|
{/* Mobile Menu */}
|
||||||
{isMenuOpen && (
|
{isMenuOpen && (
|
||||||
<div className="md:hidden bg-gray-700 px-6 pb-4">
|
<div className="md:hidden bg-gray-700 px-6 pb-4">
|
||||||
<Link href="/" 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
|
Book List
|
||||||
</Link>
|
</a>
|
||||||
<Link href={Ad.patreon} className="block py-2 hover:text-gray-400">
|
<a href={"/early"} className="hover:text-gray-400">
|
||||||
|
Early Access
|
||||||
|
</a>
|
||||||
|
<a href={Ad.patreon} className="hover:text-yellow-400 text-yellow-200 font-semibold">
|
||||||
Patreon
|
Patreon
|
||||||
</Link>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Ad } from "@/lib/types";
|
import Link from "next/link";
|
||||||
|
import { encodeId } from "@/lib/utils";
|
||||||
|
|
||||||
interface NavigationButtonsProps {
|
interface NavigationButtonsProps {
|
||||||
bookId: string;
|
bookId: string;
|
||||||
@@ -21,7 +22,6 @@ const NavigationButtons: React.FC<NavigationButtonsProps> = ({ bookId, prevChapt
|
|||||||
router.push(`/books/${bookId}`);
|
router.push(`/books/${bookId}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-2 flex justify-between">
|
<div className="mt-2 flex justify-between">
|
||||||
<button
|
<button
|
||||||
@@ -53,18 +53,16 @@ const NavigationButtons: React.FC<NavigationButtonsProps> = ({ bookId, prevChapt
|
|||||||
Next Chapter
|
Next Chapter
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<a
|
<Link
|
||||||
href={Ad.patreon}
|
href={'/early?bookId=' + encodeId(bookId)}
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className={`
|
className={`
|
||||||
bg-yellow-500 text-white py-2 px-4 rounded
|
bg-yellow-500 text-white py-2 px-4 rounded
|
||||||
hover:bg-yellow-600
|
hover:bg-yellow-600
|
||||||
transition duration-200
|
transition duration-200
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
Unreleased Chapters
|
Unlock Early Chapter
|
||||||
</a>
|
</Link>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
125
src/components/ReportButton.tsx
Normal file
125
src/components/ReportButton.tsx
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
"use client"
|
||||||
|
import React, { useState, useRef } from "react";
|
||||||
|
import { createReport
|
||||||
|
|
||||||
|
} from "@/lib/api";
|
||||||
|
interface ReportButtonProps {
|
||||||
|
bookId: string;
|
||||||
|
chapterId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ReportButton: React.FC<ReportButtonProps> = ({ bookId, chapterId }) => {
|
||||||
|
const modalRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
const [errorType, setErrorType] = useState('')
|
||||||
|
const [details, setDetails] = useState('')
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
|
const handleSubmitReport = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
// Implement report submission here
|
||||||
|
event.preventDefault()
|
||||||
|
const response = await createReport(errorType,details,bookId,chapterId,email)
|
||||||
|
//Linting be linting
|
||||||
|
if (response.status === 201){
|
||||||
|
alert('Report submitted successfully')
|
||||||
|
}
|
||||||
|
else{
|
||||||
|
alert('Failed to submit report')
|
||||||
|
}
|
||||||
|
setErrorType('')
|
||||||
|
setDetails('')
|
||||||
|
setEmail('')
|
||||||
|
setIsOpen(false)
|
||||||
|
}
|
||||||
|
const handleExit = (event: React.MouseEvent) => {
|
||||||
|
setErrorType('')
|
||||||
|
setDetails('')
|
||||||
|
setEmail('')
|
||||||
|
if (modalRef.current && !modalRef.current.contains(event.target as Node)) {
|
||||||
|
setIsOpen(false)
|
||||||
|
}
|
||||||
|
event.stopPropagation()
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center pt-4"
|
||||||
|
onClick={handleExit}>
|
||||||
|
<button onClick={() => setIsOpen(true)} className="px-4 py-2 bg-red-600 text-white font-semibold rounded-lg shadow-md hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-400 focus:ring-offset-2"> Report </button>
|
||||||
|
{isOpen && (<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50">
|
||||||
|
<div
|
||||||
|
className="dark:bg-gray-800 bg-white w-11/12 md:w-1/2 lg:w-1/3 p-6 rounded-lg shadow-lg relative"
|
||||||
|
ref={modalRef}
|
||||||
|
onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="flex justify-between items-center border-b pb-3">
|
||||||
|
<h3 className="text-lg font-semibold ">Report Chapter</h3>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4">
|
||||||
|
<form className="p-6 rounded-lg max-w-md mx-auto space-y-4"
|
||||||
|
onSubmit={handleSubmitReport}>
|
||||||
|
<div>
|
||||||
|
<label className="block text-md font-semibold mb-1" htmlFor="report_email">
|
||||||
|
Email :
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="report_email"
|
||||||
|
className="block w-full border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 rounded-lg p-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
type="email"
|
||||||
|
placeholder="If you want credit/update"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}>
|
||||||
|
</input>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-md font-semibold mb-1 dark:text-gray-300" htmlFor="error-type">
|
||||||
|
Select Error Type :
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="error-type"
|
||||||
|
className="block w-full border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 rounded-lg p-2 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
value={errorType}
|
||||||
|
onChange={(e)=> setErrorType(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">Select your Type</option>
|
||||||
|
<option value="Spelling Error">Spelling Error</option>
|
||||||
|
<option value="Pronoun Error">Pronoun Error</option>
|
||||||
|
<option value="Formatting Error">Formatting Error</option>
|
||||||
|
<option value="Missing Content">Missing Content</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-md font-semibold mb-1" htmlFor="details">
|
||||||
|
Additional Details :
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="details"
|
||||||
|
placeholder="Provide additional details"
|
||||||
|
value={details}
|
||||||
|
onChange={(e) => setDetails(e.target.value)}
|
||||||
|
className="block w-full h-24 border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 rounded-lg p-2 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 resize-none overflow-y-auto"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="px-4 py-2 bg-green-500 text-white font-medium rounded-lg hover:bg-green-600 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-offset-2"
|
||||||
|
>
|
||||||
|
Submit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleExit}
|
||||||
|
className="px-4 py-2 dark:bg-gray-500 bg-gray-200 rounded-lg hover:bg-gray-300"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ReportButton;
|
32
src/hooks/useRefreshAds.ts
Normal file
32
src/hooks/useRefreshAds.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
|
||||||
|
const useRefreshAds = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
console.log("Initializing Ad Refresh...");
|
||||||
|
window.googletag = window.googletag || { cmd: [] };
|
||||||
|
|
||||||
|
router.events.on('routeChangeComplete', () => {
|
||||||
|
console.log("Refreshing Ads...");
|
||||||
|
window.googletag.cmd.push(() => {
|
||||||
|
if (window.googletag?.pubads) {
|
||||||
|
window.googletag.pubads().refresh();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
router.events.off('routeChangeComplete', () => {});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useRefreshAds;
|
175
src/lib/api.tsx
175
src/lib/api.tsx
@@ -1,43 +1,78 @@
|
|||||||
import { subDays } from "date-fns";
|
import { addDays, subDays } from "date-fns";
|
||||||
import { Book, Chapter, Editor } from "./types";
|
import { Book, Chapter, Editor, Announcement, Glossary } from "./types";
|
||||||
|
|
||||||
const API_URL = process.env.NEXT_PUBLIC_API_URL as string;
|
const API_URL = process.env.NEXT_PUBLIC_API_URL as string;
|
||||||
const API_TOKEN = process.env.STRAPI_API_TOKEN as string;
|
const API_TOKEN = process.env.STRAPI_API_TOKEN as string;
|
||||||
|
|
||||||
/**
|
export async function createFromAPI<T>(
|
||||||
* Centralized API fetch function with TypeScript support.
|
|
||||||
* Handles GET, POST, PUT, DELETE methods and includes headers by default.
|
|
||||||
*/
|
|
||||||
export async function fetchFromAPI<T>(
|
|
||||||
endpoint: string,
|
endpoint: string,
|
||||||
options: RequestInit = {}
|
payload: string,
|
||||||
|
options: RequestInit = {},
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const url = `${API_URL}${endpoint}`;
|
|
||||||
|
|
||||||
const headers: HeadersInit = {
|
const headers: HeadersInit = {
|
||||||
Authorization: `Bearer ${API_TOKEN}`,
|
Authorization: `Bearer ${API_TOKEN}`,
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
const config: RequestInit = {
|
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,
|
headers,
|
||||||
...options,
|
...options,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
let results: T[] = [];
|
||||||
|
let currentPage = 1;
|
||||||
|
let totalPages = 1;
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url, config);
|
do {
|
||||||
|
const url = `${API_URL}${endpoint}&pagination[page]=${currentPage}&pagination[pageSize]=25`;
|
||||||
|
const response = await fetch(url, { ...config, next: { revalidate: 30 } });
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorData = await response.json();
|
const errorData = await response.json();
|
||||||
console.error(`Error fetching ${url}:`, errorData);
|
console.error(`Error fetching ${url}:`, errorData);
|
||||||
throw new Error(errorData.message || `API fetch error (status: ${response.status})`);
|
throw new Error(errorData.message || `API fetch error (status: ${response.status})`);
|
||||||
}
|
}
|
||||||
const responseJson = await response.json();
|
const responseJson = await response.json();
|
||||||
return responseJson;
|
results = results.concat(responseJson.data);
|
||||||
|
totalPages = responseJson.meta?.pagination?.pageCount;
|
||||||
|
currentPage += 1;
|
||||||
|
} while (currentPage <= totalPages)
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Fetch error:", error);
|
console.error("Fetch error:", error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -45,22 +80,27 @@ export async function fetchFromAPI<T>(
|
|||||||
* Populates optional fields like Chapters or Editors based on requirements.
|
* Populates optional fields like Chapters or Editors based on requirements.
|
||||||
*/
|
*/
|
||||||
export async function fetchBooks(): Promise<Book[]> {
|
export async function fetchBooks(): Promise<Book[]> {
|
||||||
const data = await fetchFromAPI<{ data: Book[] }>("/api/books?populate=*");
|
const data = await fetchFromAPI<Book>(`/api/books?populate=cover&sort[title]=asc&filters[release_datetime][$lte]=${new Date().toISOString()}`);
|
||||||
return data.data;
|
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[]> {
|
export async function fetchChaptersRSS(): Promise<Chapter[]> {
|
||||||
const currentDateTime = new Date()
|
const currentDateTime = new Date()
|
||||||
const yesterday = subDays(currentDateTime, 1);
|
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()}`);
|
(`/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> {
|
export async function fetchBookChapterLinks(bookId: string): Promise<Book> {
|
||||||
const currentDateTime = new Date().toISOString();
|
const currentDateTime = new Date().toISOString();
|
||||||
const data = await fetchFromAPI<{ data: Book }>(`/api/books/${bookId}?populate[chapters][filters][release_datetime][$lte]=${currentDateTime}`);
|
const data = await fetchFromAPI<Book>(`/api/books/${bookId}?populate[chapters][filters][release_datetime][$lte]=${currentDateTime}`);
|
||||||
return data.data
|
return data[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -69,24 +109,107 @@ export async function fetchBookChapterLinks(bookId: string): Promise<Book> {
|
|||||||
*/
|
*/
|
||||||
export async function fetchBookById(bookId: string): Promise<Book> {
|
export async function fetchBookById(bookId: string): Promise<Book> {
|
||||||
const currentDateTime = new Date().toISOString();
|
const currentDateTime = new Date().toISOString();
|
||||||
const data = await fetchFromAPI<{ data: Book }>(
|
const nextday = addDays(new Date(), 1).toISOString();
|
||||||
`/api/books/${bookId}?populate[chapters][filters][release_datetime][$lte]=${currentDateTime}&populate=cover`
|
//[chapters][filters][release_datetime][$lte]=${currentDateTime}
|
||||||
|
const data = await fetchFromAPI<Book>(
|
||||||
|
`/api/books/${bookId}?populate[chapters][filters][release_datetime][$lte]=${nextday}&populate=cover`
|
||||||
);
|
);
|
||||||
return data.data;
|
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.
|
* Fetches a specific chapter by ID.
|
||||||
*/
|
*/
|
||||||
export async function fetchChapterById(chapterId: string): Promise<Chapter> {
|
export async function fetchChapterByBookId(bookId: string, chapterId: string): Promise<Chapter[]> {
|
||||||
const data = await fetchFromAPI<{ data: Chapter }>(`/api/chapters/${chapterId}?populate[book][fields]=documentId`);
|
const currentChapter = await fetchFromAPI<Chapter>(`/api/chapters/${chapterId}?populate[book][fields][0]=title&filters[book][documentId]=${bookId}`);
|
||||||
return data.data;
|
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.
|
* Fetches all editors.
|
||||||
*/
|
*/
|
||||||
export async function fetchEditors(): Promise<Editor[]> {
|
export async function fetchEditors(): Promise<Editor[]> {
|
||||||
const data = await fetchFromAPI<{ data: Editor[] }>("/api/editors");
|
const data = await fetchFromAPI<Editor>("/api/editors");
|
||||||
return data.data;
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchEarlyRelease(): Promise<Chapter[]> {
|
||||||
|
const current_datetime = new Date()
|
||||||
|
const data = await fetchFromAPI<Chapter>(`/api/chapters/?populate[book][fields][0]=title&fields[0]=number&fields[1]=title&fields[2]=release_datetime&filters[release_datetime][$gte]=${current_datetime.toISOString()}&filters[release_datetime][$lte]=${addDays(current_datetime, 1).toISOString()}`);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ChapterRelease = { current_chapters: Chapter[], future_chapters: Chapter[] }
|
||||||
|
export 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
|
||||||
}
|
}
|
@@ -61,9 +61,37 @@ export interface Book {
|
|||||||
release_datetime: string;
|
release_datetime: string;
|
||||||
chapters: Chapter[];
|
chapters: Chapter[];
|
||||||
glossary: Glossary;
|
glossary: Glossary;
|
||||||
|
translator_note: string;
|
||||||
|
rating: number;
|
||||||
|
views: number;
|
||||||
|
readers: number;
|
||||||
|
release_rate: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Announcement {
|
||||||
|
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 = {
|
export const Ad = {
|
||||||
patreon: "https://patreon.com/nulltranslationgroup/membership",
|
patreon: "https://patreon.com/nulltranslationgroup/membership",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@@ -1,3 +1,7 @@
|
|||||||
|
import { remark } from 'remark';
|
||||||
|
import html from 'remark-html';
|
||||||
|
import sanitizeHtml from 'sanitize-html'
|
||||||
|
|
||||||
export function formatDateToMonthDayYear(date: Date): string {
|
export function formatDateToMonthDayYear(date: Date): string {
|
||||||
return date.toLocaleDateString("en-US", {
|
return date.toLocaleDateString("en-US", {
|
||||||
month: "long",
|
month: "long",
|
||||||
@@ -8,3 +12,47 @@ export function formatDateToMonthDayYear(date: Date): string {
|
|||||||
timeZoneName: "short",
|
timeZoneName: "short",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function markdownToHtml(markdown: string): Promise<string> {
|
||||||
|
|
||||||
|
const result = await remark().use(html).process(markdown)
|
||||||
|
const sanitizedHtml = sanitizeHtml(result.toString(), {
|
||||||
|
allowedTags: sanitizeHtml.defaults.allowedTags.concat(['img', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'code', 'pre']),
|
||||||
|
allowedAttributes: {
|
||||||
|
...sanitizeHtml.defaults.allowedAttributes,
|
||||||
|
img: ['src', 'alt'],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if(sanitizedHtml == ""){
|
||||||
|
//Already html
|
||||||
|
return markdown
|
||||||
|
}
|
||||||
|
return sanitizedHtml
|
||||||
|
}
|
||||||
|
|
||||||
|
export function encodeId(documentId: string): string {
|
||||||
|
const salt = "salty_aff"
|
||||||
|
return Buffer.from(documentId + salt).toString('base64')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeId(encodedId: string): string {
|
||||||
|
const salt = "salty_aff"
|
||||||
|
return Buffer.from(encodedId, 'base64').toString().replace(salt, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function btoa(str: string): string {
|
||||||
|
let buffer: Buffer;
|
||||||
|
|
||||||
|
if (Buffer.isBuffer(str)) {
|
||||||
|
buffer = str;
|
||||||
|
} else {
|
||||||
|
buffer = Buffer.from(str, "binary");
|
||||||
|
}
|
||||||
|
return buffer.toString("base64");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function linkvertise(userid: number, link: string): string {
|
||||||
|
const base_url = `https://link-to.net/${userid}/${Math.floor(Math.random() * 1000)}/dynamic`;
|
||||||
|
const href = `${base_url}?r=${btoa(encodeURI(link))}`;
|
||||||
|
return href;
|
||||||
|
}
|
31
src/middleware.ts
Normal file
31
src/middleware.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
const rateLimitCache = new Map<string, { timestamp: number; count: number }>();
|
||||||
|
const RATE_LIMIT_WINDOW_MS = 60000;
|
||||||
|
const RATE_LIMIT_MAX_REQUESTS = 5;
|
||||||
|
|
||||||
|
export function middleware(request: Request) {
|
||||||
|
const ip = request.headers.get('x-forwarded-for') || '127.0.0.1';
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
const userRateLimit = rateLimitCache.get(ip) || { count: 0, timestamp: now };
|
||||||
|
|
||||||
|
if (now - userRateLimit.timestamp > RATE_LIMIT_WINDOW_MS) {
|
||||||
|
rateLimitCache.set(ip, { count: 1, timestamp: now });
|
||||||
|
} else if (userRateLimit.count >= RATE_LIMIT_MAX_REQUESTS) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: 'Rate limit exceeded. Please wait a moment.' },
|
||||||
|
{ status: 429 }
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
userRateLimit.count += 1;
|
||||||
|
rateLimitCache.set(ip, userRateLimit);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply middleware only to API routes
|
||||||
|
export const config = {
|
||||||
|
matcher: '/api/:path*',
|
||||||
|
};
|
Reference in New Issue
Block a user