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",
"axios": "^1.7.9",
"date-fns": "^4.1.0",
"html-react-parser": "^5.2.2",
"next": "15.1.4",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"rss": "^1.2.2"
"remark": "^15.0.1",
"remark-html": "^16.0.1",
"rss": "^1.2.2",
"sanitize-html": "^2.14.0"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
@ -27,6 +31,7 @@
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/rss": "^0.0.32",
"@types/sanitize-html": "^2.13.0",
"eslint": "^9",
"eslint-config-next": "15.1.4",
"postcss": "^8",

View File

@ -1,6 +1,6 @@
import { fetchAnnouncementById } from "@/lib/api";
import { Announcement } from "@/lib/types";
import { formatDateToMonthDayYear } from "@/lib/utils";
import { formatDateToMonthDayYear, markdownToHtml } from "@/lib/utils";
export const metadata = {
title: 'Announcement Detail Page',
@ -14,6 +14,7 @@ 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,41 +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: 'Annoucement page',
description: 'NullTranslationGroup Announcement page',
title: "Announcement Page",
description: "NullTranslationGroup Announcement Page",
};
export default async function AnnouncementPage() {
let announcements = [];
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.</p>
<p>Failed to load announcements. Please try again later.</p>
</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 (
<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>
<h1 className="text-3xl font-bold text-center mb-8">Announcements</h1>
{sorted_announcements.length === 0 ? (
<div className="text-center text-gray-500">
<p>No announcements available.</p>
</div>
) : (
<div className="space-y-6">
{sorted_announcements.map((announcement) => {
const contents = announcement.content.split("\n");
announcement.content = contents.length > 3 ? contents.slice(0, 3).join("\n") : announcement.content;
return (
<Link
href={`/announcements/${announcement.documentId}`}
key={announcement.documentId}
>
<div className="prose dark:prose-invert max-w-none p-6 bg-white shadow-md rounded-lg cursor-pointer transition-colors duration-300 hover:shadow-lg dark:bg-gray-800 dark:shadow-gray-900">
<div className="text-sm text-gray-500 mb-4 dark:text-gray-400">
{formatDateToMonthDayYear(new Date(announcement.datetime))}
</div>
<div
className="text-gray-700 mb-4 dark:text-gray-300 prose dark:prose-invert max-w-none"
dangerouslySetInnerHTML={{ __html: announcement.content }}
/>
<div className="flex items-center text-blue-600 hover:text-blue-800 transition-colors duration-300 dark:text-blue-400 dark:hover:text-blue-300">
<span className="mr-2">Read More</span>
</div>
</div>
</Link>
))}
)
})}
</div>
)}
</div>
);
}

View File

@ -1,7 +1,9 @@
import React from "react";
import NavigationButtons from "@/components/NavigationButtons";
import ChapterRenderer from "@/components/ChapterContentRenderer";
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 const metadata = {
@ -26,16 +28,17 @@ 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: '<center><h1> Chapter not found !</h1></center>' }}></div>
<div dangerouslySetInnerHTML={{ __html: '<NavigationButtons bookId={bookId} documentId={chapterId} prevChapter={prev_chapter} nextChapter={next_chapter} /><center><NavigationButtons /><h1> Chapter not found !</h1></center>' }}></div>
<NavigationButtons bookId={bookId} documentId={chapterId} prevChapter={""} nextChapter={""} />
</div>
)
@ -44,8 +47,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" 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}/>
</div>
);

View File

@ -1,6 +1,6 @@
import { fetchBookById } from "@/lib/api";
import { Book, Chapter } from "@/lib/types";
import { formatDateToMonthDayYear } from "@/lib/utils";
import { formatDateToMonthDayYear, markdownToHtml } 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 Translatin Group book',
description: 'Null Translation Group book description page',
};
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 cover_media = cover?.at(0);
@ -45,7 +47,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 for Unreleased Chapters
Join Our Patreon to read ahead!
</a>
</div>
@ -54,8 +56,8 @@ export default async function BookPage(props: { params: paramsType }) {
<br></br>
<strong>Translator:</strong> Null Translation Group
</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>
<ul className="grid gap-6 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3">
{recentChapters.map((chapter) => (
@ -93,6 +95,18 @@ 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

@ -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 { 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_TOKEN = process.env.STRAPI_API_TOKEN as string;
@ -81,9 +81,13 @@ 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;
data[0].chapters = data[0].chapters.sort((a, b) => a.number - b.number);
return data[0];
}
@ -93,10 +97,8 @@ 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;
@ -127,3 +129,8 @@ export async function fetchAnnouncementById(announcementId: string): Promise<Ann
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,6 +61,10 @@ 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,3 +1,7 @@
import { remark } from 'remark';
import html from 'remark-html';
import sanitizeHtml from 'sanitize-html'
export function formatDateToMonthDayYear(date: Date): string {
return date.toLocaleDateString("en-US", {
month: "long",
@ -8,3 +12,20 @@ export function formatDateToMonthDayYear(date: Date): string {
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
}