diff --git a/next.config.ts b/next.config.ts index 7330808..e685c6e 100644 --- a/next.config.ts +++ b/next.config.ts @@ -48,6 +48,7 @@ const nextConfig: NextConfig = { }, ]; }, + poweredByHeader: false, reactStrictMode: true, output: "standalone", }; diff --git a/src/app/page.tsx b/src/app/page.tsx index a94d78b..c12afac 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,150 +1,132 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useCallback } from "react"; import TableWrapper from "@/components/table-wrapper"; import Header from "@/components/header"; import TotalDisplay from "@/components/total-display"; import { toast } from "sonner"; import { type Budgetable, areRowsEqual } from "@/lib/utils"; +const DEFAULT_NEW_ROW: Budgetable = { + id: "", + title: "", + price: 0, + link: "", + note: "", + status: "Unpaid", +}; + +const ENDPOINT = "/pocketbase"; + export default function App() { - const [data, setData] = useState(() => []); - const [isEditing, setIsEditing] = useState(false); - const [loading, setLoading] = useState(false); - const [newRow, setNewRow] = useState({ - id: "", - title: "", - price: 0, - link: "", - note: "", - status: "Unpaid", - }); - const [recentlyUpdatedRowId, setRecentlyUpdatedRowId] = useState< - string | null - >(null); + const [data, setData] = useState([]); + const [isEditing, setIsEditing] = useState(false); + const [newRow, setNewRow] = useState(DEFAULT_NEW_ROW); + const [recentlyUpdatedRowId, setRecentlyUpdatedRowId] = useState(null); - useEffect(() => { - async function fetchData() { - setLoading(true); - try { - const res = await fetch("/pocketbase"); - if (!res.ok) throw new Error("Failed to fetch data"); - const records: Budgetable[] = await res.json(); - setData(records); - } catch (err) { - toast.error("Error fetching data. Please try again later."); - console.error("Error fetching data:", err); - } finally { - setLoading(false); - } - } + const fetchData = useCallback(async () => { + try { + const res = await fetch(ENDPOINT); + if (!res.ok) throw new Error("Failed to fetch data"); + const records: Budgetable[] = await res.json(); + setData(records); + } catch (err) { + toast.error("Error fetching data. Please try again later."); + console.error(err); + } + }, []); - fetchData(); - }, []); + useEffect(() => { + fetchData(); + }, [fetchData]); - const handleSave = async ( - updatedRow: Budgetable, - originalRow: Budgetable, - ) => { - if (areRowsEqual(updatedRow, originalRow)) { - return; - } + const handleSave = useCallback(async (updatedRow: Budgetable, originalRow: Budgetable) => { + if (areRowsEqual(updatedRow, originalRow)) return; - try { - const res = await fetch(`/pocketbase/${updatedRow.id}`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(updatedRow), - }); - if (!res.ok) throw new Error("Failed to update row"); - const updatedData = await res.json(); - setData((prev) => - prev.map((row) => (row.id === updatedRow.id ? updatedData : row)), - ); + try { + const res = await fetch(`${ENDPOINT}/${updatedRow.id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(updatedRow), + }); + if (!res.ok) throw new Error("Failed to update row"); - setRecentlyUpdatedRowId(updatedRow.id); - setTimeout(() => setRecentlyUpdatedRowId(null), 500); - toast.success("Row updated successfully!"); - } catch (err) { - toast.error("Error updating row. Please try again."); - console.error("Error updating row:", err); - } - }; + const updatedData = await res.json(); + setData((prev) => prev.map((row) => (row.id === updatedRow.id ? updatedData : row))); - const handleAddRow = async () => { - if (!newRow.title || newRow.price <= 0) { - toast("Title and price are required."); - return; - } + setRecentlyUpdatedRowId(updatedRow.id); + setTimeout(() => setRecentlyUpdatedRowId(null), 500); + toast.success("Row updated successfully!"); + } catch (err) { + toast.error("Error updating row. Please try again."); + console.error(err); + } + }, []); - try { - const res = await fetch("/pocketbase", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(newRow), - }); - if (!res.ok) throw new Error("Failed to add row"); - const record: Budgetable = await res.json(); - setData((prev) => [...prev, record]); - setNewRow({ - id: "", - title: "", - price: 0, - link: "", - note: "", - status: "Unpaid", - }); - toast.success("Row added successfully!"); - } catch (err) { - toast.error("Error adding row. Please try again."); - console.error("Error adding row:", err); - } - }; + const handleAddRow = useCallback(async () => { + if (!newRow.title || newRow.price <= 0) { + toast.error("Title and price are required."); + return; + } - const handleDeleteRow = async (id: string) => { - try { - const res = await fetch(`/pocketbase/${id}`, { method: "DELETE" }); - if (!res.ok) throw new Error("Failed to delete row"); - setData((prev) => prev.filter((row) => row.id !== id)); - toast.success("Row deleted successfully!"); - } catch (err) { - toast.error("Error deleting row. Please try again."); - console.error("Error deleting row:", err); - } - }; + try { + const res = await fetch(ENDPOINT, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(newRow), + }); + if (!res.ok) throw new Error("Failed to add row"); - const toggleStatus = async (row: Budgetable) => { - const updatedStatus: "Paid" | "Unpaid" = - row.status === "Paid" ? "Unpaid" : "Paid"; - const updatedRow: Budgetable = { ...row, status: updatedStatus }; - await handleSave(updatedRow, row); - setData((prev) => - prev.map((item) => (item.id === row.id ? updatedRow : item)), - ); - }; + const record: Budgetable = await res.json(); + setData((prev) => [...prev, record]); + setNewRow(DEFAULT_NEW_ROW); + toast.success("Row added successfully!"); + } catch (err) { + toast.error("Error adding row. Please try again."); + console.error(err); + } + }, [newRow]); - const total = data.reduce( - (sum, item) => sum + (item.status === "Unpaid" ? item.price : 0), - 0, - ); + const handleDeleteRow = useCallback(async (id: string) => { + try { + const res = await fetch(`${ENDPOINT}/${id}`, { method: "DELETE" }); + if (!res.ok) throw new Error("Failed to delete row"); - return ( -
- {loading} -
- - -
- ); + setData((prev) => prev.filter((row) => row.id !== id)); + toast.success("Row deleted successfully!"); + } catch (err) { + toast.error("Error deleting row. Please try again."); + console.error(err); + } + }, []); + + const toggleStatus = useCallback(async (row: Budgetable) => { + const updatedStatus = row.status === "Paid" ? "Unpaid" : "Paid"; + const updatedRow: Budgetable = { ...row, status: updatedStatus as "Paid" | "Unpaid" }; + await handleSave(updatedRow, row); + }, [handleSave]); + + const total = data.reduce( + (sum, item) => sum + (item.status === "Unpaid" ? item.price : 0), + 0, + ); + + return ( +
+
+ + +
+ ); } diff --git a/src/app/pocketbase/[id]/route.ts b/src/app/pocketbase/[id]/route.ts index 5e3cbc1..cbbe121 100644 --- a/src/app/pocketbase/[id]/route.ts +++ b/src/app/pocketbase/[id]/route.ts @@ -1,161 +1,84 @@ import pb from "@/lib/pocketbase"; +import { ResponseHelper } from "@/lib/helper"; +import { RESPONSE } from "@/lib/const"; +import { Budgetable } from "@/lib/utils"; +const { INTERNAL_SERVER_ERROR, MISSING_ID, FAILED_TO_DELETE_DATA, FAILED_TO_UPDATE_DATA, INVALID_DATA, SUCCESS, CREATED } = RESPONSE; const { EMAIL, PASSWORD, COLLECTION = "budgetable" } = process.env; -async function authenticateSuperuser() { - if (!EMAIL || !PASSWORD) { - throw new Error("Environment variables EMAIL and PASSWORD must be set"); - } - if (!pb.authStore.isValid) { - await pb.collection("_superusers").authWithPassword(EMAIL, PASSWORD); - } +async function authenticateSuperuser(): Promise { + if (!EMAIL || !PASSWORD) { + throw new Error("Environment variables EMAIL and PASSWORD must be set"); + } + if (!pb.authStore.isValid) { + await pb.collection("_superusers").authWithPassword(EMAIL, PASSWORD); + } } export async function GET( - _req: Request, - context: { params: Promise<{ id: string }> }, -) { - try { - await authenticateSuperuser(); + _req: Request, + context: { params: Promise<{ id: string }> } +): Promise { + try { + await authenticateSuperuser(); - const id = (await context.params)?.id; - if (!id) { - return Response.json( - { - error: { - message: "Missing ID in request", - }, - }, - { - status: 400, - headers: { "Content-Type": "application/json" }, - }, - ); - } + const id = (await context.params)?.id; + if (!id) { + return ResponseHelper.error(MISSING_ID); + } - const record = await pb.collection(COLLECTION).getOne(id); - return Response.json(record, { - status: 200, - headers: { "Content-Type": "application/json" }, - }); - } catch (error) { - console.error("Error fetching data:", error); - return Response.json( - { - error: { - message: "Failed to fetch data", - }, - }, - { - status: 500, - headers: { "Content-Type": "application/json" }, - }, - ); - } + const record: Budgetable = await pb.collection(COLLECTION).getOne(id); + return ResponseHelper.success(record, CREATED.STATUS); + } catch (error) { + console.error("Error fetching data:", error); + return ResponseHelper.error(INTERNAL_SERVER_ERROR, error); + } } export async function DELETE( - _req: Request, - context: { params: Promise<{ id: string }> }, -) { - try { - await authenticateSuperuser(); + _req: Request, + context: { params: Promise<{ id: string }> } +): Promise { + try { + await authenticateSuperuser(); - const id = (await context.params)?.id; - if (!id) { - return Response.json( - { - error: { - message: "Missing ID in request", - }, - }, - { - status: 400, - headers: { "Content-Type": "application/json" }, - }, - ); - } + const id = (await context.params)?.id; + if (!id) { + return ResponseHelper.error(MISSING_ID); + } - await pb.collection(COLLECTION).delete(id); - return Response.json( - { - success: true, - }, - { - status: 200, - headers: { "Content-Type": "application/json" }, - }, - ); - } catch (error) { - console.error("Error deleting data:", error); - return Response.json( - { - error: { - message: "Failed to delete data", - }, - }, - { - status: 500, - headers: { "Content-Type": "application/json" }, - }, - ); - } + await pb.collection(COLLECTION).delete(id); + return ResponseHelper.success(SUCCESS.MESSAGE); + } catch (error) { + console.error("Error deleting data:", error); + return ResponseHelper.error(FAILED_TO_DELETE_DATA, error); + } } export async function PUT( - req: Request, - context: { params: Promise<{ id: string }> }, -) { - try { - await authenticateSuperuser(); + req: Request, + context: { params: Promise<{ id: string }> } +): Promise { + try { + await authenticateSuperuser(); - const id = (await context.params)?.id; - if (!id) { - return Response.json( - { - error: { - message: "Missing ID in request", - }, - }, - { - status: 400, - headers: { "Content-Type": "application/json" }, - }, - ); - } + const id = (await context.params)?.id; + if (!id) { + return ResponseHelper.error(MISSING_ID); + } - const body = await req.json(); - if (!body.title || typeof body.price !== "number") { - return Response.json( - { - error: { - message: "Invalid data provided", - }, - }, - { - status: 400, - headers: { "Content-Type": "application/json" }, - }, - ); - } + const body: Partial = await req.json(); + if (!body.title || typeof body.price !== "number") { + return ResponseHelper.error(INVALID_DATA); + } - const updatedRecord = await pb.collection(COLLECTION).update(id, body); - return Response.json(updatedRecord, { - status: 200, - headers: { "Content-Type": "application/json" }, - }); - } catch (error) { - console.error("Error updating data:", error); - return Response.json( - { - error: { - message: "Failed to update data", - }, - }, - { - status: 500, - headers: { "Content-Type": "application/json" }, - }, - ); - } + const updatedRecord: Budgetable = await pb + .collection(COLLECTION) + .update(id, body); + + return ResponseHelper.success(updatedRecord); + } catch (error) { + console.error("Error updating data:", error); + return ResponseHelper.error(FAILED_TO_UPDATE_DATA, error); + } } diff --git a/src/app/pocketbase/route.ts b/src/app/pocketbase/route.ts index 215470f..60e604d 100644 --- a/src/app/pocketbase/route.ts +++ b/src/app/pocketbase/route.ts @@ -1,61 +1,48 @@ import pb from "@/lib/pocketbase"; +import { ResponseHelper } from "@/lib/helper"; +import { RESPONSE } from "@/lib/const"; +import { Budgetable } from "@/lib/utils"; +const { INTERNAL_SERVER_ERROR } = RESPONSE; const { EMAIL, PASSWORD, COLLECTION = "budgetable" } = process.env; -async function authenticateSuperuser() { - if (!EMAIL || !PASSWORD) { - throw new Error("Environment variables EMAIL and PASSWORD must be set"); - } - if (!pb.authStore.isValid) { - await pb.collection("_superusers").authWithPassword(EMAIL, PASSWORD); - } +async function authenticateSuperuser(): Promise { + if (!EMAIL || !PASSWORD) { + throw new Error("Environment variables EMAIL and PASSWORD must be set"); + } + if (!pb.authStore.isValid) { + await pb.collection("_superusers").authWithPassword(EMAIL, PASSWORD); + } } -export async function GET() { - try { - await authenticateSuperuser(); - const records = await pb.collection(COLLECTION).getFullList(); - return Response.json(records, { - status: 200, - headers: { "Content-Type": "application/json" }, - }); - } catch (error) { - console.error("Error fetching data:", error); - return Response.json( - { - error: { - message: "Failed to fetch data", - }, - }, - { - status: 500, - headers: { "Content-Type": "application/json" }, - }, - ); - } +export async function GET(): Promise { + try { + await authenticateSuperuser(); + + const records: Budgetable[] = await pb + .collection(COLLECTION) + .getFullList(); + + return ResponseHelper.success(records); + } catch (error) { + console.error("Error fetching data:", error); + return ResponseHelper.error(INTERNAL_SERVER_ERROR, error); + } } -export async function POST(req: Request) { - try { - await authenticateSuperuser(); - const data = await req.json(); - const record = await pb.collection(COLLECTION).create(data); - return Response.json(record, { - status: 201, - headers: { "Content-Type": "application/json" }, - }); - } catch (error) { - console.error("Error adding data:", error); - return Response.json( - { - error: { - message: "Failed to add data", - }, - }, - { - status: 500, - headers: { "Content-Type": "application/json" }, - }, - ); - } +export async function POST(req: Request): Promise { + try { + await authenticateSuperuser(); + + const data: Omit = await req.json(); + + const record: Budgetable = await pb + .collection(COLLECTION) + .create(data); + + return ResponseHelper.success(record); + } catch (error) { + console.error("Error adding data:", error); + return ResponseHelper.error(INTERNAL_SERVER_ERROR, error); + } } diff --git a/src/components/header.tsx b/src/components/header.tsx index 18c937e..1b6aa39 100644 --- a/src/components/header.tsx +++ b/src/components/header.tsx @@ -8,7 +8,7 @@ interface AppHeaderProps { const Header = ({ isEditing, setIsEditing }: AppHeaderProps) => (
-

+

( Budgetable

-
); diff --git a/src/components/status-badge.tsx b/src/components/status-badge.tsx index 0ce75d9..6aa1b57 100644 --- a/src/components/status-badge.tsx +++ b/src/components/status-badge.tsx @@ -8,7 +8,7 @@ interface StatusBadgeProps { const StatusBadge = ({ status, toggleStatus }: StatusBadgeProps) => ( {status} diff --git a/src/components/table-row.tsx b/src/components/table-row.tsx index a9d4278..4fbee48 100644 --- a/src/components/table-row.tsx +++ b/src/components/table-row.tsx @@ -84,7 +84,7 @@ const TRow = ({ onBlur={() => handleSave(row, originalRow)} /> ) : ( - {row.price.toLocaleString()} HUF + {row.price.toLocaleString()} )} diff --git a/src/components/table-wrapper.tsx b/src/components/table-wrapper.tsx index 7361e84..9408c00 100644 --- a/src/components/table-wrapper.tsx +++ b/src/components/table-wrapper.tsx @@ -37,7 +37,7 @@ const TWrapper = ({ handleDeleteRow, toggleStatus, }: TWrapperProps) => ( - +
Title diff --git a/src/components/total-display.tsx b/src/components/total-display.tsx index 58502a6..cec0beb 100644 --- a/src/components/total-display.tsx +++ b/src/components/total-display.tsx @@ -5,7 +5,7 @@ interface TotalDisplayProps { } const TotalDisplay = ({ total }: TotalDisplayProps) => ( -
+
Total:
); diff --git a/src/lib/const.ts b/src/lib/const.ts new file mode 100644 index 0000000..f98faee --- /dev/null +++ b/src/lib/const.ts @@ -0,0 +1,29 @@ +type ResponseConstant = { + STATUS: number; + MESSAGE: string; + }; + + type ResponseMap = { + [key: string]: ResponseConstant; + }; + + export enum HttpStatus { + OK = 200, + CREATED = 201, + BAD_REQUEST = 400, + INTERNAL_SERVER_ERROR = 500, + } + + function createResponse(status: number, message: string): ResponseConstant { + return { STATUS: status, MESSAGE: message }; + } + + export const RESPONSE: ResponseMap = { + INTERNAL_SERVER_ERROR: createResponse(HttpStatus.INTERNAL_SERVER_ERROR, "Internal server error."), + MISSING_ID: createResponse(HttpStatus.BAD_REQUEST, "Missing ID in request."), + FAILED_TO_DELETE_DATA: createResponse(HttpStatus.INTERNAL_SERVER_ERROR, "Failed to delete data."), + FAILED_TO_UPDATE_DATA: createResponse(HttpStatus.INTERNAL_SERVER_ERROR, "Failed to update data."), + INVALID_DATA: createResponse(HttpStatus.BAD_REQUEST, "Invalid data provided."), + SUCCESS: createResponse(HttpStatus.OK, "Operation completed successfully."), + CREATED: createResponse(HttpStatus.CREATED, "Resource created successfully."), + }; diff --git a/src/lib/helper.ts b/src/lib/helper.ts new file mode 100644 index 0000000..c13a1d0 --- /dev/null +++ b/src/lib/helper.ts @@ -0,0 +1,46 @@ +import { RESPONSE } from "@/lib/const"; + +interface ErrorResponse { + error: { + message: string; + details?: unknown; + }; +} + +type SuccessResponse = T; + +type ResponseData = SuccessResponse | ErrorResponse; + +interface ResponseOptions { + status?: number; +} + +export class ResponseHelper { + private data: ResponseData; + private status: number; + + constructor(data: ResponseData, options: ResponseOptions = {}) { + this.data = data; + this.status = options.status || 200; + } + + static success(data: T, status = RESPONSE.SUCCESS.STATUS): Response { + return new ResponseHelper(data, { status }).toResponse(); + } + + static error( + constant: (typeof RESPONSE)[keyof typeof RESPONSE], + details?: unknown + ): Response { + return new ResponseHelper( + { error: { message: constant.MESSAGE, details } }, + { status: constant.STATUS } + ).toResponse(); + } + + toResponse(): Response { + return Response.json(this.data, { + status: this.status, + }); + } +}