Full push for initial version of NullTranslationGroup website.
This commit is contained in:
parent
de0b5f5042
commit
3fed14c353
6
global.d.ts
vendored
Normal file
6
global.d.ts
vendored
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
declare namespace NodeJS {
|
||||||
|
interface ProcessEnv {
|
||||||
|
NEXT_PUBLIC_API_URL: string;
|
||||||
|
STRAPI_API_TOKEN: string;
|
||||||
|
}
|
||||||
|
}
|
327
package-lock.json
generated
327
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
18
package.json
18
package.json
@ -9,19 +9,25 @@
|
|||||||
"lint": "next lint"
|
"lint": "next lint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@tailwindcss/aspect-ratio": "^0.4.2",
|
||||||
|
"@tailwindcss/line-clamp": "^0.4.4",
|
||||||
|
"@tailwindcss/typography": "^0.5.16",
|
||||||
|
"@tanstack/react-query": "^5.63.0",
|
||||||
|
"@tanstack/react-query-devtools": "^5.63.0",
|
||||||
|
"axios": "^1.7.9",
|
||||||
|
"next": "15.1.4",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0"
|
||||||
"next": "15.1.4"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5",
|
"@eslint/eslintrc": "^3",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"postcss": "^8",
|
|
||||||
"tailwindcss": "^3.4.1",
|
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "15.1.4",
|
"eslint-config-next": "15.1.4",
|
||||||
"@eslint/eslintrc": "^3"
|
"postcss": "^8",
|
||||||
|
"tailwindcss": "^3.4.1",
|
||||||
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
BIN
public/logo.png
Normal file
BIN
public/logo.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 50 KiB |
46
src/app/books/[bookId]/chapters/[chapterId]/page.tsx
Normal file
46
src/app/books/[bookId]/chapters/[chapterId]/page.tsx
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import React from "react";
|
||||||
|
import NavigationButtons from "@/components/NavigationButtons";
|
||||||
|
import { Chapter } from "@/lib/types";
|
||||||
|
import { fetchBookById } from "@/lib/api";
|
||||||
|
|
||||||
|
export type paramsType = Promise<{ bookId: string; chapterId: string }>;
|
||||||
|
|
||||||
|
// Dynamic page component
|
||||||
|
export default async function ChapterPage(props: { params: paramsType}) {
|
||||||
|
const { bookId, chapterId } = await props.params;
|
||||||
|
|
||||||
|
const book = await fetchBookById(bookId);
|
||||||
|
|
||||||
|
const chapters :Chapter[] = book.chapters;
|
||||||
|
const sorted_chapters:Chapter[] = chapters.sort((a, b) => a.Chapter_Number - b.Chapter_Number);
|
||||||
|
const current_chapter = sorted_chapters.find((chapter) => chapter.documentId === chapterId) || undefined;
|
||||||
|
const next_chapter = current_chapter ? sorted_chapters.find((chapter) => chapter.Chapter_Number === current_chapter.Chapter_Number + 1)?.documentId || "" : "";
|
||||||
|
const prev_chapter = current_chapter ? sorted_chapters.find((chapter) => chapter.Chapter_Number === current_chapter.Chapter_Number - 1)?.documentId || "" : "";
|
||||||
|
// Fetch chapter data
|
||||||
|
|
||||||
|
if (current_chapter === undefined) {
|
||||||
|
return (
|
||||||
|
<div className="relative bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-gray-100 min-h-screen">
|
||||||
|
<div className="prose dark:prose-invert mx-auto p-6 bg-white dark:bg-gray-800 shadow-md rounded-lg">
|
||||||
|
<div dangerouslySetInnerHTML={{ __html: '<center><h1> Chapter not found !</h1></center>' }}></div>
|
||||||
|
|
||||||
|
{/* Client component for navigation */}
|
||||||
|
<NavigationButtons bookId={bookId} documentId={chapterId} prevChapter={prev_chapter} nextChapter={next_chapter} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="prose dark:prose-invert mx-auto p-6 bg-white dark:bg-gray-800 shadow-md rounded-lg">
|
||||||
|
<NavigationButtons bookId={bookId} documentId={chapterId} prevChapter={prev_chapter} nextChapter={next_chapter} />
|
||||||
|
|
||||||
|
<div className="pt-4" dangerouslySetInnerHTML={{ __html: current_chapter.Content }}></div>
|
||||||
|
|
||||||
|
{/* Client component for navigation */}
|
||||||
|
<NavigationButtons bookId={bookId} documentId={chapterId} prevChapter={prev_chapter} nextChapter={next_chapter}/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
}
|
88
src/app/books/[bookId]/page.tsx
Normal file
88
src/app/books/[bookId]/page.tsx
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
import { fetchBookChapterLinks } from "@/lib/api";
|
||||||
|
import { Book } from "@/lib/types";
|
||||||
|
import { formatDateToMonthDayYear } from "@/lib/utils";
|
||||||
|
import ChapterDropdown from "@/components/ChapterDropdown";
|
||||||
|
|
||||||
|
export type paramsType = Promise<{ bookId: string}>;
|
||||||
|
|
||||||
|
|
||||||
|
export default async function BookPage(props: { params: paramsType }) {
|
||||||
|
const { bookId } = await props.params;
|
||||||
|
|
||||||
|
let book: Book;
|
||||||
|
try {
|
||||||
|
book = await fetchBookChapterLinks(bookId);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
return (
|
||||||
|
<div className="text-center mt-10 text-red-500">
|
||||||
|
Error fetching book data. Please try again later.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { Name, Author, Description, chapters } = book;
|
||||||
|
const recentChapters = chapters.length > 6 ? chapters.slice(chapters.length - 6, chapters.length) : chapters;
|
||||||
|
return (
|
||||||
|
<div className="max-w-6xl mx-auto py-10 px-4">
|
||||||
|
<div className="flex items-center justify-between mb-4 pt-4">
|
||||||
|
{/* Book Title */}
|
||||||
|
<h1 className="text-5xl font-bold">{Name}</h1>
|
||||||
|
|
||||||
|
{/* Patreon Button */}
|
||||||
|
<a
|
||||||
|
href="https://www.patreon.com/c/nulltranslationgroup/membership?view_as=patron" // Replace with your Patreon URL
|
||||||
|
target="_blank"
|
||||||
|
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
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-lg text-gray-600 dark:text-gray-400 mb-4">
|
||||||
|
<strong>Author:</strong> {Author}
|
||||||
|
<br></br>
|
||||||
|
<strong>Translator:</strong> Null Translation Group
|
||||||
|
</p>
|
||||||
|
<p className="mb-6">{Description}</p>
|
||||||
|
|
||||||
|
<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) => (
|
||||||
|
<li key={chapter.id}>
|
||||||
|
<a
|
||||||
|
href={`/books/${bookId}/chapters/${chapter.documentId}`}
|
||||||
|
className="block bg-white dark:bg-gray-800 rounded-lg shadow p-4 hover:shadow-lg transition duration-200"
|
||||||
|
>
|
||||||
|
<h3 className="text-xl font-medium">
|
||||||
|
Chapter {chapter.Chapter_Number}: {chapter.Name}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400 mt-2">
|
||||||
|
<strong>Release Date:</strong> {formatDateToMonthDayYear(new Date(chapter.ReleaseDate))}
|
||||||
|
</p>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<div className="flex items-center justify-between mb-4 pt-4">
|
||||||
|
<h2 className="text-3xl font-semibold">All Chapters</h2>
|
||||||
|
<ChapterDropdown chapters={chapters} bookId={bookId} />
|
||||||
|
</div>
|
||||||
|
{chapters.map((chapter) => (
|
||||||
|
<li key={chapter.id} className="mb-2 list-none">
|
||||||
|
<a
|
||||||
|
href={`/books/${bookId}/chapters/${chapter.documentId}`}
|
||||||
|
className="block bg-white dark:bg-gray-800 rounded-lg shadow p-4 hover:shadow-lg transition duration-200"
|
||||||
|
>
|
||||||
|
<h3 className="text-xl font-medium">
|
||||||
|
Chapter {chapter.Chapter_Number}: {chapter.Name}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400 mt-2">
|
||||||
|
<strong>Release Date:</strong> {formatDateToMonthDayYear(new Date(chapter.ReleaseDate))}
|
||||||
|
</p>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</div>);
|
||||||
|
}
|
@ -1,33 +1,61 @@
|
|||||||
import type { Metadata } from "next";
|
"use client";
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
|
||||||
import "./globals.css";
|
|
||||||
|
|
||||||
const geistSans = Geist({
|
import "tailwindcss/tailwind.css";
|
||||||
variable: "--font-geist-sans",
|
|
||||||
subsets: ["latin"],
|
|
||||||
});
|
|
||||||
|
|
||||||
const geistMono = Geist_Mono({
|
import React, { useEffect, useState } from "react";
|
||||||
variable: "--font-geist-mono",
|
import NightModeToggle from "@/components/NightModeToggle";
|
||||||
subsets: ["latin"],
|
import Navbar from "@/components/NavigationBar";
|
||||||
});
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
title: "Create Next App",
|
const [isDarkMode, setIsDarkMode] = useState(false);
|
||||||
description: "Generated by create next app",
|
|
||||||
|
useEffect(() => {
|
||||||
|
const savedTheme = localStorage.getItem("theme");
|
||||||
|
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||||
|
|
||||||
|
const shouldEnableDark = savedTheme === "dark" || (!savedTheme && prefersDark);
|
||||||
|
setIsDarkMode(shouldEnableDark);
|
||||||
|
|
||||||
|
if (shouldEnableDark) {
|
||||||
|
document.documentElement.classList.add("dark");
|
||||||
|
} else {
|
||||||
|
document.documentElement.classList.remove("dark");
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleDarkMode = () => {
|
||||||
|
const newTheme = isDarkMode ? "light" : "dark";
|
||||||
|
setIsDarkMode(!isDarkMode);
|
||||||
|
localStorage.setItem("theme", newTheme);
|
||||||
|
|
||||||
|
document.documentElement.classList.toggle("dark", !isDarkMode);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
|
||||||
children,
|
|
||||||
}: Readonly<{
|
|
||||||
children: React.ReactNode;
|
|
||||||
}>) {
|
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en" className={isDarkMode ? "dark" : ""} suppressHydrationWarning>
|
||||||
<body
|
<head>
|
||||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
<script
|
||||||
>
|
dangerouslySetInnerHTML={{
|
||||||
{children}
|
__html: `
|
||||||
|
(function() {
|
||||||
|
const savedTheme = localStorage.getItem('theme');
|
||||||
|
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
|
if (savedTheme === 'dark' || (!savedTheme && prefersDark)) {
|
||||||
|
document.documentElement.classList.add('dark');
|
||||||
|
} else {
|
||||||
|
document.documentElement.classList.remove('dark');
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</head>
|
||||||
|
<body className="bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-gray-100 min-h-screen">
|
||||||
|
<Navbar />
|
||||||
|
<main className="relative">{children}</main>
|
||||||
|
<div className="absolute bottom-4 right-4">
|
||||||
|
<NightModeToggle isDarkMode={isDarkMode} toggleDarkMode={toggleDarkMode} />
|
||||||
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
157
src/app/page.tsx
157
src/app/page.tsx
@ -1,101 +1,66 @@
|
|||||||
import Image from "next/image";
|
import { Book } from "@/lib/types";
|
||||||
|
import { fetchBooks } from "@/lib/api";
|
||||||
|
|
||||||
export default function Home() {
|
export default async function HomePage() {
|
||||||
|
let books: Book[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
books = await fetchBooks();
|
||||||
|
} catch (error) {
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20 font-[family-name:var(--font-geist-sans)]">
|
console.log(error),
|
||||||
<main className="flex flex-col gap-8 row-start-2 items-center sm:items-start">
|
<div className="text-center mt-10 text-red-500">
|
||||||
<Image
|
Error fetching books. Please try again later.
|
||||||
className="dark:invert"
|
</div>
|
||||||
src="/next.svg"
|
);
|
||||||
alt="Next.js logo"
|
}
|
||||||
width={180}
|
|
||||||
height={38}
|
return (
|
||||||
priority
|
<div className="mx-auto p-6 bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-gray-100 min-h-screen">
|
||||||
/>
|
{/* Patreon Advertisement */}
|
||||||
<ol className="list-inside list-decimal text-sm text-center sm:text-left font-[family-name:var(--font-geist-mono)]">
|
<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">
|
||||||
<li className="mb-2">
|
<a
|
||||||
Get started by editing{" "}
|
href="https://patreon.com/NullTranslationGroup"
|
||||||
<code className="bg-black/[.05] dark:bg-white/[.06] px-1 py-0.5 rounded font-semibold">
|
target="_blank"
|
||||||
src/app/page.tsx
|
rel="noopener noreferrer"
|
||||||
</code>
|
className="font-semibold text-center block"
|
||||||
.
|
>
|
||||||
</li>
|
🌟 Join Us on Patreon for Unreleased Chapters!
|
||||||
<li>Save and see your changes instantly.</li>
|
</a>
|
||||||
</ol>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-4 items-center flex-col sm:flex-row">
|
{/* Books Grid */}
|
||||||
<a
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 px-6">
|
||||||
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5"
|
{books.map((book: Book) => (
|
||||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
<div
|
||||||
target="_blank"
|
key={book.id}
|
||||||
rel="noopener noreferrer"
|
className="p-4 bg-white dark:bg-gray-800 rounded-lg shadow-md hover:shadow-lg transition flex flex-col"
|
||||||
>
|
>
|
||||||
<Image
|
{book.Cover?.url && (
|
||||||
className="dark:invert"
|
<div className="relative w-full aspect-w-3 aspect-h-4 mb-4">
|
||||||
src="/vercel.svg"
|
<img
|
||||||
alt="Vercel logomark"
|
src={`${process.env.NEXT_PUBLIC_API_URL}${book.Cover.url}`}
|
||||||
width={20}
|
alt={book.Cover.alternativeText || `Cover of ${book.Name}`}
|
||||||
height={20}
|
className="rounded-lg object-cover"
|
||||||
/>
|
/>
|
||||||
Deploy now
|
</div>
|
||||||
</a>
|
)}
|
||||||
<a
|
|
||||||
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:min-w-44"
|
<h2 className="text-2xl font-semibold mb-2">{book.Name}</h2>
|
||||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
target="_blank"
|
<strong>Author:</strong> {book.Author}
|
||||||
rel="noopener noreferrer"
|
</p>
|
||||||
>
|
<p className="text-sm mt-2 line-clamp-3">{book.Description}</p>
|
||||||
Read our docs
|
|
||||||
</a>
|
<a
|
||||||
</div>
|
className="mt-4 inline-block bg-blue-500 hover:bg-blue-600 text-white text-sm font-semibold px-4 py-2 rounded text-center"
|
||||||
</main>
|
href={`/books/${book.documentId}`}
|
||||||
<footer className="row-start-3 flex gap-6 flex-wrap items-center justify-center">
|
>
|
||||||
<a
|
Read Book
|
||||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
</a>
|
||||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
</div>
|
||||||
target="_blank"
|
))}
|
||||||
rel="noopener noreferrer"
|
</div>
|
||||||
>
|
|
||||||
<Image
|
|
||||||
aria-hidden
|
|
||||||
src="/file.svg"
|
|
||||||
alt="File icon"
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
/>
|
|
||||||
Learn
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
|
||||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
aria-hidden
|
|
||||||
src="/window.svg"
|
|
||||||
alt="Window icon"
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
/>
|
|
||||||
Examples
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
|
||||||
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
aria-hidden
|
|
||||||
src="/globe.svg"
|
|
||||||
alt="Globe icon"
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
/>
|
|
||||||
Go to nextjs.org →
|
|
||||||
</a>
|
|
||||||
</footer>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
32
src/components/ChapterDropdown.tsx
Normal file
32
src/components/ChapterDropdown.tsx
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
export default function ChapterDropdown({
|
||||||
|
chapters,
|
||||||
|
bookId,
|
||||||
|
}: {
|
||||||
|
chapters: { id: number; documentId: string; Chapter_Number: number; Name: string }[];
|
||||||
|
bookId: string;
|
||||||
|
}) {
|
||||||
|
|
||||||
|
const navigateToChapter = (documentId: string) => {
|
||||||
|
if (documentId) {
|
||||||
|
window.location.href = `/books/${bookId}/chapters/${documentId}`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<select
|
||||||
|
className="ml-4 px-4 py-2 rounded bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
||||||
|
onChange={(e) => navigateToChapter(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="" disabled>
|
||||||
|
Select a Chapter
|
||||||
|
</option>
|
||||||
|
{chapters.map((chapter) => (
|
||||||
|
<option key={chapter.id} value={chapter.documentId}>
|
||||||
|
Chapter {chapter.Chapter_Number}: {chapter.Name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
);
|
||||||
|
}
|
67
src/components/NavigationBar.tsx
Normal file
67
src/components/NavigationBar.tsx
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
"use client";
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
|
export default function Navbar() {
|
||||||
|
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-gray-800 text-white">
|
||||||
|
<div className="flex justify-between items-center py-4 px-6 max-w-screen-xl mx-auto">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<Image
|
||||||
|
src="/logo.png" // Replace with your logo path
|
||||||
|
alt="Logo"
|
||||||
|
className="h-8 w-8"
|
||||||
|
/>
|
||||||
|
<Link href="/" className="text-2xl font-bold">
|
||||||
|
Null Translation Group
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation Links */}
|
||||||
|
<nav className="hidden md:flex space-x-6">
|
||||||
|
<Link href="/" className="hover:text-gray-400">
|
||||||
|
Book List
|
||||||
|
</Link>
|
||||||
|
<Link href="https://patreon.com/NullTranslationGroup" className="hover:text-gray-400">
|
||||||
|
Patreon
|
||||||
|
</Link>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Mobile Menu Button */}
|
||||||
|
<button
|
||||||
|
className="md:hidden flex items-center text-gray-400 hover:text-white"
|
||||||
|
onClick={() => setIsMenuOpen(!isMenuOpen)}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className="h-6 w-6"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M4 6h16M4 12h16m-7 6h7"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile Menu */}
|
||||||
|
{isMenuOpen && (
|
||||||
|
<div className="md:hidden bg-gray-700 px-6 pb-4">
|
||||||
|
<Link href="/" className="block py-2 hover:text-gray-400">
|
||||||
|
Book List
|
||||||
|
</Link>
|
||||||
|
<Link href="https://patreon/NullTranslationGroup" className="block py-2 hover:text-gray-400">
|
||||||
|
Patreon
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
62
src/components/NavigationButtons.tsx
Normal file
62
src/components/NavigationButtons.tsx
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
|
||||||
|
interface NavigationButtonsProps {
|
||||||
|
bookId: string;
|
||||||
|
documentId: string;
|
||||||
|
prevChapter: string;
|
||||||
|
nextChapter: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NavigationButtons: React.FC<NavigationButtonsProps> = ({ bookId, documentId, prevChapter, nextChapter }) => {
|
||||||
|
const router = useRouter();
|
||||||
|
console.log(documentId)
|
||||||
|
const navigateToChapter = (destinationId: string) => {
|
||||||
|
router.push(`/books/${bookId}/chapters/${destinationId}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const navigateToAllChapters = () => {
|
||||||
|
router.push(`/books/${bookId}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-8 flex justify-between">
|
||||||
|
<button
|
||||||
|
className={`
|
||||||
|
bg-teal-500 text-white py-2 px-4 rounded
|
||||||
|
hover:bg-teal-600
|
||||||
|
disabled:bg-gray-400 disabled:cursor-not-allowed
|
||||||
|
`}
|
||||||
|
onClick={() => prevChapter === ""? navigateToAllChapters() : navigateToChapter(prevChapter)}
|
||||||
|
disabled={prevChapter === ""}
|
||||||
|
>
|
||||||
|
Prev Chapter
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="bg-blue-500 text-white py-2 px-4 rounded hover:bg-blue-600"
|
||||||
|
onClick={navigateToAllChapters}
|
||||||
|
>
|
||||||
|
All Chapters
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`
|
||||||
|
bg-green-500 text-white py-2 px-4 rounded
|
||||||
|
hover:bg-green-600
|
||||||
|
disabled:bg-gray-400 disabled:cursor-not-allowed
|
||||||
|
`}
|
||||||
|
onClick={() =>
|
||||||
|
nextChapter === "" ? navigateToAllChapters() : navigateToChapter(nextChapter)
|
||||||
|
}
|
||||||
|
disabled={nextChapter === ""}
|
||||||
|
>
|
||||||
|
Next Chapter
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NavigationButtons;
|
19
src/components/NightModeToggle.tsx
Normal file
19
src/components/NightModeToggle.tsx
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
export default function NightModeToggle({
|
||||||
|
isDarkMode,
|
||||||
|
toggleDarkMode,
|
||||||
|
}: {
|
||||||
|
isDarkMode: boolean;
|
||||||
|
toggleDarkMode: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={toggleDarkMode}
|
||||||
|
className="bg-gray-200 dark:bg-gray-800 text-gray-800 dark:text-gray-200 rounded-full p-2 shadow-md"
|
||||||
|
aria-label="Toggle Dark Mode"
|
||||||
|
>
|
||||||
|
{isDarkMode ? "🌙" : "☀️"}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
84
src/lib/api.tsx
Normal file
84
src/lib/api.tsx
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
import { Book, Chapter, Editor } 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}`;
|
||||||
|
|
||||||
|
const headers: HeadersInit = {
|
||||||
|
Authorization: `Bearer ${API_TOKEN}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
};
|
||||||
|
|
||||||
|
const config: RequestInit = {
|
||||||
|
method: "GET", // Default method is GET
|
||||||
|
headers,
|
||||||
|
...options,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, config);
|
||||||
|
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();
|
||||||
|
console.log(responseJson)
|
||||||
|
return responseJson;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Fetch error:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches all books from the API.
|
||||||
|
* 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=*");
|
||||||
|
return data.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchBookChapterLinks(bookId: string): Promise<Book> {
|
||||||
|
const currentDate = new Date().toISOString().split("T")[0];
|
||||||
|
const data = await fetchFromAPI<{ data: Book }>(`/api/books/${bookId}?populate[chapters][filters][ReleaseDate][$lte]=${currentDate}`);
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches a specific book by ID with its chapters.
|
||||||
|
* Filters chapters by release date to include only valid ones.
|
||||||
|
*/
|
||||||
|
export async function fetchBookById(bookId: string): Promise<Book> {
|
||||||
|
const currentDate = new Date().toISOString().split("T")[0];
|
||||||
|
const data = await fetchFromAPI<{ data: Book }>(
|
||||||
|
`/api/books/${bookId}?populate[chapters][filters][ReleaseDate][$lte]=${currentDate}`
|
||||||
|
);
|
||||||
|
return data.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches all editors.
|
||||||
|
*/
|
||||||
|
export async function fetchEditors(): Promise<Editor[]> {
|
||||||
|
const data = await fetchFromAPI<{ data: Editor[] }>("/api/editors");
|
||||||
|
return data.data;
|
||||||
|
}
|
65
src/lib/types.tsx
Normal file
65
src/lib/types.tsx
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
// src/lib/types.ts
|
||||||
|
export interface Chapter {
|
||||||
|
id: number;
|
||||||
|
documentId: string;
|
||||||
|
Name: string;
|
||||||
|
Chapter_Number: number;
|
||||||
|
ReleaseDate: string;
|
||||||
|
Content: string;
|
||||||
|
book?: Book;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Editor {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
books: Book[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Glossary {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
entries: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Media {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
alternativeText?: string; // Optional field
|
||||||
|
caption?: string; // Optional field
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
formats?: {
|
||||||
|
thumbnail?: { url: string; width: number; height: number };
|
||||||
|
small?: { url: string; width: number; height: number };
|
||||||
|
medium?: { url: string; width: number; height: number };
|
||||||
|
large?: { url: string; width: number; height: number };
|
||||||
|
};
|
||||||
|
hash: string;
|
||||||
|
ext: string;
|
||||||
|
mime: string;
|
||||||
|
size: number;
|
||||||
|
url: string; // The URL to access the media
|
||||||
|
previewUrl?: string; // Optional preview URL
|
||||||
|
provider: string;
|
||||||
|
provider_metadata?: string; // Metadata specific to the provider
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Book {
|
||||||
|
id: number;
|
||||||
|
documentId: string;
|
||||||
|
Name: string;
|
||||||
|
ReleaseDate: string;
|
||||||
|
chapters: Chapter[];
|
||||||
|
Cover: Media | null;
|
||||||
|
Author: string;
|
||||||
|
Complete: boolean;
|
||||||
|
editors: Editor[];
|
||||||
|
RawName: string;
|
||||||
|
RawAuthor: string;
|
||||||
|
glossary: Glossary;
|
||||||
|
Description: string;
|
||||||
|
}
|
||||||
|
|
7
src/lib/utils.tsx
Normal file
7
src/lib/utils.tsx
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
export function formatDateToMonthDayYear(date: Date): string {
|
||||||
|
return date.toLocaleDateString("en-US", {
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
}
|
@ -14,5 +14,9 @@ export default {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [],
|
darkMode: "class",
|
||||||
|
plugins: [
|
||||||
|
require("@tailwindcss/typography"),
|
||||||
|
require("@tailwindcss/line-clamp"),
|
||||||
|
require('@tailwindcss/aspect-ratio')],
|
||||||
} satisfies Config;
|
} satisfies Config;
|
||||||
|
Loading…
Reference in New Issue
Block a user