feat: enhance ui components and response handling

This commit is contained in:
skidoodle 2025-01-03 18:48:57 +01:00
parent 78fbcebe91
commit 83b3a747a1
Signed by: albert
GPG key ID: A06E3070D7D55BF2
11 changed files with 296 additions and 328 deletions

View file

@ -48,6 +48,7 @@ const nextConfig: NextConfig = {
}, },
]; ];
}, },
poweredByHeader: false,
reactStrictMode: true, reactStrictMode: true,
output: "standalone", output: "standalone",
}; };

View file

@ -1,150 +1,132 @@
"use client"; "use client";
import { useState, useEffect } from "react"; import { useState, useEffect, useCallback } from "react";
import TableWrapper from "@/components/table-wrapper"; import TableWrapper from "@/components/table-wrapper";
import Header from "@/components/header"; import Header from "@/components/header";
import TotalDisplay from "@/components/total-display"; import TotalDisplay from "@/components/total-display";
import { toast } from "sonner"; import { toast } from "sonner";
import { type Budgetable, areRowsEqual } from "@/lib/utils"; 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() { export default function App() {
const [data, setData] = useState<Budgetable[]>(() => []); const [data, setData] = useState<Budgetable[]>([]);
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
const [loading, setLoading] = useState(false); const [newRow, setNewRow] = useState<Budgetable>(DEFAULT_NEW_ROW);
const [newRow, setNewRow] = useState<Budgetable>({ const [recentlyUpdatedRowId, setRecentlyUpdatedRowId] = useState<string | null>(null);
id: "",
title: "",
price: 0,
link: "",
note: "",
status: "Unpaid",
});
const [recentlyUpdatedRowId, setRecentlyUpdatedRowId] = useState<
string | null
>(null);
useEffect(() => { const fetchData = useCallback(async () => {
async function fetchData() { try {
setLoading(true); const res = await fetch(ENDPOINT);
try { if (!res.ok) throw new Error("Failed to fetch data");
const res = await fetch("/pocketbase"); const records: Budgetable[] = await res.json();
if (!res.ok) throw new Error("Failed to fetch data"); setData(records);
const records: Budgetable[] = await res.json(); } catch (err) {
setData(records); toast.error("Error fetching data. Please try again later.");
} catch (err) { console.error(err);
toast.error("Error fetching data. Please try again later."); }
console.error("Error fetching data:", err); }, []);
} finally {
setLoading(false);
}
}
fetchData(); useEffect(() => {
}, []); fetchData();
}, [fetchData]);
const handleSave = async ( const handleSave = useCallback(async (updatedRow: Budgetable, originalRow: Budgetable) => {
updatedRow: Budgetable, if (areRowsEqual(updatedRow, originalRow)) return;
originalRow: Budgetable,
) => {
if (areRowsEqual(updatedRow, originalRow)) {
return;
}
try { try {
const res = await fetch(`/pocketbase/${updatedRow.id}`, { const res = await fetch(`${ENDPOINT}/${updatedRow.id}`, {
method: "PUT", method: "PUT",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(updatedRow), body: JSON.stringify(updatedRow),
}); });
if (!res.ok) throw new Error("Failed to update row"); 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)),
);
setRecentlyUpdatedRowId(updatedRow.id); const updatedData = await res.json();
setTimeout(() => setRecentlyUpdatedRowId(null), 500); setData((prev) => prev.map((row) => (row.id === updatedRow.id ? updatedData : row)));
toast.success("Row updated successfully!");
} catch (err) {
toast.error("Error updating row. Please try again.");
console.error("Error updating row:", err);
}
};
const handleAddRow = async () => { setRecentlyUpdatedRowId(updatedRow.id);
if (!newRow.title || newRow.price <= 0) { setTimeout(() => setRecentlyUpdatedRowId(null), 500);
toast("Title and price are required."); toast.success("Row updated successfully!");
return; } catch (err) {
} toast.error("Error updating row. Please try again.");
console.error(err);
}
}, []);
try { const handleAddRow = useCallback(async () => {
const res = await fetch("/pocketbase", { if (!newRow.title || newRow.price <= 0) {
method: "POST", toast.error("Title and price are required.");
headers: { "Content-Type": "application/json" }, return;
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 handleDeleteRow = async (id: string) => { try {
try { const res = await fetch(ENDPOINT, {
const res = await fetch(`/pocketbase/${id}`, { method: "DELETE" }); method: "POST",
if (!res.ok) throw new Error("Failed to delete row"); headers: { "Content-Type": "application/json" },
setData((prev) => prev.filter((row) => row.id !== id)); body: JSON.stringify(newRow),
toast.success("Row deleted successfully!"); });
} catch (err) { if (!res.ok) throw new Error("Failed to add row");
toast.error("Error deleting row. Please try again.");
console.error("Error deleting row:", err);
}
};
const toggleStatus = async (row: Budgetable) => { const record: Budgetable = await res.json();
const updatedStatus: "Paid" | "Unpaid" = setData((prev) => [...prev, record]);
row.status === "Paid" ? "Unpaid" : "Paid"; setNewRow(DEFAULT_NEW_ROW);
const updatedRow: Budgetable = { ...row, status: updatedStatus }; toast.success("Row added successfully!");
await handleSave(updatedRow, row); } catch (err) {
setData((prev) => toast.error("Error adding row. Please try again.");
prev.map((item) => (item.id === row.id ? updatedRow : item)), console.error(err);
); }
}; }, [newRow]);
const total = data.reduce( const handleDeleteRow = useCallback(async (id: string) => {
(sum, item) => sum + (item.status === "Unpaid" ? item.price : 0), try {
0, const res = await fetch(`${ENDPOINT}/${id}`, { method: "DELETE" });
); if (!res.ok) throw new Error("Failed to delete row");
return ( setData((prev) => prev.filter((row) => row.id !== id));
<main className="container mx-auto p-4 max-w-5xl"> toast.success("Row deleted successfully!");
{loading} } catch (err) {
<Header isEditing={isEditing} setIsEditing={setIsEditing} /> toast.error("Error deleting row. Please try again.");
<TotalDisplay total={total} /> console.error(err);
<TableWrapper }
data={data} }, []);
isEditing={isEditing}
setData={setData} const toggleStatus = useCallback(async (row: Budgetable) => {
newRow={newRow} const updatedStatus = row.status === "Paid" ? "Unpaid" : "Paid";
setNewRow={setNewRow} const updatedRow: Budgetable = { ...row, status: updatedStatus as "Paid" | "Unpaid" };
recentlyUpdatedRowId={recentlyUpdatedRowId} await handleSave(updatedRow, row);
handleSave={handleSave} }, [handleSave]);
handleAddRow={handleAddRow}
handleDeleteRow={handleDeleteRow} const total = data.reduce(
toggleStatus={toggleStatus} (sum, item) => sum + (item.status === "Unpaid" ? item.price : 0),
/> 0,
</main> );
);
return (
<main className="container mx-auto p-4 max-w-7xl">
<Header isEditing={isEditing} setIsEditing={setIsEditing} />
<TotalDisplay total={total} />
<TableWrapper
data={data}
isEditing={isEditing}
setData={setData}
newRow={newRow}
setNewRow={setNewRow}
recentlyUpdatedRowId={recentlyUpdatedRowId}
handleSave={handleSave}
handleAddRow={handleAddRow}
handleDeleteRow={handleDeleteRow}
toggleStatus={toggleStatus}
/>
</main>
);
} }

View file

@ -1,161 +1,84 @@
import pb from "@/lib/pocketbase"; 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; const { EMAIL, PASSWORD, COLLECTION = "budgetable" } = process.env;
async function authenticateSuperuser() { async function authenticateSuperuser(): Promise<void> {
if (!EMAIL || !PASSWORD) { if (!EMAIL || !PASSWORD) {
throw new Error("Environment variables EMAIL and PASSWORD must be set"); throw new Error("Environment variables EMAIL and PASSWORD must be set");
} }
if (!pb.authStore.isValid) { if (!pb.authStore.isValid) {
await pb.collection("_superusers").authWithPassword(EMAIL, PASSWORD); await pb.collection("_superusers").authWithPassword(EMAIL, PASSWORD);
} }
} }
export async function GET( export async function GET(
_req: Request, _req: Request,
context: { params: Promise<{ id: string }> }, context: { params: Promise<{ id: string }> }
) { ): Promise<Response> {
try { try {
await authenticateSuperuser(); await authenticateSuperuser();
const id = (await context.params)?.id; const id = (await context.params)?.id;
if (!id) { if (!id) {
return Response.json( return ResponseHelper.error(MISSING_ID);
{ }
error: {
message: "Missing ID in request",
},
},
{
status: 400,
headers: { "Content-Type": "application/json" },
},
);
}
const record = await pb.collection(COLLECTION).getOne(id); const record: Budgetable = await pb.collection<Budgetable>(COLLECTION).getOne(id);
return Response.json(record, { return ResponseHelper.success<Budgetable>(record, CREATED.STATUS);
status: 200, } catch (error) {
headers: { "Content-Type": "application/json" }, console.error("Error fetching data:", error);
}); return ResponseHelper.error(INTERNAL_SERVER_ERROR, error);
} 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 DELETE( export async function DELETE(
_req: Request, _req: Request,
context: { params: Promise<{ id: string }> }, context: { params: Promise<{ id: string }> }
) { ): Promise<Response> {
try { try {
await authenticateSuperuser(); await authenticateSuperuser();
const id = (await context.params)?.id; const id = (await context.params)?.id;
if (!id) { if (!id) {
return Response.json( return ResponseHelper.error(MISSING_ID);
{ }
error: {
message: "Missing ID in request",
},
},
{
status: 400,
headers: { "Content-Type": "application/json" },
},
);
}
await pb.collection(COLLECTION).delete(id); await pb.collection(COLLECTION).delete(id);
return Response.json( return ResponseHelper.success(SUCCESS.MESSAGE);
{ } catch (error) {
success: true, console.error("Error deleting data:", error);
}, return ResponseHelper.error(FAILED_TO_DELETE_DATA, error);
{ }
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" },
},
);
}
} }
export async function PUT( export async function PUT(
req: Request, req: Request,
context: { params: Promise<{ id: string }> }, context: { params: Promise<{ id: string }> }
) { ): Promise<Response> {
try { try {
await authenticateSuperuser(); await authenticateSuperuser();
const id = (await context.params)?.id; const id = (await context.params)?.id;
if (!id) { if (!id) {
return Response.json( return ResponseHelper.error(MISSING_ID);
{ }
error: {
message: "Missing ID in request",
},
},
{
status: 400,
headers: { "Content-Type": "application/json" },
},
);
}
const body = await req.json(); const body: Partial<Budgetable> = await req.json();
if (!body.title || typeof body.price !== "number") { if (!body.title || typeof body.price !== "number") {
return Response.json( return ResponseHelper.error(INVALID_DATA);
{ }
error: {
message: "Invalid data provided",
},
},
{
status: 400,
headers: { "Content-Type": "application/json" },
},
);
}
const updatedRecord = await pb.collection(COLLECTION).update(id, body); const updatedRecord: Budgetable = await pb
return Response.json(updatedRecord, { .collection<Budgetable>(COLLECTION)
status: 200, .update(id, body);
headers: { "Content-Type": "application/json" },
}); return ResponseHelper.success<Budgetable>(updatedRecord);
} catch (error) { } catch (error) {
console.error("Error updating data:", error); console.error("Error updating data:", error);
return Response.json( return ResponseHelper.error(FAILED_TO_UPDATE_DATA, error);
{ }
error: {
message: "Failed to update data",
},
},
{
status: 500,
headers: { "Content-Type": "application/json" },
},
);
}
} }

View file

@ -1,61 +1,48 @@
import pb from "@/lib/pocketbase"; 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; const { EMAIL, PASSWORD, COLLECTION = "budgetable" } = process.env;
async function authenticateSuperuser() { async function authenticateSuperuser(): Promise<void> {
if (!EMAIL || !PASSWORD) { if (!EMAIL || !PASSWORD) {
throw new Error("Environment variables EMAIL and PASSWORD must be set"); throw new Error("Environment variables EMAIL and PASSWORD must be set");
} }
if (!pb.authStore.isValid) { if (!pb.authStore.isValid) {
await pb.collection("_superusers").authWithPassword(EMAIL, PASSWORD); await pb.collection("_superusers").authWithPassword(EMAIL, PASSWORD);
} }
} }
export async function GET() { export async function GET(): Promise<Response> {
try { try {
await authenticateSuperuser(); await authenticateSuperuser();
const records = await pb.collection(COLLECTION).getFullList();
return Response.json(records, { const records: Budgetable[] = await pb
status: 200, .collection<Budgetable>(COLLECTION)
headers: { "Content-Type": "application/json" }, .getFullList();
});
} catch (error) { return ResponseHelper.success<Budgetable[]>(records);
console.error("Error fetching data:", error); } catch (error) {
return Response.json( console.error("Error fetching data:", error);
{ return ResponseHelper.error(INTERNAL_SERVER_ERROR, error);
error: { }
message: "Failed to fetch data",
},
},
{
status: 500,
headers: { "Content-Type": "application/json" },
},
);
}
} }
export async function POST(req: Request) { export async function POST(req: Request): Promise<Response> {
try { try {
await authenticateSuperuser(); await authenticateSuperuser();
const data = await req.json();
const record = await pb.collection(COLLECTION).create(data); const data: Omit<Budgetable, "id"> = await req.json();
return Response.json(record, {
status: 201, const record: Budgetable = await pb
headers: { "Content-Type": "application/json" }, .collection<Budgetable>(COLLECTION)
}); .create(data);
} catch (error) {
console.error("Error adding data:", error); return ResponseHelper.success<Budgetable>(record);
return Response.json( } catch (error) {
{ console.error("Error adding data:", error);
error: { return ResponseHelper.error(INTERNAL_SERVER_ERROR, error);
message: "Failed to add data", }
},
},
{
status: 500,
headers: { "Content-Type": "application/json" },
},
);
}
} }

View file

@ -8,7 +8,7 @@ interface AppHeaderProps {
const Header = ({ isEditing, setIsEditing }: AppHeaderProps) => ( const Header = ({ isEditing, setIsEditing }: AppHeaderProps) => (
<div className="flex justify-between items-center mb-6"> <div className="flex justify-between items-center mb-6">
<h1 className="text-3xl font-bold"> <h1 className="text-4xl font-bold">
<Link <Link
href="/" href="/"
className="text-blue-500 hover:text-blue-600 transition-colors" className="text-blue-500 hover:text-blue-600 transition-colors"
@ -17,8 +17,8 @@ const Header = ({ isEditing, setIsEditing }: AppHeaderProps) => (
Budgetable Budgetable
</Link> </Link>
</h1> </h1>
<Button variant="ghost" onClick={() => setIsEditing(!isEditing)}> <Button variant="ghost" onClick={() => setIsEditing(!isEditing)} size="lg">
{isEditing ? "Lock" : "Unlock"} Editing <span className='text-lg'>{isEditing ? "Lock" : "Unlock"} Editing</span>
</Button> </Button>
</div> </div>
); );

View file

@ -8,7 +8,7 @@ interface StatusBadgeProps {
const StatusBadge = ({ status, toggleStatus }: StatusBadgeProps) => ( const StatusBadge = ({ status, toggleStatus }: StatusBadgeProps) => (
<Badge <Badge
variant={status === "Paid" ? "paid" : "unpaid"} variant={status === "Paid" ? "paid" : "unpaid"}
className="cursor-pointer" className="cursor-pointer text-[16px]"
onClick={toggleStatus} onClick={toggleStatus}
> >
{status} {status}

View file

@ -84,7 +84,7 @@ const TRow = ({
onBlur={() => handleSave(row, originalRow)} onBlur={() => handleSave(row, originalRow)}
/> />
) : ( ) : (
<span>{row.price.toLocaleString()} HUF</span> <span>{row.price.toLocaleString()}</span>
)} )}
</TableCell> </TableCell>
<TableCell> <TableCell>

View file

@ -37,7 +37,7 @@ const TWrapper = ({
handleDeleteRow, handleDeleteRow,
toggleStatus, toggleStatus,
}: TWrapperProps) => ( }: TWrapperProps) => (
<Table> <Table className="text-lg">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>Title</TableHead> <TableHead>Title</TableHead>

View file

@ -5,7 +5,7 @@ interface TotalDisplayProps {
} }
const TotalDisplay = ({ total }: TotalDisplayProps) => ( const TotalDisplay = ({ total }: TotalDisplayProps) => (
<div className="mx-2 text-left font-bold text-lg"> <div className="mx-2 text-left font-bold text-2xl">
Total: <NumberFlow value={total} /> Total: <NumberFlow value={total} />
</div> </div>
); );

29
src/lib/const.ts Normal file
View file

@ -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."),
};

46
src/lib/helper.ts Normal file
View file

@ -0,0 +1,46 @@
import { RESPONSE } from "@/lib/const";
interface ErrorResponse {
error: {
message: string;
details?: unknown;
};
}
type SuccessResponse<T> = T;
type ResponseData<T> = SuccessResponse<T> | ErrorResponse;
interface ResponseOptions {
status?: number;
}
export class ResponseHelper<T = unknown> {
private data: ResponseData<T>;
private status: number;
constructor(data: ResponseData<T>, options: ResponseOptions = {}) {
this.data = data;
this.status = options.status || 200;
}
static success<T>(data: T, status = RESPONSE.SUCCESS.STATUS): Response {
return new ResponseHelper<T>(data, { status }).toResponse();
}
static error(
constant: (typeof RESPONSE)[keyof typeof RESPONSE],
details?: unknown
): Response {
return new ResponseHelper<ErrorResponse>(
{ error: { message: constant.MESSAGE, details } },
{ status: constant.STATUS }
).toResponse();
}
toResponse(): Response {
return Response.json(this.data, {
status: this.status,
});
}
}