This commit is contained in:
skidoodle 2024-12-30 17:32:55 +01:00
parent bcfa0f92a6
commit dedb1c0628
Signed by: albert
GPG key ID: A06E3070D7D55BF2
16 changed files with 316 additions and 239 deletions

View file

@ -12,6 +12,7 @@
},
"dependencies": {
"@radix-ui/react-dialog": "^1.1.4",
"@radix-ui/react-progress": "^1.1.1",
"@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-toast": "^1.2.4",
"class-variance-authority": "^0.7.1",

26
pnpm-lock.yaml generated
View file

@ -11,6 +11,9 @@ importers:
'@radix-ui/react-dialog':
specifier: ^1.1.4
version: 1.1.4(@types/react-dom@19.0.2(@types/react@19.0.2))(@types/react@19.0.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@radix-ui/react-progress':
specifier: ^1.1.1
version: 1.1.1(@types/react-dom@19.0.2(@types/react@19.0.2))(@types/react@19.0.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@radix-ui/react-slot':
specifier: ^1.1.1
version: 1.1.1(@types/react@19.0.2)(react@19.0.0)
@ -529,6 +532,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-progress@1.1.1':
resolution: {integrity: sha512-6diOawA84f/eMxFHcWut0aE1C2kyE9dOyCTQOMRR2C/qPiXz/X0SaiA/RLbapQaXUCmy0/hLMf9meSccD1N0pA==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-slot@1.1.1':
resolution: {integrity: sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g==}
peerDependencies:
@ -2397,6 +2413,16 @@ snapshots:
'@types/react': 19.0.2
'@types/react-dom': 19.0.2(@types/react@19.0.2)
'@radix-ui/react-progress@1.1.1(@types/react-dom@19.0.2(@types/react@19.0.2))(@types/react@19.0.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
dependencies:
'@radix-ui/react-context': 1.1.1(@types/react@19.0.2)(react@19.0.0)
'@radix-ui/react-primitive': 2.0.1(@types/react-dom@19.0.2(@types/react@19.0.2))(@types/react@19.0.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
react: 19.0.0
react-dom: 19.0.0(react@19.0.0)
optionalDependencies:
'@types/react': 19.0.2
'@types/react-dom': 19.0.2(@types/react@19.0.2)
'@radix-ui/react-slot@1.1.1(@types/react@19.0.2)(react@19.0.0)':
dependencies:
'@radix-ui/react-compose-refs': 1.1.1(@types/react@19.0.2)(react@19.0.0)

View file

@ -5,18 +5,12 @@ import TableWrapper from "@/components/table-wrapper";
import Header from "@/components/header";
import TotalDisplay from "@/components/total-display";
import { toast } from "sonner";
interface Budgetable {
id: string;
title: string;
price: number;
link: string;
note?: string;
status: "Paid" | "Unpaid";
}
import { Budgetable, areRowsEqual } from "@/lib/utils";
export default function App() {
const [data, setData] = useState<Budgetable[]>([]);
const [data, setData] = useState<Budgetable[]>(() => []);
const [isEditing, setIsEditing] = useState(false);
const [loading, setLoading] = useState(false);
const [newRow, setNewRow] = useState<Budgetable>({
id: "",
title: "",
@ -28,7 +22,6 @@ export default function App() {
const [recentlyUpdatedRowId, setRecentlyUpdatedRowId] = useState<
string | null
>(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
async function fetchData() {
@ -53,32 +46,46 @@ export default function App() {
updatedRow: Budgetable,
originalRow: Budgetable,
) => {
setLoading(true);
try {
await new Promise((resolve) => setTimeout(resolve, 250));
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((item) => (item.id === originalRow.id ? updatedRow : item)),
prev.map((row) => (row.id === updatedRow.id ? updatedData : row)),
);
setRecentlyUpdatedRowId(updatedRow.id);
setTimeout(() => setRecentlyUpdatedRowId(null), 250);
toast.success("Row saved successfully");
setTimeout(() => setRecentlyUpdatedRowId(null), 500);
toast.success("Row updated successfully!");
} catch (err) {
toast.error("Error saving row. Please try again.");
console.error("Error saving row:", err);
} finally {
setLoading(false);
toast.error("Error updating row. Please try again.");
console.error("Error updating row:", err);
}
};
const handleAddRow = async () => {
if (!newRow.title || newRow.price <= 0) return;
if (!newRow.title || newRow.price <= 0) {
toast("Title and price are required.");
return;
}
setLoading(true);
try {
await new Promise((resolve) => setTimeout(resolve, 250));
setData((prev) => [...prev, { ...newRow, id: `${Date.now()}` }]);
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: "",
@ -87,48 +94,39 @@ export default function App() {
note: "",
status: "Unpaid",
});
toast.success("Row added successfully");
toast.success("Row added successfully!");
} catch (err) {
toast.error("Error adding row. Please try again.");
console.error("Error adding row:", err);
} finally {
setLoading(false);
}
};
const handleDeleteRow = async (id: string) => {
setLoading(true);
try {
await new Promise((resolve) => setTimeout(resolve, 250));
setData((prev) => prev.filter((item) => item.id !== id));
toast.success("Row deleted successfully");
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);
} finally {
setLoading(false);
}
};
const toggleStatus = async (row: Budgetable) => {
setLoading(true);
try {
await new Promise((resolve) => setTimeout(resolve, 250));
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
? { ...item, status: item.status === "Paid" ? "Unpaid" : "Paid" }
: item,
),
prev.map((item) => (item.id === row.id ? updatedRow : item)),
);
} finally {
setLoading(false);
}
};
const total = data.reduce((sum, item) => sum + item.price, 0);
const total = data.reduce(
(sum, item) => sum + (item.status === "Unpaid" ? item.price : 0),
0,
);
return (
<main className="container mx-auto p-4 max-w-5xl">

View file

@ -12,7 +12,7 @@ async function authenticateSuperuser() {
}
export async function GET(
req: Request,
_req: Request,
context: { params: Promise<{ id: string }> },
) {
try {
@ -20,27 +20,36 @@ export async function GET(
const id = (await context.params)?.id;
if (!id) {
return new Response(
JSON.stringify({ error: { message: "Missing ID in request" } }),
{ status: 400, headers: { "Content-Type": "application/json" } },
);
return Response.json({
error: {
message: "Missing ID in request",
},
}, {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
const record = await pb.collection("budgetable").getOne(id);
return new Response(JSON.stringify(record), {
return Response.json(record, {
status: 200,
headers: { "Content-Type": "application/json" },
});
} catch (error) {
console.error("Error fetching data:", error);
return new Response(
JSON.stringify({ error: { message: "Failed to fetch data" } }),
{ status: 500, headers: { "Content-Type": "application/json" } },
);
return Response.json({
error: {
message: "Failed to fetch data",
},
}, {
status: 500,
headers: { "Content-Type": "application/json" },
})
}
}
export async function DELETE(
req: Request,
_req: Request,
context: { params: Promise<{ id: string }> },
) {
try {
@ -48,22 +57,33 @@ export async function DELETE(
const id = (await context.params)?.id;
if (!id) {
return new Response(
JSON.stringify({ error: { message: "Missing ID in request" } }),
{ status: 400, headers: { "Content-Type": "application/json" } },
);
return Response.json({
error: {
message: "Missing ID in request",
},
}, {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
await pb.collection("budgetable").delete(id);
return new Response(JSON.stringify({ success: true }), {
return Response.json({
success: true,
}, {
status: 200,
headers: { "Content-Type": "application/json" },
});
} catch (error) {
console.error("Error deleting data:", error);
return new Response(
JSON.stringify({ error: { message: "Failed to delete data" } }),
{ status: 500, headers: { "Content-Type": "application/json" } },
);
return Response.json({
error: {
message: "Failed to delete data",
},
}, {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
}
@ -74,31 +94,44 @@ export async function PUT(
try {
await authenticateSuperuser();
const id = (await context.params)?.id; // Use `context.params?.id`
const id = (await context.params)?.id;
if (!id) {
return new Response(
JSON.stringify({ error: { message: "Missing ID in request" } }),
{ status: 400, headers: { "Content-Type": "application/json" } },
);
return Response.json({
error: {
message: "Missing ID in request",
},
}, {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
const body = await req.json();
if (!body.title || typeof body.price !== "number") {
return new Response(
JSON.stringify({ error: { message: "Invalid data provided" } }),
{ status: 400, headers: { "Content-Type": "application/json" } },
);
return Response.json({
error: {
message: "Invalid data provided",
},
}, {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
const updatedRecord = await pb.collection("budgetable").update(id, body);
return new Response(JSON.stringify(updatedRecord), {
return Response.json(updatedRecord, {
status: 200,
headers: { "Content-Type": "application/json" },
});
} catch (error) {
console.error("Error updating data:", error);
return new Response(
JSON.stringify({ error: { message: "Failed to update data" } }),
{ status: 500, headers: { "Content-Type": "application/json" } },
);
return Response.json({
error: {
message: "Failed to update data",
},
}, {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
}

View file

@ -15,22 +15,20 @@ export async function GET() {
try {
await authenticateSuperuser();
const records = await pb.collection("budgetable").getFullList();
return new Response(JSON.stringify(records), {
return Response.json(records, {
status: 200,
headers: { "Content-Type": "application/json" },
});
} catch (error) {
console.error("Error fetching data:", error);
return new Response(
JSON.stringify({
return Response.json({
error: {
message: "Failed to fetch data",
},
}),
{
}, {
status: 500,
headers: { "Content-Type": "application/json" },
},
);
});
}
}
@ -39,21 +37,19 @@ export async function POST(req: Request) {
await authenticateSuperuser();
const data = await req.json();
const record = await pb.collection("budgetable").create(data);
return new Response(JSON.stringify(record), {
return Response.json(record, {
status: 201,
headers: { "Content-Type": "application/json" },
});
} catch (error) {
console.error("Error adding data:", error);
return new Response(
JSON.stringify({
return Response.json({
error: {
message: "Failed to add data",
},
}),
{
}, {
status: 500,
headers: { "Content-Type": "application/json" },
},
);
});
}
}

View file

@ -12,7 +12,7 @@ interface DeleteDialogProps {
onConfirm: () => void;
}
const DeleteDialog: React.FC<DeleteDialogProps> = ({ onConfirm }) => (
const DeleteDialog = ({ onConfirm }: DeleteDialogProps) => (
<Dialog>
<DialogTrigger asChild>
<Button variant="destructive">Delete</Button>

View file

@ -1,13 +1,22 @@
import { Button } from "@/components/ui/button";
import Link from "next/link";
interface AppHeaderProps {
isEditing: boolean;
setIsEditing: (value: boolean) => void;
}
const Header: React.FC<AppHeaderProps> = ({ isEditing, setIsEditing }) => (
const Header = ({ isEditing, setIsEditing }: AppHeaderProps) => (
<div className="flex justify-between items-center mb-6">
<h1 className="text-3xl font-bold">Budgetable</h1>
<h1 className="text-3xl font-bold">
<Link
href="/"
className="text-blue-500 hover:text-blue-600 transition-colors"
onClick={() => setIsEditing(false)}
>
Budgetable
</Link>
</h1>
<Button variant="ghost" onClick={() => setIsEditing(!isEditing)}>
{isEditing ? "Lock" : "Unlock"} Editing
</Button>

View file

@ -1,5 +1,5 @@
import { Skeleton } from "@/components/ui/skeleton";
const LoadingSkeleton: React.FC = () => <Skeleton className="h-10 w-full" />;
const LoadingSkeleton = () => <Skeleton className="h-10 w-full" />;
export default LoadingSkeleton;

View file

@ -5,9 +5,9 @@ interface StatusBadgeProps {
toggleStatus: () => void;
}
const StatusBadge: React.FC<StatusBadgeProps> = ({ status, toggleStatus }) => (
const StatusBadge = ({ status, toggleStatus }: StatusBadgeProps) => (
<Badge
variant={status === "Paid" ? "destructive" : "secondary"}
variant={status === "Paid" ? "paid" : "unpaid"}
className="cursor-pointer"
onClick={toggleStatus}
>

View file

@ -1,15 +1,7 @@
import { TableRow, TableCell } from "@/components/ui/table";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
interface Budgetable {
id: string;
title: string;
price: number;
link: string;
note?: string;
status: "Paid" | "Unpaid";
}
import { Budgetable } from '@/lib/utils';
interface TNewRowProps {
newRow: Budgetable;

View file

@ -3,15 +3,8 @@ import { TableRow, TableCell } from "@/components/ui/table";
import Link from "next/link";
import StatusBadge from "@/components/status-badge";
import DeleteDialog from "@/components/delete-dialog";
interface Budgetable {
id: string;
title: string;
price: number;
link: string;
note?: string;
status: "Paid" | "Unpaid";
}
import { useState } from 'react';
import { Budgetable } from '@/lib/utils';
interface TRowProps {
row: Budgetable;
@ -26,7 +19,7 @@ interface TRowProps {
toggleStatus: (row: Budgetable) => Promise<void>;
}
const TRow: React.FC<TRowProps> = ({
const TRow = ({
row,
isEditing,
setData,
@ -34,7 +27,14 @@ const TRow: React.FC<TRowProps> = ({
handleSave,
handleDeleteRow,
toggleStatus,
}) => (
}: TRowProps) => {
const [originalRow, setOriginalRow] = useState<Budgetable>(row);
const handleInputFocus = () => {
setOriginalRow({...row});
};
return (
<TableRow
className={`transition-opacity ${
recentlyUpdatedRowId === row.id ? "blur-sm opacity-50" : ""
@ -44,6 +44,7 @@ const TRow: React.FC<TRowProps> = ({
{isEditing ? (
<Input
value={row.title}
onFocus={handleInputFocus}
onChange={(e) =>
setData((prev) =>
prev.map((item) =>
@ -51,7 +52,7 @@ const TRow: React.FC<TRowProps> = ({
),
)
}
onBlur={() => handleSave(row, { ...row })}
onBlur={() => handleSave(row, originalRow)}
/>
) : (
<span>{row.title}</span>
@ -62,6 +63,7 @@ const TRow: React.FC<TRowProps> = ({
<Input
type="number"
value={row.price}
onFocus={handleInputFocus}
onChange={(e) =>
setData((prev) =>
prev.map((item) =>
@ -71,7 +73,7 @@ const TRow: React.FC<TRowProps> = ({
),
)
}
onBlur={() => handleSave(row, { ...row })}
onBlur={() => handleSave(row, originalRow)}
/>
) : (
<span>{row.price.toLocaleString()} HUF</span>
@ -81,6 +83,7 @@ const TRow: React.FC<TRowProps> = ({
{isEditing ? (
<Input
value={row.link}
onFocus={handleInputFocus}
onChange={(e) =>
setData((prev) =>
prev.map((item) =>
@ -88,7 +91,7 @@ const TRow: React.FC<TRowProps> = ({
),
)
}
onBlur={() => handleSave(row, { ...row })}
onBlur={() => handleSave(row, originalRow)}
/>
) : row.link ? (
<Link
@ -107,6 +110,7 @@ const TRow: React.FC<TRowProps> = ({
{isEditing ? (
<Input
value={row.note || ""}
onFocus={handleInputFocus}
onChange={(e) =>
setData((prev) =>
prev.map((item) =>
@ -114,7 +118,7 @@ const TRow: React.FC<TRowProps> = ({
),
)
}
onBlur={() => handleSave(row, { ...row })}
onBlur={() => handleSave(row, originalRow)}
/>
) : (
<span>{row.note || "-"}</span>
@ -130,5 +134,6 @@ const TRow: React.FC<TRowProps> = ({
)}
</TableRow>
);
};
export default TRow;

View file

@ -7,15 +7,7 @@ import {
} from "@/components/ui/table";
import TRow from "@/components/table-row";
import TNewRow from "@/components/table-new-row";
interface Budgetable {
id: string;
title: string;
price: number;
link: string;
note?: string;
status: "Paid" | "Unpaid";
}
import { Budgetable } from '@/lib/utils';
interface TWrapperProps {
data: Budgetable[];
@ -33,7 +25,7 @@ interface TWrapperProps {
toggleStatus: (row: Budgetable) => Promise<void>;
}
const TWrapper: React.FC<TWrapperProps> = ({
const TWrapper = ({
data,
isEditing,
setData,
@ -44,7 +36,7 @@ const TWrapper: React.FC<TWrapperProps> = ({
handleAddRow,
handleDeleteRow,
toggleStatus,
}) => (
}: TWrapperProps) => (
<Table>
<TableHeader>
<TableRow>

View file

@ -1,11 +1,11 @@
"use client";
import type * as React from "react";
import { ComponentProps } from "react";
import { ThemeProvider as NextThemesProvider } from "next-themes";
export function ThemeProvider({
children,
...props
}: React.ComponentProps<typeof NextThemesProvider>) {
}: ComponentProps<typeof NextThemesProvider>) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
}

View file

@ -2,7 +2,7 @@ interface TotalDisplayProps {
total: number;
}
const TotalDisplay: React.FC<TotalDisplayProps> = ({ total }) => (
const TotalDisplay = ({ total }: TotalDisplayProps) => (
<div className="mt-4 text-right font-bold text-lg">
Total: {total.toLocaleString()}
<p className="text-xs ml-1">

View file

@ -15,6 +15,8 @@ const badgeVariants = cva(
destructive:
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
outline: "text-foreground",
paid: "border-transparent bg-green-500 text-green-50",
unpaid: "border-transparent bg-red-500 text-red-50",
},
},
defaultVariants: {

View file

@ -4,3 +4,26 @@ import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export interface Budgetable {
id: string;
title: string;
price: number;
link: string;
note?: string;
status: "Paid" | "Unpaid";
}
export const areRowsEqual = (row1: Budgetable, row2: Budgetable): boolean => {
const normalize = (value: string | number | undefined) =>
String(value ?? "").trim();
const areEqual = (field: keyof Budgetable) =>
field === "price"
? Number(row1[field]) === Number(row2[field])
: normalize(row1[field]) === normalize(row2[field]);
return ["title", "price", "link", "note", "status"].every(field =>
areEqual(field as keyof Budgetable)
);
};