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.
This commit is contained in:
Hieuhuy Pham 2025-01-23 01:43:36 -05:00
parent 2316fe68e1
commit 554a6dff45
10 changed files with 1467 additions and 41 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

@ -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
}