diff --git a/README.md b/README.md index bb81063..f7e3eef 100644 --- a/README.md +++ b/README.md @@ -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! diff --git a/src/app/announcements/[announcementId]/page.tsx b/src/app/announcements/[announcementId]/page.tsx new file mode 100644 index 0000000..6bc8fce --- /dev/null +++ b/src/app/announcements/[announcementId]/page.tsx @@ -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 ( +
+

Announcement not found !

' }}>
+
+ ) + } + return ( +
+

{announcement.title}

+

Release Date: {formatDateToMonthDayYear(new Date(announcement.datetime))}

+
+
+ ) +} diff --git a/src/app/announcements/page.tsx b/src/app/announcements/page.tsx new file mode 100644 index 0000000..1bf8b92 --- /dev/null +++ b/src/app/announcements/page.tsx @@ -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 ( +
+

Failed to load announcements.

+
+ ); + } + + const sorted_announcements:Announcement[] = announcements.sort((a, b) => new Date(a.datetime).getTime() - new Date(b.datetime).getTime()); + + + return ( +
+ {sorted_announcements.map((announcement) => ( +
  • +
    {announcement.title}
    +
    {formatDateToMonthDayYear(new Date(announcement.datetime))}
    +
  • + ))} +
    + ); + +} diff --git a/src/app/books/[bookId]/chapters/[chapterId]/page.tsx b/src/app/books/[bookId]/chapters/[chapterId]/page.tsx index a032e44..01d204c 100644 --- a/src/app/books/[bookId]/chapters/[chapterId]/page.tsx +++ b/src/app/books/[bookId]/chapters/[chapterId]/page.tsx @@ -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 (

    Chapter not found !

    ' }}>
    @@ -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 ( diff --git a/src/app/releases/page.tsx b/src/app/releases/page.tsx new file mode 100644 index 0000000..ca45ab0 --- /dev/null +++ b/src/app/releases/page.tsx @@ -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 ( +
    +

    Failed to load releases.

    +
    + ); + } + 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); + }; + + const groupedCurrentChapters = groupChaptersByNovel(sorted_current_chapters) + const groupedFutureChapters = groupChaptersByNovel(sorted_future_chapters); + + return ( +
    +
    + + WANT TO READ AHEAD OF SCHEDULE ? JOIN OUR PATREON ! + +
    +
    + {/* Current Releases Section */} +
    +

    + Current Releases +

    + {Object.keys(groupedCurrentChapters).length > 0 ? ( + Object.entries(groupedCurrentChapters) + .sort(([titleA], [titleB]) => titleA.localeCompare(titleB)) + .map(([bookTitle, chapters]) => ( +
    + + {bookTitle} + +
      + {chapters.map((chapter) => ( +
    • + + +

      + Chapter {chapter.number}: {chapter.title} +

      +

      + Released on:{" "} + {formatDateToMonthDayYear( + new Date(chapter.release_datetime) + )} +

      + +
    • + ))} +
    +
    + )) + ) : ( +

    + No current releases available. +

    + )} +
    + + {/* Future Releases Section */} +
    +

    + Future Releases +

    + {Object.keys(groupedFutureChapters).length > 0 ? ( + Object.entries(groupedFutureChapters) + .sort(([titleA], [titleB]) => titleA.localeCompare(titleB)) + .map(([bookTitle, chapters]) => ( +
    + + {bookTitle} + +
      + {chapters.map((chapter) => ( +
    • +

      + Chapter {chapter.number}: {chapter.title} +

      +

      + Release date:{" "} + {formatDateToMonthDayYear( + new Date(chapter.release_datetime) + )} +

      +
    • + ))} +
    +
    + )) + ) : ( +

    + No future releases available. +

    + )} +
    +
    +
    + ); + +} diff --git a/src/components/NavigationBar.tsx b/src/components/NavigationBar.tsx index ac48688..0639373 100644 --- a/src/components/NavigationBar.tsx +++ b/src/components/NavigationBar.tsx @@ -23,13 +23,22 @@ export default function Navbar() {
    {/* Navigation Links */} + {/* Mobile Menu Button */} @@ -57,6 +66,12 @@ export default function Navbar() { {/* Mobile Menu */} {isMenuOpen && (
    + + Announcements + + + Release + Book List diff --git a/src/lib/api.tsx b/src/lib/api.tsx index 8da0e1d..948a553 100644 --- a/src/lib/api.tsx +++ b/src/lib/api.tsx @@ -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( endpoint: string, options: RequestInit = {} -): Promise { - const url = `${API_URL}${endpoint}`; - +): Promise { const headers: HeadersInit = { Authorization: `Bearer ${API_TOKEN}`, "Content-Type": "application/json", @@ -25,19 +19,32 @@ export async function fetchFromAPI( ...options, }; + + + let results: T[] = []; + let currentPage = 1; + let totalPages = 1; try { - const response = await fetch(url, {...config, next: {revalidate:30}}); + 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(); console.error(`Error fetching ${url}:`, errorData); 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( * Populates optional fields like Chapters or Editors based on requirements. */ export async function fetchBooks(): Promise { - const data = await fetchFromAPI<{ data: Book[] }>("/api/books?populate=*&chapters.sort=number:desc"); - return data.data; + const data = await fetchFromAPI("/api/books?populate=cover&sort[title]=asc"); + return data; +} + +export async function fetchAnnouncements(): Promise { + const data = await fetchFromAPI("/api/announcements?"); + return data; } export async function fetchChaptersRSS(): Promise { const currentDateTime = new Date() const yesterday = subDays(currentDateTime, 1); - const data = await fetchFromAPI<{ data: Chapter[] }> + const data = await fetchFromAPI (`/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 { 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(`/api/books/${bookId}?populate[chapters][filters][release_datetime][$lte]=${currentDateTime}`); + return data[0] } /** @@ -69,25 +81,49 @@ export async function fetchBookChapterLinks(bookId: string): Promise { */ export async function fetchBookById(bookId: string): Promise { const currentDateTime = new Date().toISOString(); - const data = await fetchFromAPI<{ data: Book }>( + const data = await fetchFromAPI( `/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 { - 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 { + const currentChapter = await fetchFromAPI(`/api/chapters/${chapterId}?populate[book][fields][0]=title&filters[book][documentId]=${bookId}`); + const bookWithAllChapters = await fetchFromAPI( `/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(`/api/chapters?populate[book]&filters[book][id]=${bookId}&sort[number]=asc`); + return bookWithAllChapters[0].chapters; } /** * Fetches all editors. */ export async function fetchEditors(): Promise { - const data = await fetchFromAPI<{ data: Editor[] }>("/api/editors"); - return data.data; + const data = await fetchFromAPI("/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(`/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 { + const data = await fetchFromAPI(`/api/announcements/${announcementId}`); + return data[0]; } \ No newline at end of file diff --git a/src/lib/types.tsx b/src/lib/types.tsx index 265fd5a..d2cc15e 100644 --- a/src/lib/types.tsx +++ b/src/lib/types.tsx @@ -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", }