Added releases changed the way apis work underneath to give more room for caching, i.e remove unneeded crap when it is unneeded

This commit is contained in:
Hieuhuy Pham 2025-01-21 00:43:14 -05:00
parent 9baa792111
commit 52b2301fc1
8 changed files with 343 additions and 59 deletions

View File

@ -1,37 +1,44 @@
## Put my baby in a docker and let it rip
Frontend of a headless CMS running stripi in the back.
Frontend of a headless CMS running Strapi in the back.
Will host all LLM translation and edit for Foreign Langauges Novels.
Will host all LLM translation and editing for Foreign Language Novels.
Check the main site at https://NullTranslation.com
Check the main site: [NullTranslation](https://NullTranslation.com)
Check out our patreon at https://patreon.com/NullTranslationGroup
Support us on Patreon: [Null Translation Group](https://patreon.com/NullTranslationGroup)
**Books Currently Supporting**
### **TODO**
- ~~ADD MORE NOVEL SUPPORT~~
- STANDARDIZE THE ERROR LOGS (it will be here till the end of days)
- ADD ADS LATER (Added Analytics for ads and user tracking)
- SCHEDULE POSTS FOR PATREON READAHEAD (Lets be real a chron is a real chron in the booty)
- Releases Page for recent releases and schedule for releases (Needed so badly needed)
- Annoucement page for announcements (Gotta tell the 5 users apparently we support audio soon ?)
- AUDIO TM? TTS! TTS!! TTS!!! (I am not paying right ?)
- NextJS data cache, DID SOMEONE SAY PAGINATION ?
---
对弈江山 by 染夕遥 = "Fight of the Throne" by Ran Xiyao
### **Books Currently Supporting**
- **对弈江山** by 染夕遥 = *"Fight of the Throne"* by Ran Xiyao
- **医手遮天** by 慕璎珞 = *"Godly Talented Doctor"* by Mu Yingluo
- **MANY MORE**
医手遮天 by 慕璎珞 = "Godly Talented Doctor" by Mu Yingluo
This project acts as a learning experience for:
- **Dynamic Routing**: Using NEXT.js dynamic router.
- **SSR** vs **CSR** : Interesting experiment
- **SPA** vs **MPA** : Also kindda fund tomess with.
- **Frontend for a Headless CMS**: Powered by Strapi, at first kindda rough, now very easy to understand why CMS exists.
- **NEXTJS DATA CACHE**: 2mb ? what i that for ants ? pagination here we come
This project acts a learning experience for Dynamic Routing using NEXT JS dynamic router and a frontend for a headless CMS Stripi.
### **Conclusion**
*Somehow, dealing with Java Spring Boot nightmares feels easier than this. In the end, using the API directly for uploading data seemed more efficient for my needs, rather than relying on the CMS interface.*
**Conclusion:** Somehow Java Springboot nightmare is easier than this. In the end it seems utterly unncessary for my use.
I ended up pushing to the api instead of using the CMS interface for uploading data anyway...
Maybe CMS is more useful to make websites for customers with no experience in CS, but personally very not useful.
*Perhaps CMS is more useful for building websites for customers with little to no CS experience. For personal use, however, it currently feels unnecessary.*
*That said, CMS might become more useful later if the project grows. Features like shopping tiers and authentication could be easier to implement using a CMS framework.*
> **Okay, CMS is god—we believe in CMS now.**
**WHERE FROM HERE**
Site will be polish more later when/if any of the novels has traffic.
Things like shopping tiers and authentications seems like maybe something CMS would help with implementing.
We will see if then CMS is more useful later.
**TODO**
ADD MORE NOVEL SUPPORT
STANDARDIZE THE ERROR LOGS
ADS LATER
SCHEDULE POSTS FOR PATREON READAHEAD
Polish for the site will come when/if any of the novels gain significant traffic. For now, we'll keep iterating!

View File

@ -0,0 +1,33 @@
import { fetchAnnouncementById } from "@/lib/api";
import { Announcement } from "@/lib/types";
import { formatDateToMonthDayYear } from "@/lib/utils";
export const metadata = {
title: 'Announcement Detail Page',
description: 'NullTranslationGroup Announcement page',
};
export type paramsType = Promise<{ announcementId: string }>;
export default async function AnnouncementDetailPage(props: {params: paramsType}) {
const { announcementId } = await props.params;
let announcement: Announcement;
try{
announcement = await fetchAnnouncementById(announcementId);
}
catch (error) {
console.error(error);
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> Announcement not found !</h1></center>' }}></div>
</div>
)
}
return (
<div className="prose dark:prose-invert mx-auto p-6 bg-white dark:bg-gray-800 shadow-md rounded-lg mt-4">
<h1>{announcement.title}</h1>
<h2>Release Date: {formatDateToMonthDayYear(new Date(announcement.datetime))}</h2>
<div dangerouslySetInnerHTML={{ __html: announcement.content }}></div>
</div>
)
}

View File

@ -0,0 +1,38 @@
import { formatDateToMonthDayYear } from "@/lib/utils";
import { Announcement } from "@/lib/types";
import { fetchAnnouncements } from "@/lib/api";
export const metadata = {
title: 'Annoucement page',
description: 'NullTranslationGroup Announcement page',
};
export default async function AnnouncementPage() {
let announcements = [];
try {
announcements = await fetchAnnouncements();
} catch (error) {
console.error(error);
return (
<div className="text-center mt-10 text-red-500">
<p>Failed to load announcements.</p>
</div>
);
}
const sorted_announcements:Announcement[] = announcements.sort((a, b) => new Date(a.datetime).getTime() - new Date(b.datetime).getTime());
return (
<div className="max-w-6xl mx-auto py-10 px-4">
{sorted_announcements.map((announcement) => (
<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>
))}
</div>
);
}

View File

@ -1,7 +1,7 @@
import React from "react";
import NavigationButtons from "@/components/NavigationButtons";
import { Book, Chapter } from "@/lib/types";
import { fetchBookById } from "@/lib/api";
import { Chapter } from "@/lib/types";
import {fetchChapterByBookId } from "@/lib/api";
export type paramsType = Promise<{ bookId: string; chapterId: string }>;
export const metadata = {
@ -13,13 +13,12 @@ export const metadata = {
export default async function ChapterPage(props: { params: paramsType}) {
const { bookId, chapterId } = await props.params;
let book: Book;
let chapters: Chapter[];
try{
book = await fetchBookById(bookId);
chapters = await fetchChapterByBookId(bookId, chapterId);
}
catch (error) {
console.error(error);
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>
@ -28,11 +27,10 @@ export default async function ChapterPage(props: { params: paramsType}) {
)
}
const chapters :Chapter[] = book.chapters;
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)?.documentId || "" : "";
const prev_chapter = current_chapter ? sorted_chapters.find((chapter) => chapter.number === current_chapter.number - 1)?.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 || "" : "";
if(current_chapter === null){
return (

144
src/app/releases/page.tsx Normal file
View File

@ -0,0 +1,144 @@
import { formatDateToMonthDayYear } from "@/lib/utils";
import { Chapter, Ad } from "@/lib/types";
import { fetchReleases } from "@/lib/api";
import Link from "next/link";
export const metadata = {
title: 'Release page',
description: 'NullTranslationGroup Announcement page',
};
export default async function ReleasePage() {
let current_chapters: Chapter[] = [];
let future_chapters: Chapter[] = [];
try {
const releases = await fetchReleases();
current_chapters = releases.current_chapters
future_chapters = releases.future_chapters
} catch (error) {
console.error(error);
return (
<div className="text-center mt-10 text-red-500">
<p>Failed to load releases.</p>
</div>
);
}
const sorted_current_chapters = current_chapters.sort((a, b) => new Date(a.release_datetime).getTime() - new Date(b.release_datetime).getTime());
const sorted_future_chapters = future_chapters.sort((a, b) => new Date(a.release_datetime).getTime() - new Date(b.release_datetime).getTime());
const groupChaptersByNovel = (chapters: Chapter[]) => {
return chapters.reduce((acc, chapter) => {
const bookTitle = chapter.book?.title || "Unknown Title";
if (!acc[bookTitle]) {
acc[bookTitle] = [];
}
acc[bookTitle].push(chapter);
return acc;
}, {} as Record<string, Chapter[]>);
};
const groupedCurrentChapters = groupChaptersByNovel(sorted_current_chapters)
const groupedFutureChapters = groupChaptersByNovel(sorted_future_chapters);
return (
<div className="mx-auto p-6 bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-gray-100 min-h-screen">
<div className="hidden md:block bg-yellow-500 text-black py-2 px-4 rounded-lg hover:bg-yellow-600 transition duration-200 mb-6">
<a
href={Ad.patreon}
target="_blank"
rel="noopener noreferrer"
className="font-semibold text-center block"
>
WANT TO READ AHEAD OF SCHEDULE ? JOIN OUR PATREON !
</a>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
{/* Current Releases Section */}
<div>
<h2 className="text-2xl font-bold text-yellow-500 mb-6 border-b-2 border-yellow-500 pb-2">
Current Releases
</h2>
{Object.keys(groupedCurrentChapters).length > 0 ? (
Object.entries(groupedCurrentChapters)
.sort(([titleA], [titleB]) => titleA.localeCompare(titleB))
.map(([bookTitle, chapters]) => (
<div key={bookTitle} className="mb-6">
<Link href={`/books/${chapters[0].book?.documentId}`}
className="text-lg font-semibold text-gray-700 dark:text-yellow-300 mb-4 hover:underline">
{bookTitle}
</Link>
<ul className="space-y-4">
{chapters.map((chapter) => (
<li
key={chapter.id}
className="p-4 bg-yellow-100 dark:bg-yellow-800 text-gray-800 dark:text-gray-100 rounded-lg shadow-sm border border-yellow-300 dark:border-yellow-700 transition-transform transform hover:scale-105"
>
<Link href={`books/${chapter.book?.documentId}/chapters/${chapter.documentId}`} className="block">
<h4 className="text-md font-medium">
Chapter {chapter.number}: {chapter.title}
</h4>
<p className="text-sm">
Released on:{" "}
{formatDateToMonthDayYear(
new Date(chapter.release_datetime)
)}
</p>
</Link>
</li>
))}
</ul>
</div>
))
) : (
<p className="text-gray-600 dark:text-gray-400 italic">
No current releases available.
</p>
)}
</div>
{/* Future Releases Section */}
<div>
<h2 className="text-2xl font-bold text-blue-500 mb-6 border-b-2 border-blue-500 pb-2">
Future Releases
</h2>
{Object.keys(groupedFutureChapters).length > 0 ? (
Object.entries(groupedFutureChapters)
.sort(([titleA], [titleB]) => titleA.localeCompare(titleB))
.map(([bookTitle, chapters]) => (
<div key={bookTitle} className="mb-6">
<Link href={`/books/${chapters[0].book?.documentId}`}
className="text-lg font-semibold text-blue-700 dark:text-blue-300 mb-4 hover:underline">
{bookTitle}
</Link>
<ul className="space-y-4">
{chapters.map((chapter) => (
<li
key={chapter.id}
className="p-4 bg-blue-100 dark:bg-blue-800 text-gray-800 dark:text-gray-100 rounded-lg shadow-sm border border-blue-300 dark:border-blue-700 transition-transform transform hover:scale-105"
>
<h4 className="text-md font-medium">
Chapter {chapter.number}: {chapter.title}
</h4>
<p className="text-sm">
Release date:{" "}
{formatDateToMonthDayYear(
new Date(chapter.release_datetime)
)}
</p>
</li>
))}
</ul>
</div>
))
) : (
<p className="text-gray-600 dark:text-gray-400 italic">
No future releases available.
</p>
)}
</div>
</div>
</div>
);
}

View File

@ -23,13 +23,22 @@ export default function Navbar() {
</div>
{/* Navigation Links */}
<nav className="hidden md:flex space-x-6">
<Link href={"/announcements"} className="hover:text-gray-400">
Announcements
</Link>
<Link href="/" className="hover:text-gray-400">
Book List
Books
</Link>
<Link href={"/releases"} className="hover:text-gray-400">
Releases
</Link>
<Link href={Ad.patreon} className="hover:text-gray-400">
Patreon
</Link>
</nav>
{/* Mobile Menu Button */}
@ -57,6 +66,12 @@ export default function Navbar() {
{/* Mobile Menu */}
{isMenuOpen && (
<div className="md:hidden bg-gray-700 px-6 pb-4">
<Link href={"/announcements"} className="hover:text-gray-400">
Announcements
</Link>
<Link href={"/releases"} className="hover:text-gray-400">
Release
</Link>
<Link href="/" className="block py-2 hover:text-gray-400">
Book List
</Link>

View File

@ -1,19 +1,13 @@
import { subDays } from "date-fns";
import { Book, Chapter, Editor } from "./types";
import { addDays, subDays } from "date-fns";
import { Book, Chapter, Editor, Announcement } from "./types";
const API_URL = process.env.NEXT_PUBLIC_API_URL as string;
const API_TOKEN = process.env.STRAPI_API_TOKEN as string;
/**
* Centralized API fetch function with TypeScript support.
* Handles GET, POST, PUT, DELETE methods and includes headers by default.
*/
export async function fetchFromAPI<T>(
endpoint: string,
options: RequestInit = {}
): Promise<T> {
const url = `${API_URL}${endpoint}`;
): Promise<T[]> {
const headers: HeadersInit = {
Authorization: `Bearer ${API_TOKEN}`,
"Content-Type": "application/json",
@ -25,7 +19,14 @@ export async function fetchFromAPI<T>(
...options,
};
let results: T[] = [];
let currentPage = 1;
let totalPages = 1;
try {
do{
const url = `${API_URL}${endpoint}&pagination[page]=${currentPage}&pagination[pageSize]=25`;
const response = await fetch(url, {...config, next: {revalidate:30}});
if (!response.ok) {
const errorData = await response.json();
@ -33,11 +34,17 @@ export async function fetchFromAPI<T>(
throw new Error(errorData.message || `API fetch error (status: ${response.status})`);
}
const responseJson = await response.json();
return responseJson;
results = results.concat(responseJson.data);
totalPages = responseJson.meta?.pagination?.pageCount;
currentPage += 1;
}while(currentPage <= totalPages)
} catch (error) {
console.error("Fetch error:", error);
throw error;
}
return results;
}
/**
@ -45,22 +52,27 @@ export async function fetchFromAPI<T>(
* Populates optional fields like Chapters or Editors based on requirements.
*/
export async function fetchBooks(): Promise<Book[]> {
const data = await fetchFromAPI<{ data: Book[] }>("/api/books?populate=*&chapters.sort=number:desc");
return data.data;
const data = await fetchFromAPI<Book>("/api/books?populate=cover&sort[title]=asc");
return data;
}
export async function fetchAnnouncements(): Promise<Announcement[]> {
const data = await fetchFromAPI<Announcement>("/api/announcements?");
return data;
}
export async function fetchChaptersRSS(): Promise<Chapter[]> {
const currentDateTime = new Date()
const yesterday = subDays(currentDateTime, 1);
const data = await fetchFromAPI<{ data: Chapter[] }>
const data = await fetchFromAPI<Chapter>
(`/api/chapters?populate=book&filters[release_datetime][$lte]=${currentDateTime.toISOString()}&filters[release_datetime][$gte]=${yesterday.toISOString()}`);
return data.data;
return data;
}
export async function fetchBookChapterLinks(bookId: string): Promise<Book> {
const currentDateTime = new Date().toISOString();
const data = await fetchFromAPI<{ data: Book }>(`/api/books/${bookId}?populate[chapters][filters][release_datetime][$lte]=${currentDateTime}`);
return data.data
const data = await fetchFromAPI<Book>(`/api/books/${bookId}?populate[chapters][filters][release_datetime][$lte]=${currentDateTime}`);
return data[0]
}
/**
@ -69,25 +81,49 @@ export async function fetchBookChapterLinks(bookId: string): Promise<Book> {
*/
export async function fetchBookById(bookId: string): Promise<Book> {
const currentDateTime = new Date().toISOString();
const data = await fetchFromAPI<{ data: Book }>(
const data = await fetchFromAPI<Book>(
`/api/books/${bookId}?populate[chapters][filters][release_datetime][$lte]=${currentDateTime}&populate=cover`
);
data.data.chapters = data.data.chapters.sort((a, b) => a.number - b.number);
return data.data;
data[0].chapters = data[0].chapters.sort((a, b) => a.number - b.number);
return data[0];
}
/**
* Fetches a specific chapter by ID.
*/
export async function fetchChapterById(chapterId: string): Promise<Chapter> {
const data = await fetchFromAPI<{ data: Chapter }>(`/api/chapters/${chapterId}?populate[book][fields]=documentId`);
return data.data;
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 nextChapter = await fetchFromAPI<Chapter>(`/api/chapters?populate[book]&filters[book][id]=${bookId}&sort[number]=asc`);
return bookWithAllChapters[0].chapters;
}
/**
* Fetches all editors.
*/
export async function fetchEditors(): Promise<Editor[]> {
const data = await fetchFromAPI<{ data: Editor[] }>("/api/editors");
return data.data;
const data = await fetchFromAPI<Editor>("/api/editors");
return data;
}
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);
const data = await fetchFromAPI<Chapter>(`/api/chapters/?populate[book][fields][0]=title&fields[0]=number&fields[1]=title&fields[2]=release_datetime&filters[release_datetime][$gte]=${previous_week.toISOString()}&filters[release_datetime][$lte]=${next_week.toISOString()}`);
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}
}
export async function fetchAnnouncementById(announcementId: string): Promise<Announcement> {
const data = await fetchFromAPI<Announcement>(`/api/announcements/${announcementId}`);
return data[0];
}

View File

@ -63,6 +63,19 @@ export interface Book {
glossary: Glossary;
}
export interface Announcement {
id: number;
title: string;
content: string;
datetime: string;
}
export interface Release {
book_title: string;
chapter: string;
datetime: string;
url: string;
}
export const Ad = {
patreon: "https://patreon.com/nulltranslationgroup/membership",
}