-
+
Add Chapter
@@ -72,6 +72,7 @@
\ No newline at end of file
diff --git a/public/assets/css/main.css b/public/assets/css/main.css
index 5a81c91..5a5db65 100644
--- a/public/assets/css/main.css
+++ b/public/assets/css/main.css
@@ -1,6 +1,10 @@
@import "tailwindcss";
@import "@nuxt/ui";
+.bg-accented {
+ @apply dark:bg-zinc-800
+}
+
.container {
width: 100%; /* Default 100% lebar */
padding-left: 15px; /* Padding default untuk mobile */
diff --git a/server/api/manga/[mangaID].get.ts b/server/api/manga/[mangaID].get.ts
index 3403ef8..94b541d 100644
--- a/server/api/manga/[mangaID].get.ts
+++ b/server/api/manga/[mangaID].get.ts
@@ -28,20 +28,21 @@ export default defineEventHandler(async (event) => {
manga = result.rows[0];
return {
- id: manga.manga_id,
- title: manga.manga_title,
- original_title: manga.manga_original_title,
- description: manga.manga_description,
- author: manga.manga_author,
- cover: manga.manga_cover,
- ratings: manga.manga_ratings,
- genre: mangaGenre.rows.map((g: any) => g.name),
- chapters: manga.chapters.map((chapter: any) => ({
- id: chapter.id,
- name: chapter.name,
- number: chapter.number,
- date_added: chapter.date_added,
- views: chapter.views
+ manga_id: manga.manga_id,
+ manga_title: manga.manga_title,
+ manga_original_title: manga.manga_original_title,
+ manga_description: manga.manga_description,
+ manga_author: manga.manga_author,
+ manga_cover: manga.manga_cover,
+ manga_ratings: manga.manga_ratings,
+ manga_genres: mangaGenre.rows.map((g: any) => ({
+ genre_name: g.name
+ })),
+ manga_chapters: manga.chapters.map((chapter: any) => ({
+ chapter_id: chapter.number,
+ chapter_name: chapter.name,
+ chapter_date_added: chapter.date_added,
+ chapter_views: chapter.views
})),
};
});
diff --git a/server/api/manga/[mangaID]/[chapterID]/comments/[commentID].delete.ts b/server/api/manga/[mangaID]/[chapterID]/comments/[commentID].delete.ts
new file mode 100644
index 0000000..c6a5a88
--- /dev/null
+++ b/server/api/manga/[mangaID]/[chapterID]/comments/[commentID].delete.ts
@@ -0,0 +1,94 @@
+/**
+ * @summary Delete a user's comment.
+ * @description Deletes a specific comment. This endpoint requires the user to be authenticated.
+ * A user can only delete their own comments.
+ *
+ * @param {string} commentID - The ID of the comment to delete.
+ * @param {string} mangaID - The ID of the manga (used for URL structure, not in logic).
+ * @param {string} chapterID - The ID of the chapter (used for URL structure, not in logic).
+ *
+ * @body {{ commentId: number }} - The request body must be a JSON object containing the ID of the comment to be deleted.
+ *
+ * @returns {200, { message: string }} - On success, returns a confirmation message.
+ * @returns {400} - If the commentId is missing from the request body.
+ * @returns {401} - If the user is not authenticated or the token is invalid.
+ * @returns {403} - If the user tries to delete a comment that does not exist or does not belong to them.
+ * @returns {500} - For any other server-side errors.
+ */
+
+import jwt from "jsonwebtoken";
+import { db } from "~/server/utils/db";
+
+export default defineEventHandler(async (event) => {
+ // 1. Get User ID from JWT token (Authentication)
+ const token = getCookie(event, "auth-token");
+ if (!token) {
+ throw createError({
+ statusCode: 401,
+ message: "Authentication token is required",
+ });
+ }
+
+ let userID: string;
+ try {
+ const JWT_SECRET = process.env.JWT_SECRET;
+ if (!JWT_SECRET) {
+ throw new Error("Server configuration error: JWT_SECRET is not set.");
+ }
+ const decoded = jwt.verify(token, JWT_SECRET);
+ if (typeof decoded === "object" && decoded !== null && "id" in decoded) {
+ userID = (decoded as jwt.JwtPayload).id;
+ } else {
+ throw new Error("Invalid token payload.");
+ }
+ } catch (error) {
+ console.error("JWT verification failed:", error);
+ throw createError({
+ statusCode: 401,
+ message: "Invalid or expired authentication token",
+ });
+ }
+
+ // 2. Read comment ID from params
+ let commentID = getRouterParam(event, 'commentID')
+
+ if (!commentID) {
+ throw createError({
+ statusCode: 400,
+ message: "Comment ID is required in the request body.",
+ });
+ }
+
+ // 3. Delete from database, ensuring the user owns the comment
+ try {
+ const result = await db.query(
+ `DELETE FROM chapter_comments
+ WHERE id = $1 AND user_ID = $2`,
+ [commentID, userID]
+ );
+
+ // Check if any row was actually deleted
+ if (result.rowCount === 0) {
+ // This happens if the comment doesn't exist OR the user doesn't own it.
+ // For security, we don't tell the user which one it is.
+ throw createError({
+ statusCode: 403, // Forbidden
+ message: "Comment not found or you do not have permission to delete it.",
+ });
+ }
+
+ return {
+ message: "Comment deleted successfully",
+ };
+ } catch (error: any) {
+ // Re-throw specific errors, otherwise throw a generic 500
+ if (error.statusCode) {
+ throw error;
+ }
+ console.error("Error deleting comment:", error);
+ throw createError({
+ statusCode: 500,
+ message: "An unexpected error occurred while deleting the comment.",
+ });
+ }
+});
diff --git a/server/api/manga/[mangaID]/[chapterID]/comments/index.post.ts b/server/api/manga/[mangaID]/[chapterID]/comments/index.post.ts
index ef43cbe..de6244c 100644
--- a/server/api/manga/[mangaID]/[chapterID]/comments/index.post.ts
+++ b/server/api/manga/[mangaID]/[chapterID]/comments/index.post.ts
@@ -1,13 +1,27 @@
-import jwt from 'jsonwebtoken';
+/**
+ * @summary Add a comment to a specific chapter of a manga.
+ * @description This endpoint allows users to add comments to a specific chapter of a manga.
+ * It requires the user to be authenticated via a JWT token stored in a cookie named 'auth-token'.
+ * The comment is associated with the user ID extracted from the token.
+ *
+ * @param {string} mangaID - Manga ID, extracted from the route parameters.
+ * @param {string} chapterID - Chapter ID, extracted from the route parameters.
+ *
+ * @body {{ comment: string }} - The comment to be added.
+ *
+ * @returns {200, { id: number, message: string }} - Returns an object containing the ID of the newly added comment and a success message.
+ */
+
+import jwt from "jsonwebtoken";
export default defineEventHandler(async (event) => {
- let manga_id = getRouterParam(event, 'mangaID') as string | undefined;
- let chapter_id = getRouterParam(event, 'chapterID') as string | undefined;
-
+ let manga_id = getRouterParam(event, "mangaID") as string | undefined;
+ let chapter_id = getRouterParam(event, "chapterID") as string | undefined;
+
if (!manga_id || !chapter_id) {
throw createError({
statusCode: 400,
- message: 'Manga ID and Chapter ID are required',
+ message: "Manga ID and Chapter ID are required",
});
}
@@ -17,26 +31,29 @@ export default defineEventHandler(async (event) => {
// get user ID from JWT token
let userID = "";
- const token = getCookie(event, 'auth-token');
+ const token = getCookie(event, "auth-token");
if (!token) {
throw createError({
statusCode: 401,
- message: 'Authentication token is required',
+ message: "Authentication token is required",
});
}
let decoded = null;
try {
- const JWT_SECRET = process.env.JWT_SECRET || "secretgoeshere,butthisshouldbechanged>:(";
+ const JWT_SECRET = process.env.JWT_SECRET;
+ if (!JWT_SECRET) {
+ throw new Error("Configuration Incomplete: JWT_SECRET is not set");
+ }
decoded = jwt.verify(token, JWT_SECRET);
} catch (error) {
- console.error('JWT verification failed:', error);
+ console.error("JWT verification failed:", error);
throw createError({
statusCode: 401,
- message: 'Invalid authentication token',
+ message: "Invalid authentication token",
});
}
-
+
if (typeof decoded === "object" && decoded !== null && "id" in decoded) {
userID = (decoded as jwt.JwtPayload).id;
} else {
@@ -49,7 +66,7 @@ export default defineEventHandler(async (event) => {
if (!comment || !userID) {
throw createError({
statusCode: 400,
- message: 'Comment and User ID are required',
+ message: "Comment and User ID are required",
});
}
@@ -67,12 +84,12 @@ export default defineEventHandler(async (event) => {
if (result.rows.length === 0) {
throw createError({
statusCode: 500,
- message: 'Failed to add comment',
+ message: "Failed to add comment",
});
}
return {
id: result.rows[0].id,
- message: 'Comment added successfully'
- }
-})
\ No newline at end of file
+ message: "Comment added successfully",
+ };
+});
diff --git a/server/api/manga/[mangaID]/comments/[commentID].delete.ts b/server/api/manga/[mangaID]/comments/[commentID].delete.ts
new file mode 100644
index 0000000..360963e
--- /dev/null
+++ b/server/api/manga/[mangaID]/comments/[commentID].delete.ts
@@ -0,0 +1,93 @@
+/**
+ * @summary Delete a user's comment.
+ * @description Deletes a specific comment. This endpoint requires the user to be authenticated.
+ * A user can only delete their own comments.
+ *
+ * @param {string} commentID - The ID of the comment to delete.
+ * @param {string} mangaID - The ID of the manga (used for URL structure, not in logic).
+ *
+ * @body {{ commentId: number }} - The request body must be a JSON object containing the ID of the comment to be deleted.
+ *
+ * @returns {200, { message: string }} - On success, returns a confirmation message.
+ * @returns {400} - If the commentId is missing from the request body.
+ * @returns {401} - If the user is not authenticated or the token is invalid.
+ * @returns {403} - If the user tries to delete a comment that does not exist or does not belong to them.
+ * @returns {500} - For any other server-side errors.
+ */
+
+import jwt from "jsonwebtoken";
+import { db } from "~/server/utils/db";
+
+export default defineEventHandler(async (event) => {
+ // 1. Get User ID from JWT token (Authentication)
+ const token = getCookie(event, "auth-token");
+ if (!token) {
+ throw createError({
+ statusCode: 401,
+ message: "Authentication token is required",
+ });
+ }
+
+ let userID: string;
+ try {
+ const JWT_SECRET = process.env.JWT_SECRET;
+ if (!JWT_SECRET) {
+ throw new Error("Server configuration error: JWT_SECRET is not set.");
+ }
+ const decoded = jwt.verify(token, JWT_SECRET);
+ if (typeof decoded === "object" && decoded !== null && "id" in decoded) {
+ userID = (decoded as jwt.JwtPayload).id;
+ } else {
+ throw new Error("Invalid token payload.");
+ }
+ } catch (error) {
+ console.error("JWT verification failed:", error);
+ throw createError({
+ statusCode: 401,
+ message: "Invalid or expired authentication token",
+ });
+ }
+
+ // 2. Read comment ID from params
+ let commentID = getRouterParam(event, 'commentID')
+
+ if (!commentID) {
+ throw createError({
+ statusCode: 400,
+ message: "Comment ID is required in the request body.",
+ });
+ }
+
+ // 3. Delete from database, ensuring the user owns the comment
+ try {
+ const result = await db.query(
+ `DELETE FROM manga_comments
+ WHERE id = $1 AND user_ID = $2`,
+ [commentID, userID]
+ );
+
+ // Check if any row was actually deleted
+ if (result.rowCount === 0) {
+ // This happens if the comment doesn't exist OR the user doesn't own it.
+ // For security, we don't tell the user which one it is.
+ throw createError({
+ statusCode: 403, // Forbidden
+ message: "Comment not found or you do not have permission to delete it.",
+ });
+ }
+
+ return {
+ message: "Comment deleted successfully",
+ };
+ } catch (error: any) {
+ // Re-throw specific errors, otherwise throw a generic 500
+ if (error.statusCode) {
+ throw error;
+ }
+ console.error("Error deleting comment:", error);
+ throw createError({
+ statusCode: 500,
+ message: "An unexpected error occurred while deleting the comment.",
+ });
+ }
+});
diff --git a/server/api/manga/daily-highlights.get.ts b/server/api/manga/daily-highlights.get.ts
index 86794a7..60be6b3 100644
--- a/server/api/manga/daily-highlights.get.ts
+++ b/server/api/manga/daily-highlights.get.ts
@@ -16,5 +16,15 @@ export default defineEventHandler(async (event) => {
return [];
}
- return result.rows;
+ return result.rows.map((dh: any) => ({
+ manga_id: dh.manga_id,
+ manga_title: dh.manga_title,
+ manga_author: dh.manga_author,
+ manga_cover: dh.manga_cover,
+ chapter_id: dh.chapter_number,
+ manga_genres: dh.genres.map((g: any) => ({
+ genre_name: g.name,
+ genre_id: g.id,
+ }))
+ }));
});
diff --git a/server/api/manga/index.delete.ts b/server/api/manga/index.delete.ts
index 5cbb245..3577969 100644
--- a/server/api/manga/index.delete.ts
+++ b/server/api/manga/index.delete.ts
@@ -20,6 +20,6 @@ export default defineEventHandler(async (event) => {
return {
message: "Manga deleted successfully",
- id: result.rows[0].id,
+ manga_id: result.rows[0].id,
};
})
\ No newline at end of file
diff --git a/server/api/manga/index.get.ts b/server/api/manga/index.get.ts
index 093df8e..155b492 100644
--- a/server/api/manga/index.get.ts
+++ b/server/api/manga/index.get.ts
@@ -19,7 +19,15 @@ export default defineEventHandler(async (event) => {
FROM manga
LIMIT 30`
);
- manga = result.rows;
+ manga = result.rows.map((m) => ({
+ manga_id: m.id,
+ manga_title: m.title,
+ manga_original_title: m.original_title,
+ manga_description: m.description,
+ manga_author: m.author,
+ manga_cover: m.cover,
+ manga_ratings: m.ratings,
+ }));
return manga;
}
// Retrieve manga by ID
@@ -37,7 +45,15 @@ export default defineEventHandler(async (event) => {
message: "Manga not found",
});
}
- manga = result.rows;
+ manga = result.rows.map((m) => ({
+ manga_id: m.id,
+ manga_title: m.title,
+ manga_original_title: m.original_title,
+ manga_description: m.description,
+ manga_author: m.author,
+ manga_cover: m.cover,
+ manga_ratings: m.ratings,
+ }));
return manga[0];
} else if (title && title != "undefined") {
const result = await db.query(
@@ -53,7 +69,15 @@ export default defineEventHandler(async (event) => {
message: "Manga not found",
});
}
- manga = result.rows;
+ manga = result.rows.map((m) => ({
+ manga_id: m.id,
+ manga_title: m.title,
+ manga_original_title: m.original_title,
+ manga_description: m.description,
+ manga_author: m.author,
+ manga_cover: m.cover,
+ manga_ratings: m.ratings,
+ }))
return manga;
}
});
diff --git a/server/api/manga/latest-chapters.get.ts b/server/api/manga/latest-chapters.get.ts
index 459e7b0..3e8d6ad 100644
--- a/server/api/manga/latest-chapters.get.ts
+++ b/server/api/manga/latest-chapters.get.ts
@@ -1,10 +1,11 @@
import { db } from "~/server/utils/db";
-export default defineEventHandler(async (event) => {
+export default defineEventHandler(async () => {
const result = await db.query(
`
SELECT manga_id, manga_title, manga_cover, chapter_number, chapter_name, chapter_date_added
FROM manga_latest_chapters
+ WHERE chapter_number IS NOT NULL
ORDER BY chapter_date_added DESC
LIMIT 20`
);
@@ -16,5 +17,11 @@ export default defineEventHandler(async (event) => {
});
}
- return result.rows;
+ return result.rows.map((m) => ({
+ manga_id: m.manga_id,
+ manga_title: m.manga_title,
+ manga_cover: m.manga_cover,
+ chapter_id: m.chapter_number,
+ chapter_date_added: m.chapter_date_added,
+ }));
});
diff --git a/server/api/user/[id]/bookmarks/index.get.ts b/server/api/user/[id]/bookmarks/index.get.ts
index cd7a53f..ee2485d 100644
--- a/server/api/user/[id]/bookmarks/index.get.ts
+++ b/server/api/user/[id]/bookmarks/index.get.ts
@@ -18,7 +18,12 @@ export default defineEventHandler(async (event) => {
message: "Bookmarks not found",
});
}
- return result.rows;
+ return result.rows.map(b => ({
+ manga_id: b.manga_id,
+ manga_title: b.manga_title,
+ chapter_last_read: b.last_read_chapter,
+ chapter_date_added: b.date_added,
+ }));
} catch (error) {
console.error("Error fetching bookmarks:", error);
throw createError({
diff --git a/stores/bookmark.ts b/stores/bookmark.ts
deleted file mode 100644
index 267e879..0000000
--- a/stores/bookmark.ts
+++ /dev/null
@@ -1,15 +0,0 @@
-import { defineStore } from 'pinia';
-import { useAuthStore } from '~/stores/auth';
-
-// Source Hierarchy: 1. API/Database (logged in), 2. Local Storage
-export const useUserMangaStore = defineStore('userManga', {
- state: () => ({
- bookmarks: {} as Record,
- readingProgress: {} as Record,
- }),
-
- actions: {
-
-
- }
-})
\ No newline at end of file
diff --git a/stores/userPreference.ts b/stores/userPreference.ts
new file mode 100644
index 0000000..5e9996c
--- /dev/null
+++ b/stores/userPreference.ts
@@ -0,0 +1,25 @@
+import { defineStore } from 'pinia';
+
+export const useUserPreference = defineStore('userPreference', {
+ state: () => ({
+ // Reading View Preference
+ isLongstrip: false,
+ }),
+
+ actions: {
+ setViewMode(isLongstrip: boolean) {
+ this.isLongstrip = isLongstrip;
+ },
+ toggleViewMode() {
+ this.isLongstrip = !this.isLongstrip;
+ }
+ },
+
+ getters: {
+ getViewMode: (state) => {
+ return state.isLongstrip;
+ }
+ },
+
+ persist: true,
+})
\ No newline at end of file
diff --git a/types/database.ts b/types/database.ts
deleted file mode 100644
index 1ed3f25..0000000
--- a/types/database.ts
+++ /dev/null
@@ -1,112 +0,0 @@
-// Tipe kustom yang dibuat dari ENUM di PostgreSQL
-export type TaskStatus = "pending" | "in_progress" | "completed";
-
-// Table: role
-export interface Role {
- ID: number;
- name: string;
- description?: string | null;
-}
-
-// Table: users
-export interface User {
- ID: number;
- avatar?: string | null;
- username: string;
- name: string;
- email: string;
- password: string; // Di aplikasi, sebaiknya jangan pernah ekspos field ini ke client
- status: boolean;
- date_joined: Date;
-}
-
-// Table: user_role (Junction Table)
-export interface UserRole {
- user_ID: number;
- role_ID: number;
-}
-
-// Table: manga
-export interface Manga {
- ID: number;
- title: string;
- original_title?: string | null;
- description: string;
- author: string;
- cover: string;
- ratings: number; // Tipe NUMERIC di-mapping ke number
-}
-
-// Table: genre
-export interface Genre {
- ID: number;
- name: string;
-}
-
-// Table: manga_genre (Junction Table)
-export interface MangaGenre {
- manga_ID: number;
- genre_ID: number;
-}
-
-// Table: chapter
-export interface Chapter {
- ID: number;
- number: number; // Tipe NUMERIC di-mapping ke number
- name?: string | null;
- date_added: Date;
- manga_ID: number;
- title: string;
-}
-
-// Table: image
-export interface Image {
- ID: number; // BIGINT bisa di-mapping ke number jika tidak melebihi batas, atau string/bigint
- page_number: number;
- link: string;
- chapter_ID: number;
-}
-
-// Table: bookmark
-export interface Bookmark {
- ID: number;
- last_read_chapter_id: number;
- date_added: Date;
- user_ID: number;
- manga_ID: number;
-}
-
-// Table: manga_comments
-export interface MangaComment {
- ID: number; // BIGINT bisa di-mapping ke number atau string/bigint
- comment: string;
- date_added: Date;
- manga_ID: number;
- user_ID: number;
-}
-
-// Table: chapter_comments
-export interface ChapterComment {
- ID: number; // BIGINT bisa di--mapping ke number atau string/bigint
- comment: string;
- date_added: Date;
- chapter_ID: number;
- user_ID: number;
-}
-
-// Table: salary
-export interface Salary {
- ID: number;
- payment_date: Date;
- amount: number; // Tipe NUMERIC di-mapping ke number
- user_ID: number;
-}
-
-// Table: task
-export interface Task {
- ID: number;
- date_added: Date;
- status: TaskStatus;
- user_ID: number;
- manga_ID: number;
-}
\ No newline at end of file
diff --git a/types/manga.ts b/types/manga.ts
new file mode 100644
index 0000000..4c3cb26
--- /dev/null
+++ b/types/manga.ts
@@ -0,0 +1,27 @@
+export interface Manga {
+ manga_id: number;
+ manga_title: string;
+ manga_original_title: string;
+ manga_description: string;
+ manga_author: string;
+ manga_cover: string;
+ manga_ratings: number;
+ manga_genres?: Genre[];
+ manga_chapters?: Chapter[];
+ manga_views?: number;
+}
+
+export interface Genre {
+ genre_id: number;
+ genre_name: string;
+}
+
+export interface Chapter {
+ // Chapter_id: chapter_number in database.
+ // To look for certain chapter from a manga,
+ // do a lookout using (SQL) chapter.number + manga.id combo.
+ chapter_id: number;
+ chapter_name: string;
+ chapter_date_added: Date;
+ chapter_views: number;
+}