Refactor: Break down single table component into multiple modular components

This commit is contained in:
skidoodle 2024-12-30 13:16:13 +01:00
parent 8c9688017b
commit 95380493cb
Signed by: albert
GPG key ID: A06E3070D7D55BF2
33 changed files with 1562 additions and 6642 deletions

30
biome.json Normal file
View file

@ -0,0 +1,30 @@
{
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
"vcs": {
"enabled": false,
"clientKind": "git",
"useIgnoreFile": false
},
"files": {
"ignoreUnknown": false,
"ignore": []
},
"formatter": {
"enabled": true,
"indentStyle": "tab"
},
"organizeImports": {
"enabled": true
},
"linter": {
"enabled": true,
"rules": {
"recommended": true
}
},
"javascript": {
"formatter": {
"quoteStyle": "double"
}
}
}

View file

@ -1,7 +1,55 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
const securityHeaders = [
{
key: "X-DNS-Prefetch-Control",
value: "on",
},
{
key: "X-XSS-Protection",
value: "1; mode=block",
},
{
key: "X-Content-Type-Options",
value: "nosniff",
},
{
key: "Referrer-Policy",
value: "strict-origin",
},
{
key: "Content-Security-Policy",
value: `frame-ancestors 'self';`,
},
{
key: "X-Frame-Options",
value: "SAMEORIGIN",
},
{
key: "Strict-Transport-Security",
value: "max-age=31536000; includeSubDomains; preload",
},
{
key: "Permissions-Policy",
value: "camera=(), microphone=(), geolocation=()",
},
{
key: "X-Source",
value: "github.com/skidoodle/budgetable",
},
];
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
/* config options here */ async headers() {
return [
{
source: "/:path*",
headers: securityHeaders,
},
];
},
reactStrictMode: true,
output: "standalone",
}; };
export default nextConfig; export default nextConfig;

5423
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -6,7 +6,8 @@
"dev": "next dev --turbopack", "dev": "next dev --turbopack",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "next lint" "lint": "biome lint --write src/",
"format": "biome format --write src/"
}, },
"dependencies": { "dependencies": {
"@radix-ui/react-dialog": "^1.1.4", "@radix-ui/react-dialog": "^1.1.4",
@ -25,8 +26,9 @@
"tailwindcss-animate": "^1.0.7" "tailwindcss-animate": "^1.0.7"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "1.9.4",
"@eslint/eslintrc": "^3", "@eslint/eslintrc": "^3",
"@types/node": "^20", "@types/node": "22.10.2",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"eslint": "^9", "eslint": "^9",

109
pnpm-lock.yaml generated
View file

@ -51,12 +51,15 @@ importers:
specifier: ^1.0.7 specifier: ^1.0.7
version: 1.0.7(tailwindcss@3.4.17) version: 1.0.7(tailwindcss@3.4.17)
devDependencies: devDependencies:
'@biomejs/biome':
specifier: 1.9.4
version: 1.9.4
'@eslint/eslintrc': '@eslint/eslintrc':
specifier: ^3 specifier: ^3
version: 3.2.0 version: 3.2.0
'@types/node': '@types/node':
specifier: ^20 specifier: 22.10.2
version: 20.17.10 version: 22.10.2
'@types/react': '@types/react':
specifier: ^19 specifier: ^19
version: 19.0.2 version: 19.0.2
@ -85,6 +88,59 @@ packages:
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
engines: {node: '>=10'} engines: {node: '>=10'}
'@biomejs/biome@1.9.4':
resolution: {integrity: sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog==}
engines: {node: '>=14.21.3'}
hasBin: true
'@biomejs/cli-darwin-arm64@1.9.4':
resolution: {integrity: sha512-bFBsPWrNvkdKrNCYeAp+xo2HecOGPAy9WyNyB/jKnnedgzl4W4Hb9ZMzYNbf8dMCGmUdSavlYHiR01QaYR58cw==}
engines: {node: '>=14.21.3'}
cpu: [arm64]
os: [darwin]
'@biomejs/cli-darwin-x64@1.9.4':
resolution: {integrity: sha512-ngYBh/+bEedqkSevPVhLP4QfVPCpb+4BBe2p7Xs32dBgs7rh9nY2AIYUL6BgLw1JVXV8GlpKmb/hNiuIxfPfZg==}
engines: {node: '>=14.21.3'}
cpu: [x64]
os: [darwin]
'@biomejs/cli-linux-arm64-musl@1.9.4':
resolution: {integrity: sha512-v665Ct9WCRjGa8+kTr0CzApU0+XXtRgwmzIf1SeKSGAv+2scAlW6JR5PMFo6FzqqZ64Po79cKODKf3/AAmECqA==}
engines: {node: '>=14.21.3'}
cpu: [arm64]
os: [linux]
'@biomejs/cli-linux-arm64@1.9.4':
resolution: {integrity: sha512-fJIW0+LYujdjUgJJuwesP4EjIBl/N/TcOX3IvIHJQNsAqvV2CHIogsmA94BPG6jZATS4Hi+xv4SkBBQSt1N4/g==}
engines: {node: '>=14.21.3'}
cpu: [arm64]
os: [linux]
'@biomejs/cli-linux-x64-musl@1.9.4':
resolution: {integrity: sha512-gEhi/jSBhZ2m6wjV530Yy8+fNqG8PAinM3oV7CyO+6c3CEh16Eizm21uHVsyVBEB6RIM8JHIl6AGYCv6Q6Q9Tg==}
engines: {node: '>=14.21.3'}
cpu: [x64]
os: [linux]
'@biomejs/cli-linux-x64@1.9.4':
resolution: {integrity: sha512-lRCJv/Vi3Vlwmbd6K+oQ0KhLHMAysN8lXoCI7XeHlxaajk06u7G+UsFSO01NAs5iYuWKmVZjmiOzJ0OJmGsMwg==}
engines: {node: '>=14.21.3'}
cpu: [x64]
os: [linux]
'@biomejs/cli-win32-arm64@1.9.4':
resolution: {integrity: sha512-tlbhLk+WXZmgwoIKwHIHEBZUwxml7bRJgk0X2sPyNR3S93cdRq6XulAZRQJ17FYGGzWne0fgrXBKpl7l4M87Hg==}
engines: {node: '>=14.21.3'}
cpu: [arm64]
os: [win32]
'@biomejs/cli-win32-x64@1.9.4':
resolution: {integrity: sha512-8Y5wMhVIPaWe6jw2H+KlEm4wP/f7EW3810ZLmDlrEEy5KvBsb9ECEfu/kMWD484ijfQ8+nIi0giMgu9g1UAuuA==}
engines: {node: '>=14.21.3'}
cpu: [x64]
os: [win32]
'@emnapi/runtime@1.3.1': '@emnapi/runtime@1.3.1':
resolution: {integrity: sha512-kEBmG8KyqtxJZv+ygbEim+KCGtIq1fC22Ms3S4ziXmYKm8uyoLX0MHONVKwp+9opg390VaKRNt4a7A9NwmpNhw==} resolution: {integrity: sha512-kEBmG8KyqtxJZv+ygbEim+KCGtIq1fC22Ms3S4ziXmYKm8uyoLX0MHONVKwp+9opg390VaKRNt4a7A9NwmpNhw==}
@ -565,8 +621,8 @@ packages:
'@types/json5@0.0.29': '@types/json5@0.0.29':
resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==}
'@types/node@20.17.10': '@types/node@22.10.2':
resolution: {integrity: sha512-/jrvh5h6NXhEauFFexRin69nA0uHJ5gwk4iDivp/DeoEua3uwCUto6PC86IpRITBOs4+6i2I56K5x5b6WYGXHA==} resolution: {integrity: sha512-Xxr6BBRCAOQixvonOye19wnzyDiUtTeqldOOmj3CkeblonbccA12PFwlufvRdrpjXxqnmUaeiU5EOA+7s5diUQ==}
'@types/react-dom@19.0.2': '@types/react-dom@19.0.2':
resolution: {integrity: sha512-c1s+7TKFaDRRxr1TxccIX2u7sfCnc3RxkVyBIUA2lCpyqCF+QoAwQ/CBg7bsMdVwP120HEH143VQezKtef5nCg==} resolution: {integrity: sha512-c1s+7TKFaDRRxr1TxccIX2u7sfCnc3RxkVyBIUA2lCpyqCF+QoAwQ/CBg7bsMdVwP120HEH143VQezKtef5nCg==}
@ -1910,8 +1966,8 @@ packages:
resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
undici-types@6.19.8: undici-types@6.20.0:
resolution: {integrity: sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==} resolution: {integrity: sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==}
uri-js@4.4.1: uri-js@4.4.1:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
@ -1985,6 +2041,41 @@ snapshots:
'@alloc/quick-lru@5.2.0': {} '@alloc/quick-lru@5.2.0': {}
'@biomejs/biome@1.9.4':
optionalDependencies:
'@biomejs/cli-darwin-arm64': 1.9.4
'@biomejs/cli-darwin-x64': 1.9.4
'@biomejs/cli-linux-arm64': 1.9.4
'@biomejs/cli-linux-arm64-musl': 1.9.4
'@biomejs/cli-linux-x64': 1.9.4
'@biomejs/cli-linux-x64-musl': 1.9.4
'@biomejs/cli-win32-arm64': 1.9.4
'@biomejs/cli-win32-x64': 1.9.4
'@biomejs/cli-darwin-arm64@1.9.4':
optional: true
'@biomejs/cli-darwin-x64@1.9.4':
optional: true
'@biomejs/cli-linux-arm64-musl@1.9.4':
optional: true
'@biomejs/cli-linux-arm64@1.9.4':
optional: true
'@biomejs/cli-linux-x64-musl@1.9.4':
optional: true
'@biomejs/cli-linux-x64@1.9.4':
optional: true
'@biomejs/cli-win32-arm64@1.9.4':
optional: true
'@biomejs/cli-win32-x64@1.9.4':
optional: true
'@emnapi/runtime@1.3.1': '@emnapi/runtime@1.3.1':
dependencies: dependencies:
tslib: 2.8.1 tslib: 2.8.1
@ -2384,9 +2475,9 @@ snapshots:
'@types/json5@0.0.29': {} '@types/json5@0.0.29': {}
'@types/node@20.17.10': '@types/node@22.10.2':
dependencies: dependencies:
undici-types: 6.19.8 undici-types: 6.20.0
'@types/react-dom@19.0.2(@types/react@19.0.2)': '@types/react-dom@19.0.2(@types/react@19.0.2)':
dependencies: dependencies:
@ -4016,7 +4107,7 @@ snapshots:
has-symbols: 1.1.0 has-symbols: 1.1.0
which-boxed-primitive: 1.1.1 which-boxed-primitive: 1.1.1
undici-types@6.19.8: {} undici-types@6.20.0: {}
uri-js@4.4.1: uri-js@4.4.1:
dependencies: dependencies:

View file

@ -1,7 +1,7 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Albert_Sans } from "next/font/google"; import { Albert_Sans } from "next/font/google";
import { ThemeProvider } from "@/components/theme-provider"; import { ThemeProvider } from "@/components/theme-provider";
import { Toaster } from "@/components/ui/sonner" import { Toaster } from "@/components/ui/sonner";
import "./globals.css"; import "./globals.css";
const albertSans = Albert_Sans({ const albertSans = Albert_Sans({
@ -23,7 +23,7 @@ export default function RootLayout({
<body <body
className={`${albertSans.variable} ${albertSans.variable} antialiased`} className={`${albertSans.variable} ${albertSans.variable} antialiased`}
> >
<ThemeProvider attribute="class" defaultTheme='dark' forcedTheme='dark'> <ThemeProvider attribute="class" defaultTheme="dark" forcedTheme="dark">
{children} {children}
<Toaster /> <Toaster />
</ThemeProvider> </ThemeProvider>

View file

@ -1,9 +1,140 @@
import TableComponent from "@/components/table"; "use client";
import { useState, useEffect } from "react";
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";
}
export default function App() { export default function App() {
const [data, setData] = useState<Budgetable[]>([]);
const [isEditing, setIsEditing] = useState(false);
const [newRow, setNewRow] = useState<Budgetable>({
id: "",
title: "",
price: 0,
link: "",
note: "",
status: "Unpaid",
});
const [recentlyUpdatedRowId, setRecentlyUpdatedRowId] = useState<
string | null
>(null);
const [loading, setLoading] = useState(false);
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);
}
}
fetchData();
}, []);
const handleSave = async (
updatedRow: Budgetable,
originalRow: Budgetable,
) => {
setLoading(true);
try {
await new Promise((resolve) => setTimeout(resolve, 500));
setData((prev) =>
prev.map((item) => (item.id === originalRow.id ? updatedRow : item)),
);
setRecentlyUpdatedRowId(updatedRow.id);
setTimeout(() => setRecentlyUpdatedRowId(null), 1000);
} finally {
setLoading(false);
}
};
const handleAddRow = async () => {
if (!newRow.title || newRow.price <= 0) return;
setLoading(true);
try {
await new Promise((resolve) => setTimeout(resolve, 500));
setData((prev) => [...prev, { ...newRow, id: `${Date.now()}` }]);
setNewRow({
id: "",
title: "",
price: 0,
link: "",
note: "",
status: "Unpaid",
});
} finally {
setLoading(false);
}
};
const handleDeleteRow = async (id: string) => {
setLoading(true);
try {
await new Promise((resolve) => setTimeout(resolve, 500));
setData((prev) => prev.filter((item) => item.id !== id));
} finally {
setLoading(false);
}
};
const toggleStatus = async (row: Budgetable) => {
setLoading(true);
try {
await new Promise((resolve) => setTimeout(resolve, 500));
setData((prev) =>
prev.map((item) =>
item.id === row.id
? { ...item, status: item.status === "Paid" ? "Unpaid" : "Paid" }
: item,
),
);
} finally {
setLoading(false);
}
};
const total = data.reduce((sum, item) => sum + item.price, 0);
return ( return (
<main className="container mx-auto py-8"> <main className="container mx-auto p-4 max-w-5xl">
<TableComponent /> {loading}
<Header isEditing={isEditing} setIsEditing={setIsEditing} />
<TableWrapper
data={data}
isEditing={isEditing}
setData={setData}
newRow={newRow}
setNewRow={setNewRow}
recentlyUpdatedRowId={recentlyUpdatedRowId}
handleSave={handleSave}
handleAddRow={handleAddRow}
handleDeleteRow={handleDeleteRow}
toggleStatus={toggleStatus}
/>
<TotalDisplay total={total} />
</main> </main>
); );
} }

View file

@ -1,14 +1,20 @@
import pb from "@/app/lib/pocketbase"; import pb from "@/lib/pocketbase";
const { EMAIL, PASSWORD } = process.env; const { EMAIL, PASSWORD } = process.env;
async function authenticateSuperuser() { async function authenticateSuperuser() {
if (!EMAIL || !PASSWORD) {
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(req: Request, context: { params: Promise<{ id: string }> }) { export async function GET(
req: Request,
context: { params: Promise<{ id: string }> },
) {
try { try {
await authenticateSuperuser(); await authenticateSuperuser();
@ -16,7 +22,7 @@ export async function GET(req: Request, context: { params: Promise<{ id: string
if (!id) { if (!id) {
return new Response( return new Response(
JSON.stringify({ error: { message: "Missing ID in request" } }), JSON.stringify({ error: { message: "Missing ID in request" } }),
{ status: 400, headers: { "Content-Type": "application/json" } } { status: 400, headers: { "Content-Type": "application/json" } },
); );
} }
@ -28,12 +34,15 @@ export async function GET(req: Request, context: { params: Promise<{ id: string
console.error("Error fetching data:", error); console.error("Error fetching data:", error);
return new Response( return new Response(
JSON.stringify({ error: { message: "Failed to fetch data" } }), JSON.stringify({ error: { message: "Failed to fetch data" } }),
{ status: 500, headers: { "Content-Type": "application/json" } } { status: 500, headers: { "Content-Type": "application/json" } },
); );
} }
} }
export async function DELETE(req: Request, context: { params: Promise<{ id: string }> }) { export async function DELETE(
req: Request,
context: { params: Promise<{ id: string }> },
) {
try { try {
await authenticateSuperuser(); await authenticateSuperuser();
@ -41,25 +50,27 @@ export async function DELETE(req: Request, context: { params: Promise<{ id: stri
if (!id) { if (!id) {
return new Response( return new Response(
JSON.stringify({ error: { message: "Missing ID in request" } }), JSON.stringify({ error: { message: "Missing ID in request" } }),
{ status: 400, headers: { "Content-Type": "application/json" } } { status: 400, headers: { "Content-Type": "application/json" } },
); );
} }
await pb.collection("budgetable").delete(id); await pb.collection("budgetable").delete(id);
return new Response( return new Response(JSON.stringify({ success: true }), {
JSON.stringify({ success: true }), headers: { "Content-Type": "application/json" },
{ headers: { "Content-Type": "application/json" } } });
);
} catch (error) { } catch (error) {
console.error("Error deleting data:", error); console.error("Error deleting data:", error);
return new Response( return new Response(
JSON.stringify({ error: { message: "Failed to delete data" } }), JSON.stringify({ error: { message: "Failed to delete data" } }),
{ status: 500, headers: { "Content-Type": "application/json" } } { status: 500, headers: { "Content-Type": "application/json" } },
); );
} }
} }
export async function PUT(req: Request, context: { params: Promise<{ id: string }> }) { export async function PUT(
req: Request,
context: { params: Promise<{ id: string }> },
) {
try { try {
await authenticateSuperuser(); await authenticateSuperuser();
@ -67,7 +78,7 @@ export async function PUT(req: Request, context: { params: Promise<{ id: string
if (!id) { if (!id) {
return new Response( return new Response(
JSON.stringify({ error: { message: "Missing ID in request" } }), JSON.stringify({ error: { message: "Missing ID in request" } }),
{ status: 400, headers: { "Content-Type": "application/json" } } { status: 400, headers: { "Content-Type": "application/json" } },
); );
} }
@ -75,20 +86,19 @@ export async function PUT(req: Request, context: { params: Promise<{ id: string
if (!body.title || typeof body.price !== "number") { if (!body.title || typeof body.price !== "number") {
return new Response( return new Response(
JSON.stringify({ error: { message: "Invalid data provided" } }), JSON.stringify({ error: { message: "Invalid data provided" } }),
{ status: 400, headers: { "Content-Type": "application/json" } } { status: 400, headers: { "Content-Type": "application/json" } },
); );
} }
const updatedRecord = await pb.collection("budgetable").update(id, body); const updatedRecord = await pb.collection("budgetable").update(id, body);
return new Response( return new Response(JSON.stringify(updatedRecord), {
JSON.stringify(updatedRecord), headers: { "Content-Type": "application/json" },
{ headers: { "Content-Type": "application/json" } } });
);
} catch (error) { } catch (error) {
console.error("Error updating data:", error); console.error("Error updating data:", error);
return new Response( return new Response(
JSON.stringify({ error: { message: "Failed to update data" } }), JSON.stringify({ error: { message: "Failed to update data" } }),
{ status: 500, headers: { "Content-Type": "application/json" } } { status: 500, headers: { "Content-Type": "application/json" } },
); );
} }
} }

View file

@ -1,10 +1,13 @@
import pb from "@/app/lib/pocketbase"; import pb from "@/lib/pocketbase";
const { EMAIL, PASSWORD } = process.env; const { EMAIL, PASSWORD } = process.env;
async function authenticateSuperuser() { async function authenticateSuperuser() {
if (!EMAIL || !PASSWORD) {
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);
} }
} }
@ -26,7 +29,7 @@ export async function GET() {
{ {
status: 500, status: 500,
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
} },
); );
} }
} }
@ -50,7 +53,7 @@ export async function POST(req: Request) {
{ {
status: 500, status: 500,
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
} },
); );
} }
} }

View file

@ -0,0 +1,30 @@
import {
Dialog,
DialogTitle,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
interface DeleteDialogProps {
onConfirm: () => void;
}
const DeleteDialog: React.FC<DeleteDialogProps> = ({ onConfirm }) => (
<Dialog>
<DialogTrigger asChild>
<Button variant="destructive">Delete</Button>
</DialogTrigger>
<DialogContent>
<DialogTitle>Delete Row</DialogTitle>
<DialogHeader>Are you sure you want to delete this row?</DialogHeader>
<DialogFooter>
<Button onClick={onConfirm}>Yes, delete</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
export default DeleteDialog;

17
src/components/header.tsx Normal file
View file

@ -0,0 +1,17 @@
import { Button } from "@/components/ui/button";
interface AppHeaderProps {
isEditing: boolean;
setIsEditing: (value: boolean) => void;
}
const Header: React.FC<AppHeaderProps> = ({ isEditing, setIsEditing }) => (
<div className="flex justify-between items-center mb-6">
<h1 className="text-3xl font-bold">Budgetable</h1>
<Button variant="ghost" onClick={() => setIsEditing(!isEditing)}>
{isEditing ? "Lock" : "Unlock"} Editing
</Button>
</div>
);
export default Header;

View file

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

View file

@ -0,0 +1,18 @@
import { Badge } from "@/components/ui/badge";
interface StatusBadgeProps {
status: "Paid" | "Unpaid";
toggleStatus: () => void;
}
const StatusBadge: React.FC<StatusBadgeProps> = ({ status, toggleStatus }) => (
<Badge
variant={status === "Paid" ? "destructive" : "secondary"}
className="cursor-pointer"
onClick={toggleStatus}
>
{status}
</Badge>
);
export default StatusBadge;

View file

@ -0,0 +1,67 @@
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";
}
interface TNewRowProps {
newRow: Budgetable;
setNewRow: React.Dispatch<React.SetStateAction<Budgetable>>;
handleAddRow: () => Promise<void>;
}
const TNewRow: React.FC<TNewRowProps> = ({
newRow,
setNewRow,
handleAddRow,
}) => (
<TableRow>
<TableCell>
<Input
placeholder="New title"
value={newRow.title}
onChange={(e) => setNewRow({ ...newRow, title: e.target.value })}
/>
</TableCell>
<TableCell>
<Input
type="number"
placeholder="New price"
value={newRow.price || ""}
onChange={(e) =>
setNewRow({
...newRow,
price: Number.parseFloat(e.target.value) || 0,
})
}
/>
</TableCell>
<TableCell>
<Input
placeholder="New link"
value={newRow.link}
onChange={(e) => setNewRow({ ...newRow, link: e.target.value })}
/>
</TableCell>
<TableCell>
<Input
placeholder="New note"
value={newRow.note || ""}
onChange={(e) => setNewRow({ ...newRow, note: e.target.value })}
/>
</TableCell>
<TableCell />
<TableCell>
<Button onClick={handleAddRow}>Add</Button>
</TableCell>
</TableRow>
);
export default TNewRow;

View file

@ -0,0 +1,134 @@
import { Input } from "@/components/ui/input";
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";
}
interface TRowProps {
row: Budgetable;
isEditing: boolean;
setData: React.Dispatch<React.SetStateAction<Budgetable[]>>;
recentlyUpdatedRowId: string | null;
handleSave: (
updatedRow: Budgetable,
originalRow: Budgetable,
) => Promise<void>;
handleDeleteRow: (id: string) => Promise<void>;
toggleStatus: (row: Budgetable) => Promise<void>;
}
const TRow: React.FC<TRowProps> = ({
row,
isEditing,
setData,
recentlyUpdatedRowId,
handleSave,
handleDeleteRow,
toggleStatus,
}) => (
<TableRow
className={`transition-opacity ${
recentlyUpdatedRowId === row.id ? "blur-sm opacity-50" : ""
}`}
>
<TableCell>
{isEditing ? (
<Input
value={row.title}
onChange={(e) =>
setData((prev) =>
prev.map((item) =>
item.id === row.id ? { ...item, title: e.target.value } : item,
),
)
}
onBlur={() => handleSave(row, { ...row })}
/>
) : (
<span>{row.title}</span>
)}
</TableCell>
<TableCell>
{isEditing ? (
<Input
type="number"
value={row.price}
onChange={(e) =>
setData((prev) =>
prev.map((item) =>
item.id === row.id
? { ...item, price: Number.parseFloat(e.target.value) || 0 }
: item,
),
)
}
onBlur={() => handleSave(row, { ...row })}
/>
) : (
<span>{row.price.toLocaleString()} HUF</span>
)}
</TableCell>
<TableCell>
{isEditing ? (
<Input
value={row.link}
onChange={(e) =>
setData((prev) =>
prev.map((item) =>
item.id === row.id ? { ...item, link: e.target.value } : item,
),
)
}
onBlur={() => handleSave(row, { ...row })}
/>
) : row.link ? (
<Link
href={row.link}
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 underline"
>
Visit
</Link>
) : (
<span className="text-gray-400 italic">No link</span>
)}
</TableCell>
<TableCell>
{isEditing ? (
<Input
value={row.note || ""}
onChange={(e) =>
setData((prev) =>
prev.map((item) =>
item.id === row.id ? { ...item, note: e.target.value } : item,
),
)
}
onBlur={() => handleSave(row, { ...row })}
/>
) : (
<span>{row.note || "-"}</span>
)}
</TableCell>
<TableCell>
<StatusBadge status={row.status} toggleStatus={() => toggleStatus(row)} />
</TableCell>
{isEditing && (
<TableCell>
<DeleteDialog onConfirm={() => handleDeleteRow(row.id)} />
</TableCell>
)}
</TableRow>
);
export default TRow;

View file

@ -0,0 +1,83 @@
import {
Table,
TableBody,
TableHead,
TableHeader,
TableRow,
} 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";
}
interface TWrapperProps {
data: Budgetable[];
isEditing: boolean;
setData: React.Dispatch<React.SetStateAction<Budgetable[]>>;
newRow: Budgetable;
setNewRow: React.Dispatch<React.SetStateAction<Budgetable>>;
recentlyUpdatedRowId: string | null;
handleSave: (
updatedRow: Budgetable,
originalRow: Budgetable,
) => Promise<void>;
handleAddRow: () => Promise<void>;
handleDeleteRow: (id: string) => Promise<void>;
toggleStatus: (row: Budgetable) => Promise<void>;
}
const TWrapper: React.FC<TWrapperProps> = ({
data,
isEditing,
setData,
newRow,
setNewRow,
recentlyUpdatedRowId,
handleSave,
handleAddRow,
handleDeleteRow,
toggleStatus,
}) => (
<Table>
<TableHeader>
<TableRow>
<TableHead>Title</TableHead>
<TableHead>Price</TableHead>
<TableHead>Link</TableHead>
<TableHead>Note</TableHead>
<TableHead>Status</TableHead>
{isEditing && <TableHead>Actions</TableHead>}
</TableRow>
</TableHeader>
<TableBody>
{data.map((row) => (
<TRow
key={row.id}
row={row}
isEditing={isEditing}
setData={setData}
recentlyUpdatedRowId={recentlyUpdatedRowId}
handleSave={handleSave}
handleDeleteRow={handleDeleteRow}
toggleStatus={toggleStatus}
/>
))}
{isEditing && (
<TNewRow
newRow={newRow}
setNewRow={setNewRow}
handleAddRow={handleAddRow}
/>
)}
</TableBody>
</Table>
);
export default TWrapper;

View file

@ -1,340 +0,0 @@
"use client";
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogFooter, DialogTitle } from "@/components/ui/dialog";
import { Badge } from "@/components/ui/badge";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { toast } from "sonner";
import Link from "next/link";
interface Budgetable {
id: string;
title: string;
price: number;
link: string;
note?: string;
status: "Paid" | "Unpaid";
}
const TableComponent = () => {
const [data, setData] = useState<Budgetable[]>([]);
const [newRow, setNewRow] = useState<Budgetable>({
id: "",
title: "",
price: 0,
link: "",
note: "",
status: "Unpaid",
});
const [recentlyUpdatedRowId, setRecentlyUpdatedRowId] = useState<string | null>(null);
const [isEditing, setIsEditing] = useState(false);
const [loading, setLoading] = useState(true);
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);
}
}
fetchData();
}, []);
const handleSave = async (updatedRow: Budgetable, originalRow: Budgetable) => {
if (
updatedRow.title === originalRow.title &&
updatedRow.price === originalRow.price &&
updatedRow.link === originalRow.link &&
updatedRow.note === originalRow.note &&
updatedRow.status === originalRow.status
) {
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");
setData((prev) =>
prev.map((row) => (row.id === updatedRow.id ? updatedRow : 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 handleAddRow = async () => {
if (!newRow.title || newRow.price <= 0) {
toast("Title and price are required.");
return;
}
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 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);
}
};
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 total = data.reduce((sum, item) => sum + (item.status === "Unpaid" ? item.price : 0), 0);
return (
<div className="container mx-auto p-6 max-w-4xl">
<div className="flex justify-between items-center mb-6">
<h1 className="text-3xl font-bold">Budgetable</h1>
<Button variant="outline" onClick={() => setIsEditing(!isEditing)}>
{isEditing ? "Lock" : "Unlock"} Editing
</Button>
</div>
{loading ? (
<Skeleton className="h-10 w-full" />
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Title</TableHead>
<TableHead>Price</TableHead>
<TableHead>Link</TableHead>
<TableHead>Note</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.map((row) => (
<TableRow
key={row.id}
className={`transition-opacity ${
recentlyUpdatedRowId === row.id ? "blur-sm opacity-50" : ""
}`}
>
<TableCell>
{isEditing ? (
<Input
value={row.title}
onChange={(e) =>
setData((prev) =>
prev.map((item) =>
item.id === row.id
? { ...item, title: e.target.value }
: item
)
)
}
onBlur={() => handleSave(row, data.find((r) => r.id === row.id)!)}
/>
) : (
<span>{row.title}</span>
)}
</TableCell>
<TableCell>
{isEditing ? (
<Input
type="number"
value={row.price}
onChange={(e) =>
setData((prev) =>
prev.map((item) =>
item.id === row.id
? { ...item, price: parseFloat(e.target.value) || 0 }
: item
)
)
}
onBlur={() => handleSave(row, data.find((r) => r.id === row.id)!)}
/>
) : (
<span>{row.price.toLocaleString()} HUF</span>
)}
</TableCell>
<TableCell>
{isEditing ? (
<Input
value={row.link}
onChange={(e) =>
setData((prev) =>
prev.map((item) =>
item.id === row.id
? { ...item, link: e.target.value }
: item
)
)
}
onBlur={() => handleSave(row, data.find((r) => r.id === row.id)!)}
/>
) : row.link ? (
<Link
href={row.link}
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 underline"
>
Visit
</Link>
) : (
<span className="text-gray-400 italic">No link</span>
)}
</TableCell>
<TableCell>
{isEditing ? (
<Input
value={row.note || ""}
onChange={(e) =>
setData((prev) =>
prev.map((item) =>
item.id === row.id
? { ...item, note: e.target.value }
: item
)
)
}
onBlur={() => handleSave(row, data.find((r) => r.id === row.id)!)}
/>
) : (
<span>{row.note || "-"}</span>
)}
</TableCell>
<TableCell>
<Badge
variant={row.status === "Paid" ? "destructive" : "secondary"}
className="cursor-pointer"
onClick={() => toggleStatus(row)}
>
{row.status}
</Badge>
</TableCell>
<TableCell>
{isEditing && (
<Dialog>
<DialogTrigger asChild>
<Button variant="destructive">Delete</Button>
</DialogTrigger>
<DialogContent>
<DialogTitle>Delete Row</DialogTitle>
<DialogHeader>
Are you sure you want to delete this row?
</DialogHeader>
<DialogFooter>
<Button onClick={() => handleDeleteRow(row.id)}>Yes, delete</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
</TableCell>
</TableRow>
))}
{/* New Row */}
{isEditing && (
<TableRow>
<TableCell>
<Input
placeholder="New title"
value={newRow.title}
onChange={(e) => setNewRow({ ...newRow, title: e.target.value })}
/>
</TableCell>
<TableCell>
<Input
type="number"
placeholder="New price"
value={newRow.price || ""}
onChange={(e) =>
setNewRow({
...newRow,
price: parseFloat(e.target.value) || 0,
})
}
/>
</TableCell>
<TableCell>
<Input
placeholder="New link"
value={newRow.link}
onChange={(e) => setNewRow({ ...newRow, link: e.target.value })}
/>
</TableCell>
<TableCell>
<Input
placeholder="New note"
value={newRow.note || ""}
onChange={(e) => setNewRow({ ...newRow, note: e.target.value })}
/>
</TableCell>
<TableCell></TableCell>
<TableCell>
<Button onClick={handleAddRow}>Add</Button>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
)}
<div className="mt-4 text-right font-bold text-lg">
Total: {total.toLocaleString()}
</div>
</div>
);
};
export default TableComponent;

View file

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

View file

@ -0,0 +1,14 @@
interface TotalDisplayProps {
total: number;
}
const TotalDisplay: React.FC<TotalDisplayProps> = ({ total }) => (
<div className="mt-4 text-right font-bold text-lg">
Total: {total.toLocaleString()}
<p className="text-xs ml-1">
({Math.round(total * 1.27).toLocaleString()})
</p>
</div>
);
export default TotalDisplay;

View file

@ -1,7 +1,7 @@
import * as React from "react" import type * as React from "react";
import { cva, type VariantProps } from "class-variance-authority" import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const badgeVariants = cva( const badgeVariants = cva(
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", "inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
@ -20,8 +20,8 @@ const badgeVariants = cva(
defaultVariants: { defaultVariants: {
variant: "default", variant: "default",
}, },
} },
) );
export interface BadgeProps export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>, extends React.HTMLAttributes<HTMLDivElement>,
@ -30,7 +30,7 @@ export interface BadgeProps
function Badge({ className, variant, ...props }: BadgeProps) { function Badge({ className, variant, ...props }: BadgeProps) {
return ( return (
<div className={cn(badgeVariants({ variant }), className)} {...props} /> <div className={cn(badgeVariants({ variant }), className)} {...props} />
) );
} }
export { Badge, badgeVariants } export { Badge, badgeVariants };

View file

@ -1,8 +1,8 @@
import * as React from "react" import * as React from "react";
import { Slot } from "@radix-ui/react-slot" import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority" import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const buttonVariants = cva( const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
@ -31,27 +31,27 @@ const buttonVariants = cva(
variant: "default", variant: "default",
size: "default", size: "default",
}, },
} },
) );
export interface ButtonProps export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>, extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> { VariantProps<typeof buttonVariants> {
asChild?: boolean asChild?: boolean;
} }
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>( const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => { ({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button" const Comp = asChild ? Slot : "button";
return ( return (
<Comp <Comp
className={cn(buttonVariants({ variant, size, className }))} className={cn(buttonVariants({ variant, size, className }))}
ref={ref} ref={ref}
{...props} {...props}
/> />
) );
} },
) );
Button.displayName = "Button" Button.displayName = "Button";
export { Button, buttonVariants } export { Button, buttonVariants };

View file

@ -1,6 +1,6 @@
import * as React from "react" import * as React from "react";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const Card = React.forwardRef< const Card = React.forwardRef<
HTMLDivElement, HTMLDivElement,
@ -10,12 +10,12 @@ const Card = React.forwardRef<
ref={ref} ref={ref}
className={cn( className={cn(
"rounded-xl border bg-card text-card-foreground shadow", "rounded-xl border bg-card text-card-foreground shadow",
className className,
)} )}
{...props} {...props}
/> />
)) ));
Card.displayName = "Card" Card.displayName = "Card";
const CardHeader = React.forwardRef< const CardHeader = React.forwardRef<
HTMLDivElement, HTMLDivElement,
@ -26,8 +26,8 @@ const CardHeader = React.forwardRef<
className={cn("flex flex-col space-y-1.5 p-6", className)} className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props} {...props}
/> />
)) ));
CardHeader.displayName = "CardHeader" CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef< const CardTitle = React.forwardRef<
HTMLDivElement, HTMLDivElement,
@ -38,8 +38,8 @@ const CardTitle = React.forwardRef<
className={cn("font-semibold leading-none tracking-tight", className)} className={cn("font-semibold leading-none tracking-tight", className)}
{...props} {...props}
/> />
)) ));
CardTitle.displayName = "CardTitle" CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef< const CardDescription = React.forwardRef<
HTMLDivElement, HTMLDivElement,
@ -50,16 +50,16 @@ const CardDescription = React.forwardRef<
className={cn("text-sm text-muted-foreground", className)} className={cn("text-sm text-muted-foreground", className)}
{...props} {...props}
/> />
)) ));
CardDescription.displayName = "CardDescription" CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef< const CardContent = React.forwardRef<
HTMLDivElement, HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} /> <div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
)) ));
CardContent.displayName = "CardContent" CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef< const CardFooter = React.forwardRef<
HTMLDivElement, HTMLDivElement,
@ -70,7 +70,14 @@ const CardFooter = React.forwardRef<
className={cn("flex items-center p-6 pt-0", className)} className={cn("flex items-center p-6 pt-0", className)}
{...props} {...props}
/> />
)) ));
CardFooter.displayName = "CardFooter" CardFooter.displayName = "CardFooter";
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardDescription,
CardContent,
};

View file

@ -1,18 +1,18 @@
"use client" "use client";
import * as React from "react" import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog" import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react" import { X } from "lucide-react";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const Dialog = DialogPrimitive.Root const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef< const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>, React.ElementRef<typeof DialogPrimitive.Overlay>,
@ -22,12 +22,12 @@ const DialogOverlay = React.forwardRef<
ref={ref} ref={ref}
className={cn( className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", "fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className className,
)} )}
{...props} {...props}
/> />
)) ));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef< const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>, React.ElementRef<typeof DialogPrimitive.Content>,
@ -39,7 +39,7 @@ const DialogContent = React.forwardRef<
ref={ref} ref={ref}
className={cn( className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg", "fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className className,
)} )}
{...props} {...props}
> >
@ -50,8 +50,8 @@ const DialogContent = React.forwardRef<
</DialogPrimitive.Close> </DialogPrimitive.Close>
</DialogPrimitive.Content> </DialogPrimitive.Content>
</DialogPortal> </DialogPortal>
)) ));
DialogContent.displayName = DialogPrimitive.Content.displayName DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({ const DialogHeader = ({
className, className,
@ -60,12 +60,12 @@ const DialogHeader = ({
<div <div
className={cn( className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left", "flex flex-col space-y-1.5 text-center sm:text-left",
className className,
)} )}
{...props} {...props}
/> />
) );
DialogHeader.displayName = "DialogHeader" DialogHeader.displayName = "DialogHeader";
const DialogFooter = ({ const DialogFooter = ({
className, className,
@ -74,12 +74,12 @@ const DialogFooter = ({
<div <div
className={cn( className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className className,
)} )}
{...props} {...props}
/> />
) );
DialogFooter.displayName = "DialogFooter" DialogFooter.displayName = "DialogFooter";
const DialogTitle = React.forwardRef< const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>, React.ElementRef<typeof DialogPrimitive.Title>,
@ -89,12 +89,12 @@ const DialogTitle = React.forwardRef<
ref={ref} ref={ref}
className={cn( className={cn(
"text-lg font-semibold leading-none tracking-tight", "text-lg font-semibold leading-none tracking-tight",
className className,
)} )}
{...props} {...props}
/> />
)) ));
DialogTitle.displayName = DialogPrimitive.Title.displayName DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef< const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>, React.ElementRef<typeof DialogPrimitive.Description>,
@ -105,8 +105,8 @@ const DialogDescription = React.forwardRef<
className={cn("text-sm text-muted-foreground", className)} className={cn("text-sm text-muted-foreground", className)}
{...props} {...props}
/> />
)) ));
DialogDescription.displayName = DialogPrimitive.Description.displayName DialogDescription.displayName = DialogPrimitive.Description.displayName;
export { export {
Dialog, Dialog,
@ -119,4 +119,4 @@ export {
DialogFooter, DialogFooter,
DialogTitle, DialogTitle,
DialogDescription, DialogDescription,
} };

View file

@ -1,6 +1,6 @@
import * as React from "react" import * as React from "react";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>( const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => { ({ className, type, ...props }, ref) => {
@ -9,14 +9,14 @@ const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
type={type} type={type}
className={cn( className={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", "flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className className,
)} )}
ref={ref} ref={ref}
{...props} {...props}
/> />
) );
} },
) );
Input.displayName = "Input" Input.displayName = "Input";
export { Input } export { Input };

View file

@ -1,4 +1,4 @@
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
function Skeleton({ function Skeleton({
className, className,
@ -9,7 +9,7 @@ function Skeleton({
className={cn("animate-pulse rounded-md bg-primary/10", className)} className={cn("animate-pulse rounded-md bg-primary/10", className)}
{...props} {...props}
/> />
) );
} }
export { Skeleton } export { Skeleton };

View file

@ -1,12 +1,12 @@
"use client" "use client";
import { useTheme } from "next-themes" import { useTheme } from "next-themes";
import { Toaster as Sonner } from "sonner" import { Toaster as Sonner } from "sonner";
type ToasterProps = React.ComponentProps<typeof Sonner> type ToasterProps = React.ComponentProps<typeof Sonner>;
const Toaster = ({ ...props }: ToasterProps) => { const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme() const { theme = "system" } = useTheme();
return ( return (
<Sonner <Sonner
@ -25,7 +25,7 @@ const Toaster = ({ ...props }: ToasterProps) => {
}} }}
{...props} {...props}
/> />
) );
} };
export { Toaster } export { Toaster };

View file

@ -1,6 +1,6 @@
import * as React from "react" import * as React from "react";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const Table = React.forwardRef< const Table = React.forwardRef<
HTMLTableElement, HTMLTableElement,
@ -13,16 +13,16 @@ const Table = React.forwardRef<
{...props} {...props}
/> />
</div> </div>
)) ));
Table.displayName = "Table" Table.displayName = "Table";
const TableHeader = React.forwardRef< const TableHeader = React.forwardRef<
HTMLTableSectionElement, HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement> React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} /> <thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
)) ));
TableHeader.displayName = "TableHeader" TableHeader.displayName = "TableHeader";
const TableBody = React.forwardRef< const TableBody = React.forwardRef<
HTMLTableSectionElement, HTMLTableSectionElement,
@ -33,8 +33,8 @@ const TableBody = React.forwardRef<
className={cn("[&_tr:last-child]:border-0", className)} className={cn("[&_tr:last-child]:border-0", className)}
{...props} {...props}
/> />
)) ));
TableBody.displayName = "TableBody" TableBody.displayName = "TableBody";
const TableFooter = React.forwardRef< const TableFooter = React.forwardRef<
HTMLTableSectionElement, HTMLTableSectionElement,
@ -44,12 +44,12 @@ const TableFooter = React.forwardRef<
ref={ref} ref={ref}
className={cn( className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0", "border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className className,
)} )}
{...props} {...props}
/> />
)) ));
TableFooter.displayName = "TableFooter" TableFooter.displayName = "TableFooter";
const TableRow = React.forwardRef< const TableRow = React.forwardRef<
HTMLTableRowElement, HTMLTableRowElement,
@ -59,12 +59,12 @@ const TableRow = React.forwardRef<
ref={ref} ref={ref}
className={cn( className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted", "border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className className,
)} )}
{...props} {...props}
/> />
)) ));
TableRow.displayName = "TableRow" TableRow.displayName = "TableRow";
const TableHead = React.forwardRef< const TableHead = React.forwardRef<
HTMLTableCellElement, HTMLTableCellElement,
@ -74,12 +74,12 @@ const TableHead = React.forwardRef<
ref={ref} ref={ref}
className={cn( className={cn(
"h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]", "h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className className,
)} )}
{...props} {...props}
/> />
)) ));
TableHead.displayName = "TableHead" TableHead.displayName = "TableHead";
const TableCell = React.forwardRef< const TableCell = React.forwardRef<
HTMLTableCellElement, HTMLTableCellElement,
@ -89,12 +89,12 @@ const TableCell = React.forwardRef<
ref={ref} ref={ref}
className={cn( className={cn(
"p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]", "p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className className,
)} )}
{...props} {...props}
/> />
)) ));
TableCell.displayName = "TableCell" TableCell.displayName = "TableCell";
const TableCaption = React.forwardRef< const TableCaption = React.forwardRef<
HTMLTableCaptionElement, HTMLTableCaptionElement,
@ -105,8 +105,8 @@ const TableCaption = React.forwardRef<
className={cn("mt-4 text-sm text-muted-foreground", className)} className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props} {...props}
/> />
)) ));
TableCaption.displayName = "TableCaption" TableCaption.displayName = "TableCaption";
export { export {
Table, Table,
@ -117,4 +117,4 @@ export {
TableRow, TableRow,
TableCell, TableCell,
TableCaption, TableCaption,
} };

View file

@ -1,13 +1,13 @@
"use client" "use client";
import * as React from "react" import * as React from "react";
import * as ToastPrimitives from "@radix-ui/react-toast" import * as ToastPrimitives from "@radix-ui/react-toast";
import { cva, type VariantProps } from "class-variance-authority" import { cva, type VariantProps } from "class-variance-authority";
import { X } from "lucide-react" import { X } from "lucide-react";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const ToastProvider = ToastPrimitives.Provider const ToastProvider = ToastPrimitives.Provider;
const ToastViewport = React.forwardRef< const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>, React.ElementRef<typeof ToastPrimitives.Viewport>,
@ -17,12 +17,12 @@ const ToastViewport = React.forwardRef<
ref={ref} ref={ref}
className={cn( className={cn(
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]", "fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
className className,
)} )}
{...props} {...props}
/> />
)) ));
ToastViewport.displayName = ToastPrimitives.Viewport.displayName ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
const toastVariants = cva( const toastVariants = cva(
"group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full", "group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
@ -37,8 +37,8 @@ const toastVariants = cva(
defaultVariants: { defaultVariants: {
variant: "default", variant: "default",
}, },
} },
) );
const Toast = React.forwardRef< const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>, React.ElementRef<typeof ToastPrimitives.Root>,
@ -51,9 +51,9 @@ const Toast = React.forwardRef<
className={cn(toastVariants({ variant }), className)} className={cn(toastVariants({ variant }), className)}
{...props} {...props}
/> />
) );
}) });
Toast.displayName = ToastPrimitives.Root.displayName Toast.displayName = ToastPrimitives.Root.displayName;
const ToastAction = React.forwardRef< const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>, React.ElementRef<typeof ToastPrimitives.Action>,
@ -63,12 +63,12 @@ const ToastAction = React.forwardRef<
ref={ref} ref={ref}
className={cn( className={cn(
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium transition-colors hover:bg-secondary focus:outline-none focus:ring-1 focus:ring-ring disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive", "inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium transition-colors hover:bg-secondary focus:outline-none focus:ring-1 focus:ring-ring disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
className className,
)} )}
{...props} {...props}
/> />
)) ));
ToastAction.displayName = ToastPrimitives.Action.displayName ToastAction.displayName = ToastPrimitives.Action.displayName;
const ToastClose = React.forwardRef< const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>, React.ElementRef<typeof ToastPrimitives.Close>,
@ -78,15 +78,15 @@ const ToastClose = React.forwardRef<
ref={ref} ref={ref}
className={cn( className={cn(
"absolute right-1 top-1 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-1 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600", "absolute right-1 top-1 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-1 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
className className,
)} )}
toast-close="" toast-close=""
{...props} {...props}
> >
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</ToastPrimitives.Close> </ToastPrimitives.Close>
)) ));
ToastClose.displayName = ToastPrimitives.Close.displayName ToastClose.displayName = ToastPrimitives.Close.displayName;
const ToastTitle = React.forwardRef< const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>, React.ElementRef<typeof ToastPrimitives.Title>,
@ -97,8 +97,8 @@ const ToastTitle = React.forwardRef<
className={cn("text-sm font-semibold [&+div]:text-xs", className)} className={cn("text-sm font-semibold [&+div]:text-xs", className)}
{...props} {...props}
/> />
)) ));
ToastTitle.displayName = ToastPrimitives.Title.displayName ToastTitle.displayName = ToastPrimitives.Title.displayName;
const ToastDescription = React.forwardRef< const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>, React.ElementRef<typeof ToastPrimitives.Description>,
@ -109,12 +109,12 @@ const ToastDescription = React.forwardRef<
className={cn("text-sm opacity-90", className)} className={cn("text-sm opacity-90", className)}
{...props} {...props}
/> />
)) ));
ToastDescription.displayName = ToastPrimitives.Description.displayName ToastDescription.displayName = ToastPrimitives.Description.displayName;
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast> type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>;
type ToastActionElement = React.ReactElement<typeof ToastAction> type ToastActionElement = React.ReactElement<typeof ToastAction>;
export { export {
type ToastProps, type ToastProps,
@ -126,4 +126,4 @@ export {
ToastDescription, ToastDescription,
ToastClose, ToastClose,
ToastAction, ToastAction,
} };

View file

@ -1,6 +1,6 @@
"use client" "use client";
import { useToast } from "@/hooks/use-toast" import { useToast } from "@/hooks/use-toast";
import { import {
Toast, Toast,
ToastClose, ToastClose,
@ -8,28 +8,24 @@ import {
ToastProvider, ToastProvider,
ToastTitle, ToastTitle,
ToastViewport, ToastViewport,
} from "@/components/ui/toast" } from "@/components/ui/toast";
export function Toaster() { export function Toaster() {
const { toasts } = useToast() const { toasts } = useToast();
return ( return (
<ToastProvider> <ToastProvider>
{toasts.map(function ({ id, title, description, action, ...props }) { {toasts.map(({ id, title, description, action, ...props }) => (
return (
<Toast key={id} {...props}> <Toast key={id} {...props}>
<div className="grid gap-1"> <div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>} {title && <ToastTitle>{title}</ToastTitle>}
{description && ( {description && <ToastDescription>{description}</ToastDescription>}
<ToastDescription>{description}</ToastDescription>
)}
</div> </div>
{action} {action}
<ToastClose /> <ToastClose />
</Toast> </Toast>
) ))}
})}
<ToastViewport /> <ToastViewport />
</ToastProvider> </ToastProvider>
) );
} }

View file

@ -1,78 +1,75 @@
"use client" "use client";
// Inspired by react-hot-toast library // Inspired by react-hot-toast library
import * as React from "react" import * as React from "react";
import type { import type { ToastActionElement, ToastProps } from "@/components/ui/toast";
ToastActionElement,
ToastProps,
} from "@/components/ui/toast"
const TOAST_LIMIT = 1 const TOAST_LIMIT = 1;
const TOAST_REMOVE_DELAY = 1000000 const TOAST_REMOVE_DELAY = 1000000;
type ToasterToast = ToastProps & { type ToasterToast = ToastProps & {
id: string id: string;
title?: React.ReactNode title?: React.ReactNode;
description?: React.ReactNode description?: React.ReactNode;
action?: ToastActionElement action?: ToastActionElement;
} };
const actionTypes = { const actionTypes = {
ADD_TOAST: "ADD_TOAST", ADD_TOAST: "ADD_TOAST",
UPDATE_TOAST: "UPDATE_TOAST", UPDATE_TOAST: "UPDATE_TOAST",
DISMISS_TOAST: "DISMISS_TOAST", DISMISS_TOAST: "DISMISS_TOAST",
REMOVE_TOAST: "REMOVE_TOAST", REMOVE_TOAST: "REMOVE_TOAST",
} as const } as const;
let count = 0 let count = 0;
function genId() { function genId() {
count = (count + 1) % Number.MAX_SAFE_INTEGER count = (count + 1) % Number.MAX_SAFE_INTEGER;
return count.toString() return count.toString();
} }
type ActionType = typeof actionTypes type ActionType = typeof actionTypes;
type Action = type Action =
| { | {
type: ActionType["ADD_TOAST"] type: ActionType["ADD_TOAST"];
toast: ToasterToast toast: ToasterToast;
} }
| { | {
type: ActionType["UPDATE_TOAST"] type: ActionType["UPDATE_TOAST"];
toast: Partial<ToasterToast> toast: Partial<ToasterToast>;
} }
| { | {
type: ActionType["DISMISS_TOAST"] type: ActionType["DISMISS_TOAST"];
toastId?: ToasterToast["id"] toastId?: ToasterToast["id"];
} }
| { | {
type: ActionType["REMOVE_TOAST"] type: ActionType["REMOVE_TOAST"];
toastId?: ToasterToast["id"] toastId?: ToasterToast["id"];
} };
interface State { interface State {
toasts: ToasterToast[] toasts: ToasterToast[];
} }
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>() const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
const addToRemoveQueue = (toastId: string) => { const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) { if (toastTimeouts.has(toastId)) {
return return;
} }
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
toastTimeouts.delete(toastId) toastTimeouts.delete(toastId);
dispatch({ dispatch({
type: "REMOVE_TOAST", type: "REMOVE_TOAST",
toastId: toastId, toastId: toastId,
}) });
}, TOAST_REMOVE_DELAY) }, TOAST_REMOVE_DELAY);
toastTimeouts.set(toastId, timeout) toastTimeouts.set(toastId, timeout);
} };
export const reducer = (state: State, action: Action): State => { export const reducer = (state: State, action: Action): State => {
switch (action.type) { switch (action.type) {
@ -80,27 +77,27 @@ export const reducer = (state: State, action: Action): State => {
return { return {
...state, ...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
} };
case "UPDATE_TOAST": case "UPDATE_TOAST":
return { return {
...state, ...state,
toasts: state.toasts.map((t) => toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t t.id === action.toast.id ? { ...t, ...action.toast } : t,
), ),
} };
case "DISMISS_TOAST": { case "DISMISS_TOAST": {
const { toastId } = action const { toastId } = action;
// ! Side effects ! - This could be extracted into a dismissToast() action, // ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity // but I'll keep it here for simplicity
if (toastId) { if (toastId) {
addToRemoveQueue(toastId) addToRemoveQueue(toastId);
} else { } else {
state.toasts.forEach((toast) => { state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id) addToRemoveQueue(toast.id);
}) });
} }
return { return {
@ -111,46 +108,46 @@ export const reducer = (state: State, action: Action): State => {
...t, ...t,
open: false, open: false,
} }
: t : t,
), ),
} };
} }
case "REMOVE_TOAST": case "REMOVE_TOAST":
if (action.toastId === undefined) { if (action.toastId === undefined) {
return { return {
...state, ...state,
toasts: [], toasts: [],
} };
} }
return { return {
...state, ...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId), toasts: state.toasts.filter((t) => t.id !== action.toastId),
};
} }
} };
}
const listeners: Array<(state: State) => void> = [] const listeners: Array<(state: State) => void> = [];
let memoryState: State = { toasts: [] } let memoryState: State = { toasts: [] };
function dispatch(action: Action) { function dispatch(action: Action) {
memoryState = reducer(memoryState, action) memoryState = reducer(memoryState, action);
listeners.forEach((listener) => { listeners.forEach((listener) => {
listener(memoryState) listener(memoryState);
}) });
} }
type Toast = Omit<ToasterToast, "id"> type Toast = Omit<ToasterToast, "id">;
function toast({ ...props }: Toast) { function toast({ ...props }: Toast) {
const id = genId() const id = genId();
const update = (props: ToasterToast) => const update = (props: ToasterToast) =>
dispatch({ dispatch({
type: "UPDATE_TOAST", type: "UPDATE_TOAST",
toast: { ...props, id }, toast: { ...props, id },
}) });
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id }) const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id });
dispatch({ dispatch({
type: "ADD_TOAST", type: "ADD_TOAST",
@ -159,36 +156,36 @@ function toast({ ...props }: Toast) {
id, id,
open: true, open: true,
onOpenChange: (open) => { onOpenChange: (open) => {
if (!open) dismiss() if (!open) dismiss();
}, },
}, },
}) });
return { return {
id: id, id: id,
dismiss, dismiss,
update, update,
} };
} }
function useToast() { function useToast() {
const [state, setState] = React.useState<State>(memoryState) const [state, setState] = React.useState<State>(memoryState);
React.useEffect(() => { React.useEffect(() => {
listeners.push(setState) listeners.push(setState);
return () => { return () => {
const index = listeners.indexOf(setState) const index = listeners.indexOf(setState);
if (index > -1) { if (index > -1) {
listeners.splice(index, 1) listeners.splice(index, 1);
} }
} };
}, [state]) }, [state]);
return { return {
...state, ...state,
toast, toast,
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }), dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
} };
} }
export { useToast, toast } export { useToast, toast };

View file

@ -1,5 +1,5 @@
import PocketBase from 'pocketbase'; import PocketBase from "pocketbase";
const pb = new PocketBase(process.env.NEXT_PUBLIC_POCKETBASE_URL); const pb = new PocketBase(process.env.NEXT_PUBLIC_POCKETBASE_URL);
export default pb export default pb;

View file

@ -1,6 +1,6 @@
import { clsx, type ClassValue } from "clsx" import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge" import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)) return twMerge(clsx(inputs));
} }