Compare commits

...

2 Commits

Author SHA1 Message Date
97e318ce6f The mystery of the glossary addition. I really gotta figure out why it refuses to populate glossary and cover with chapters at the same time. 2025-01-23 01:44:12 -05:00
554a6dff45 Announcement Page retooling, make it look better.
added Markdown support for rich text for all type.
Markdown support for books description page too to add Translator Notes.
Added a Rendering Pipeline for specialized Glossary Popup to work.
Added more info to book, from back and front end. Getting ready for ratings views and reader list.
2025-01-23 01:43:36 -05:00
11 changed files with 1501 additions and 68 deletions

1267
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -16,10 +16,14 @@
"@tanstack/react-query-devtools": "^5.63.0", "@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",

View File

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

View File

@ -1,41 +1,68 @@
import { formatDateToMonthDayYear } from "@/lib/utils"; import { formatDateToMonthDayYear } from "@/lib/utils";
import { Announcement } from "@/lib/types"; import { Announcement } from "@/lib/types";
import { fetchAnnouncements } from "@/lib/api"; import { fetchAnnouncements } from "@/lib/api";
import { markdownToHtml } from "@/lib/utils";
import Link from "next/link"; import Link from "next/link";
export const metadata = { export const metadata = {
title: 'Annoucement page', title: "Announcement Page",
description: 'NullTranslationGroup Announcement page', description: "NullTranslationGroup Announcement Page",
}; };
export default async function AnnouncementPage() { export default async function AnnouncementPage() {
let announcements = []; let announcements: Announcement[] = [];
try { try {
announcements = await fetchAnnouncements(); announcements = await fetchAnnouncements();
for (const announcement of announcements) {
announcement.content = await markdownToHtml(announcement.content);
}
} catch (error) { } catch (error) {
console.error(error); console.error(error);
return ( return (
<div className="text-center mt-10 text-red-500"> <div className="text-center mt-10 text-red-500">
<p>Failed to load announcements.</p> <p>Failed to load announcements. Please try again later.</p>
</div> </div>
); );
} }
const sorted_announcements:Announcement[] = announcements.sort((a, b) => new Date(a.datetime).getTime() - new Date(b.datetime).getTime()); // Sort announcements by date (newest first)
const sorted_announcements: Announcement[] = announcements.sort(
(a, b) => new Date(b.datetime).getTime() - new Date(a.datetime).getTime()
);
return ( return (
<div className="max-w-6xl mx-auto py-10 px-4"> <div className="max-w-6xl mx-auto py-10 px-4">
{sorted_announcements.map((announcement) => ( <h1 className="text-3xl font-bold text-center mb-8">Announcements</h1>
<Link href = {`/announcements/${announcement.documentId}`} key={announcement.documentId}> {sorted_announcements.length === 0 ? (
<li key={announcement.id} className="mb-2 list-none"> <div className="text-center text-gray-500">
<div className="text-lg font-semibold">{announcement.title}</div> <p>No announcements available.</p>
<div className="text-sm text-gray-500">{formatDateToMonthDayYear(new Date(announcement.datetime))}</div> </div>
</li> ) : (
</Link> <div className="space-y-6">
))} {sorted_announcements.map((announcement) => {
</div> const contents = announcement.content.split("\n");
); announcement.content = contents.length > 3 ? contents.slice(0, 3).join("\n") : announcement.content;
return (
<Link
href={`/announcements/${announcement.documentId}`}
key={announcement.documentId}
>
<div className="prose dark:prose-invert max-w-none p-6 bg-white shadow-md rounded-lg cursor-pointer transition-colors duration-300 hover:shadow-lg dark:bg-gray-800 dark:shadow-gray-900">
<div className="text-sm text-gray-500 mb-4 dark:text-gray-400">
{formatDateToMonthDayYear(new Date(announcement.datetime))}
</div>
<div
className="text-gray-700 mb-4 dark:text-gray-300 prose dark:prose-invert max-w-none"
dangerouslySetInnerHTML={{ __html: announcement.content }}
/>
<div className="flex items-center text-blue-600 hover:text-blue-800 transition-colors duration-300 dark:text-blue-400 dark:hover:text-blue-300">
<span className="mr-2">Read More</span>
</div>
</div>
</Link>
)
})}
</div>
)}
</div>
);
} }

View File

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

View File

@ -1,6 +1,6 @@
import { fetchBookById } from "@/lib/api"; import { fetchBookById } from "@/lib/api";
import { Book, Chapter } from "@/lib/types"; import { Book, Chapter } from "@/lib/types";
import { formatDateToMonthDayYear } from "@/lib/utils"; import { 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";
@ -8,7 +8,7 @@ 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,7 +25,9 @@ export default async function BookPage(props: { params: paramsType }) {
); );
} }
const { title, author, description, chapters, cover } = book; const {title, author, description, chapters, cover, translator_note, glossary} = book;
const english_glossary = glossary?.english_english;
const translator_note_html = await markdownToHtml(translator_note);
const sorted_chapters:Chapter[] = chapters.sort((a, b) => a.number - b.number); const sorted_chapters:Chapter[] = chapters.sort((a, b) => a.number - b.number);
const cover_media = cover?.at(0); const cover_media = cover?.at(0);
@ -45,7 +47,7 @@ export default async function BookPage(props: { params: paramsType }) {
rel="noopener noreferrer" rel="noopener noreferrer"
className="ml-4 bg-yellow-500 text-white font-semibold py-2 px-4 rounded hover:bg-yellow-600 transition duration-200" className="ml-4 bg-yellow-500 text-white font-semibold py-2 px-4 rounded hover:bg-yellow-600 transition duration-200"
> >
Join Our Patreon for Unreleased Chapters Join Our Patreon to read ahead!
</a> </a>
</div> </div>
@ -54,8 +56,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) => (
@ -93,6 +95,18 @@ export default async function BookPage(props: { params: paramsType }) {
</a> </a>
</li> </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>
); );

View File

@ -0,0 +1,50 @@
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

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

View File

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

View File

@ -61,6 +61,10 @@ 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;
} }
export interface Announcement { export interface Announcement {

View File

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