Compare commits

..

No commits in common. "97e318ce6f700b30c2a8c4beefced63d92fcb5ed" and "2316fe68e146d6756291e4f1160e4caf7aebdff1" have entirely different histories.

11 changed files with 68 additions and 1501 deletions

1267
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -16,14 +16,10 @@
"@tanstack/react-query-devtools": "^5.63.0",
"axios": "^1.7.9",
"date-fns": "^4.1.0",
"html-react-parser": "^5.2.2",
"next": "15.1.4",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"remark": "^15.0.1",
"remark-html": "^16.0.1",
"rss": "^1.2.2",
"sanitize-html": "^2.14.0"
"rss": "^1.2.2"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
@ -31,7 +27,6 @@
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/rss": "^0.0.32",
"@types/sanitize-html": "^2.13.0",
"eslint": "^9",
"eslint-config-next": "15.1.4",
"postcss": "^8",

View File

@ -1,6 +1,6 @@
import { fetchAnnouncementById } from "@/lib/api";
import { Announcement } from "@/lib/types";
import { formatDateToMonthDayYear, markdownToHtml } from "@/lib/utils";
import { formatDateToMonthDayYear } from "@/lib/utils";
export const metadata = {
title: 'Announcement Detail Page',
@ -14,7 +14,6 @@ export default async function AnnouncementDetailPage(props: {params: paramsType}
let announcement: Announcement;
try{
announcement = await fetchAnnouncementById(announcementId);
announcement.content = await markdownToHtml(announcement.content);
}
catch (error) {
console.error(error);

View File

@ -1,68 +1,41 @@
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",
title: 'Annoucement page',
description: 'NullTranslationGroup Announcement page',
};
export default async function AnnouncementPage() {
let announcements: Announcement[] = [];
let announcements = [];
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>
<p>Failed to load announcements.</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()
);
const sorted_announcements:Announcement[] = announcements.sort((a, b) => new Date(a.datetime).getTime() - new Date(b.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>
);
}
<div className="max-w-6xl mx-auto py-10 px-4">
{sorted_announcements.map((announcement) => (
<Link href = {`/announcements/${announcement.documentId}`} key={announcement.documentId}>
<li key={announcement.id} className="mb-2 list-none">
<div className="text-lg font-semibold">{announcement.title}</div>
<div className="text-sm text-gray-500">{formatDateToMonthDayYear(new Date(announcement.datetime))}</div>
</li>
</Link>
))}
</div>
);
}

View File

@ -1,9 +1,7 @@
import React from "react";
import NavigationButtons from "@/components/NavigationButtons";
import ChapterRenderer from "@/components/ChapterContentRenderer";
import { Chapter } from "@/lib/types";
import { fetchChapterByBookId, fetchGlossaryByBookId } from "@/lib/api";
import { markdownToHtml } from "@/lib/utils";
import {fetchChapterByBookId } from "@/lib/api";
export type paramsType = Promise<{ bookId: string; chapterId: string }>;
export const metadata = {
@ -28,17 +26,16 @@ export default async function ChapterPage(props: { params: paramsType}) {
</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>
<div dangerouslySetInnerHTML={{ __html: '<center><h1> Chapter not found !</h1></center>' }}></div>
<NavigationButtons bookId={bookId} documentId={chapterId} prevChapter={""} nextChapter={""} />
</div>
)
@ -47,8 +44,8 @@ export default async function ChapterPage(props: { params: paramsType}) {
return (
<div className="prose dark:prose-invert mx-auto p-6 bg-white dark:bg-gray-800 shadow-md rounded-lg mt-4">
<NavigationButtons bookId={bookId} documentId={chapterId} prevChapter={prev_chapter} nextChapter={next_chapter} />
<div className="pt-4"></div>
<ChapterRenderer content={chapter_content_html} glossary={english_glossary} />
<div className="pt-4" dangerouslySetInnerHTML={{ __html: current_chapter.content }}></div>
<NavigationButtons bookId={bookId} documentId={chapterId} prevChapter={prev_chapter} nextChapter={next_chapter}/>
</div>
);

View File

@ -1,6 +1,6 @@
import { fetchBookById } from "@/lib/api";
import { Book, Chapter } from "@/lib/types";
import { formatDateToMonthDayYear, markdownToHtml } from "@/lib/utils";
import { formatDateToMonthDayYear } from "@/lib/utils";
import ChapterDropdown from "@/components/ChapterDropdown";
import { Ad } from "@/lib/types";
@ -8,7 +8,7 @@ export type paramsType = Promise<{ bookId: string}>;
export const metadata = {
title: 'Null Translation Group',
description: 'Null Translation Group book description page',
description: 'Null Translatin Group book',
};
export default async function BookPage(props: { params: paramsType }) {
@ -25,9 +25,7 @@ export default async function BookPage(props: { params: paramsType }) {
);
}
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 { title, author, description, chapters, cover } = book;
const sorted_chapters:Chapter[] = chapters.sort((a, b) => a.number - b.number);
const cover_media = cover?.at(0);
@ -47,7 +45,7 @@ export default async function BookPage(props: { params: paramsType }) {
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!
Join Our Patreon for Unreleased Chapters
</a>
</div>
@ -56,8 +54,8 @@ export default async function BookPage(props: { params: paramsType }) {
<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}} />
<p className="mb-6">{description}</p>
<h2 className="text-3xl font-semibold mb-4">Recent Chapters</h2>
<ul className="grid gap-6 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3">
{recentChapters.map((chapter) => (
@ -95,18 +93,6 @@ 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">
{Object.entries(english_glossary).map(([term, definition]) => (
<li key={term} className="mb-2">
<strong>{term}:</strong> {definition}
</li>
))}
</ul>
</div>
)}
</div>
);

View File

@ -1,50 +0,0 @@
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}" />`);
});
line = line.replace(/<p>/g, "<div>").replace(/<\/p>/g, "</div>");
return line;
});
return (
<div>
{processed_lines.map((line, index) => (
<div key={index} className="mb-4">
{parse(line, {
replace: (domNode: DOMNode) => {
if (domNode instanceof Element && domNode.tagName === "glossarypopup") {
return (
<GlossaryPopup
term={domNode.attribs.term}
definition={domNode.attribs.definition}
/>
);
}
return domNode;
},
})}
</div>
))}
</div>
);
};
export default ChapterRenderer;

View File

@ -1,46 +0,0 @@
"use client"
import React, { useState, useEffect, useRef } from "react";
interface GlossaryPopupProps {
term: string;
definition: string;
}
const GlossaryPopup: React.FC<GlossaryPopupProps> = ({ term, definition }) => {
const [isOpen, setIsOpen] = useState(false);
const popupRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (popupRef.current && !popupRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return() => {
document.removeEventListener("mousedown", handleClickOutside);
}
}, []);
return (
<span className="relative">
{term}
<sup
className="text-blue-600 cursor-pointer"
onClick={() => setIsOpen(!isOpen)}
>
[-]
</sup>
{isOpen && (
<div
ref={popupRef}
className="absolute left-0 top-full mt-1 w-64 bg-white border border-gray-300 rounded shadow-lg p-1 z-50"
>
<p className="text-gray-900 font-bold px-2 m-2">{term}</p>
<p className="text-gray-700 text-sm px-2 m-2">{definition}</p>
</div>
)}
</span>
)
}
export default GlossaryPopup;

View File

@ -1,5 +1,5 @@
import { addDays, subDays } from "date-fns";
import { Book, Chapter, Editor, Announcement, Glossary } from "./types";
import { Book, Chapter, Editor, Announcement } from "./types";
const API_URL = process.env.NEXT_PUBLIC_API_URL as string;
const API_TOKEN = process.env.STRAPI_API_TOKEN as string;
@ -19,26 +19,26 @@ export async function fetchFromAPI<T>(
...options,
};
let results: T[] = [];
let currentPage = 1;
let totalPages = 1;
try {
do {
do{
const url = `${API_URL}${endpoint}&pagination[page]=${currentPage}&pagination[pageSize]=25`;
const response = await fetch(url, { ...config, next: { revalidate: 30 } });
if (!response.ok) {
const errorData = await response.json();
console.error(`Error fetching ${url}:`, errorData);
throw new Error(errorData.message || `API fetch error (status: ${response.status})`);
}
const responseJson = await response.json();
results = results.concat(responseJson.data);
totalPages = responseJson.meta?.pagination?.pageCount;
currentPage += 1;
} while (currentPage <= totalPages)
const response = await fetch(url, {...config, next: {revalidate:30}});
if (!response.ok) {
const errorData = await response.json();
console.error(`Error fetching ${url}:`, errorData);
throw new Error(errorData.message || `API fetch error (status: ${response.status})`);
}
const responseJson = await response.json();
results = results.concat(responseJson.data);
totalPages = responseJson.meta?.pagination?.pageCount;
currentPage += 1;
}while(currentPage <= totalPages)
} catch (error) {
console.error("Fetch error:", error);
throw error;
@ -81,13 +81,9 @@ export async function fetchBookChapterLinks(bookId: string): Promise<Book> {
*/
export async function fetchBookById(bookId: string): Promise<Book> {
const currentDateTime = new Date().toISOString();
//[chapters][filters][release_datetime][$lte]=${currentDateTime}
const data = await fetchFromAPI<Book>(
`/api/books/${bookId}?populate[chapters][filters][release_datetime][$lte]=${currentDateTime}&populate=cover`
);
//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;
const data = await fetchFromAPI<Book>(
`/api/books/${bookId}?populate[chapters][filters][release_datetime][$lte]=${currentDateTime}&populate=cover`
);
data[0].chapters = data[0].chapters.sort((a, b) => a.number - b.number);
return data[0];
}
@ -97,9 +93,11 @@ export async function fetchBookById(bookId: string): Promise<Book> {
*/
export async function fetchChapterByBookId(bookId: string, chapterId: string): Promise<Chapter[]> {
const currentChapter = await fetchFromAPI<Chapter>(`/api/chapters/${chapterId}?populate[book][fields][0]=title&filters[book][documentId]=${bookId}`);
const bookWithAllChapters = await fetchFromAPI<Book>(`/api/books/${bookId}?populate[chapters][filters][number][$gte]=${currentChapter[0].number - 1
}&populate[chapters][filters][number][$lte]=${currentChapter[0].number + 1
}`);
const 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;
}
@ -112,25 +110,20 @@ export async function fetchEditors(): Promise<Editor[]> {
return data;
}
export type ChapterRelease = { current_chapters: Chapter[], future_chapters: Chapter[] }
export async function fetchReleases(): Promise<{ current_chapters: Chapter[], future_chapters: Chapter[] }> {
export type ChapterRelease = {current_chapters:Chapter[],future_chapters:Chapter[]}
export async function fetchReleases(): Promise<{current_chapters:Chapter[],future_chapters:Chapter[]}> {
const current_datetime = new Date()
const previous_week = subDays(current_datetime, 3);
const next_week = addDays(current_datetime, 3);
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 }
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];
}

View File

@ -61,10 +61,6 @@ export interface Book {
release_datetime: string;
chapters: Chapter[];
glossary: Glossary;
translator_note: string;
rating: number;
views: number;
readers: number;
}
export interface Announcement {

View File

@ -1,7 +1,3 @@
import { remark } from 'remark';
import html from 'remark-html';
import sanitizeHtml from 'sanitize-html'
export function formatDateToMonthDayYear(date: Date): string {
return date.toLocaleDateString("en-US", {
month: "long",
@ -11,21 +7,4 @@ export function formatDateToMonthDayYear(date: Date): string {
minute: "numeric",
timeZoneName: "short",
});
}
export async function markdownToHtml(markdown: string): Promise<string> {
const result = await remark().use(html).process(markdown)
const sanitizedHtml = sanitizeHtml(result.toString(), {
allowedTags: sanitizeHtml.defaults.allowedTags.concat(['img', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'code', 'pre']),
allowedAttributes: {
...sanitizeHtml.defaults.allowedAttributes,
img: ['src', 'alt'],
},
})
if(sanitizedHtml == ""){
//Already html
return markdown
}
return sanitizedHtml
}