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:
parent
2316fe68e1
commit
554a6dff45
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",
|
||||
"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",
|
||||
|
@ -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);
|
||||
|
@ -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>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
}
|
||||
<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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
|
@ -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>
|
||||
);
|
||||
|
||||
|
50
src/components/ChapterContentRenderer.tsx
Normal file
50
src/components/ChapterContentRenderer.tsx
Normal 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;
|
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;
|
||||
|
@ -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 {
|
||||
|
@ -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",
|
||||
@ -7,4 +11,21 @@ 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
|
||||
}
|
Loading…
Reference in New Issue
Block a user