Compare commits
2 Commits
2316fe68e1
...
97e318ce6f
Author | SHA1 | Date | |
---|---|---|---|
97e318ce6f | |||
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>
|
||||
<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;
|
||||
|
@ -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;
|
||||
@ -25,9 +25,9 @@ export async function fetchFromAPI<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}});
|
||||
const response = await fetch(url, { ...config, next: { revalidate: 30 } });
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
console.error(`Error fetching ${url}:`, errorData);
|
||||
@ -37,7 +37,7 @@ export async function fetchFromAPI<T>(
|
||||
results = results.concat(responseJson.data);
|
||||
totalPages = responseJson.meta?.pagination?.pageCount;
|
||||
currentPage += 1;
|
||||
}while(currentPage <= totalPages)
|
||||
} while (currentPage <= totalPages)
|
||||
|
||||
} catch (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> {
|
||||
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;
|
||||
@ -110,8 +112,8 @@ 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);
|
||||
@ -120,10 +122,15 @@ export async function fetchReleases(): Promise<{current_chapters:Chapter[],futur
|
||||
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];
|
||||
}
|
@ -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",
|
||||
@ -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
|
||||
}
|
Loading…
Reference in New Issue
Block a user