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";
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 = {
/* config options here */
async headers() {
return [
{
source: "/:path*",
headers: securityHeaders,
},
];
},
reactStrictMode: true,
output: "standalone",
};
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",
"build": "next build",
"start": "next start",
"lint": "next lint"
"lint": "biome lint --write src/",
"format": "biome format --write src/"
},
"dependencies": {
"@radix-ui/react-dialog": "^1.1.4",
@ -25,8 +26,9 @@
"tailwindcss-animate": "^1.0.7"
},
"devDependencies": {
"@biomejs/biome": "1.9.4",
"@eslint/eslintrc": "^3",
"@types/node": "^20",
"@types/node": "22.10.2",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",

109
pnpm-lock.yaml generated
View file

@ -51,12 +51,15 @@ importers:
specifier: ^1.0.7
version: 1.0.7(tailwindcss@3.4.17)
devDependencies:
'@biomejs/biome':
specifier: 1.9.4
version: 1.9.4
'@eslint/eslintrc':
specifier: ^3
version: 3.2.0
'@types/node':
specifier: ^20
version: 20.17.10
specifier: 22.10.2
version: 22.10.2
'@types/react':
specifier: ^19
version: 19.0.2
@ -85,6 +88,59 @@ packages:
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
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':
resolution: {integrity: sha512-kEBmG8KyqtxJZv+ygbEim+KCGtIq1fC22Ms3S4ziXmYKm8uyoLX0MHONVKwp+9opg390VaKRNt4a7A9NwmpNhw==}
@ -565,8 +621,8 @@ packages:
'@types/json5@0.0.29':
resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==}
'@types/node@20.17.10':
resolution: {integrity: sha512-/jrvh5h6NXhEauFFexRin69nA0uHJ5gwk4iDivp/DeoEua3uwCUto6PC86IpRITBOs4+6i2I56K5x5b6WYGXHA==}
'@types/node@22.10.2':
resolution: {integrity: sha512-Xxr6BBRCAOQixvonOye19wnzyDiUtTeqldOOmj3CkeblonbccA12PFwlufvRdrpjXxqnmUaeiU5EOA+7s5diUQ==}
'@types/react-dom@19.0.2':
resolution: {integrity: sha512-c1s+7TKFaDRRxr1TxccIX2u7sfCnc3RxkVyBIUA2lCpyqCF+QoAwQ/CBg7bsMdVwP120HEH143VQezKtef5nCg==}
@ -1910,8 +1966,8 @@ packages:
resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==}
engines: {node: '>= 0.4'}
undici-types@6.19.8:
resolution: {integrity: sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==}
undici-types@6.20.0:
resolution: {integrity: sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==}
uri-js@4.4.1:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
@ -1985,6 +2041,41 @@ snapshots:
'@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':
dependencies:
tslib: 2.8.1
@ -2384,9 +2475,9 @@ snapshots:
'@types/json5@0.0.29': {}
'@types/node@20.17.10':
'@types/node@22.10.2':
dependencies:
undici-types: 6.19.8
undici-types: 6.20.0
'@types/react-dom@19.0.2(@types/react@19.0.2)':
dependencies:
@ -4016,7 +4107,7 @@ snapshots:
has-symbols: 1.1.0
which-boxed-primitive: 1.1.1
undici-types@6.19.8: {}
undici-types@6.20.0: {}
uri-js@4.4.1:
dependencies:

View file

@ -3,70 +3,70 @@
@tailwind utilities;
body {
font-family: Arial, Helvetica, sans-serif;
font-family: Arial, Helvetica, sans-serif;
}
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 240 10% 3.9%;
--card: 0 0% 100%;
--card-foreground: 240 10% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 240 10% 3.9%;
--primary: 240 5.9% 10%;
--primary-foreground: 0 0% 98%;
--secondary: 240 4.8% 95.9%;
--secondary-foreground: 240 5.9% 10%;
--muted: 240 4.8% 95.9%;
--muted-foreground: 240 3.8% 46.1%;
--accent: 240 4.8% 95.9%;
--accent-foreground: 240 5.9% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--ring: 240 10% 3.9%;
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
--radius: 0.5rem;
}
.dark {
--background: 240 10% 3.9%;
--foreground: 0 0% 98%;
--card: 240 10% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 240 10% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 240 5.9% 10%;
--secondary: 240 3.7% 15.9%;
--secondary-foreground: 0 0% 98%;
--muted: 240 3.7% 15.9%;
--muted-foreground: 240 5% 64.9%;
--accent: 240 3.7% 15.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 240 3.7% 15.9%;
--input: 240 3.7% 15.9%;
--ring: 240 4.9% 83.9%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
}
:root {
--background: 0 0% 100%;
--foreground: 240 10% 3.9%;
--card: 0 0% 100%;
--card-foreground: 240 10% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 240 10% 3.9%;
--primary: 240 5.9% 10%;
--primary-foreground: 0 0% 98%;
--secondary: 240 4.8% 95.9%;
--secondary-foreground: 240 5.9% 10%;
--muted: 240 4.8% 95.9%;
--muted-foreground: 240 3.8% 46.1%;
--accent: 240 4.8% 95.9%;
--accent-foreground: 240 5.9% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--ring: 240 10% 3.9%;
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
--radius: 0.5rem;
}
.dark {
--background: 240 10% 3.9%;
--foreground: 0 0% 98%;
--card: 240 10% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 240 10% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 240 5.9% 10%;
--secondary: 240 3.7% 15.9%;
--secondary-foreground: 0 0% 98%;
--muted: 240 3.7% 15.9%;
--muted-foreground: 240 5% 64.9%;
--accent: 240 3.7% 15.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 240 3.7% 15.9%;
--input: 240 3.7% 15.9%;
--ring: 240 4.9% 83.9%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

View file

@ -1,33 +1,33 @@
import type { Metadata } from "next";
import { Albert_Sans } from "next/font/google";
import { ThemeProvider } from "@/components/theme-provider";
import { Toaster } from "@/components/ui/sonner"
import { Toaster } from "@/components/ui/sonner";
import "./globals.css";
const albertSans = Albert_Sans({
variable: "--font-albert-sans",
subsets: ["latin"],
variable: "--font-albert-sans",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Budgetable",
title: "Budgetable",
};
export default function RootLayout({
children,
children,
}: Readonly<{
children: React.ReactNode;
children: React.ReactNode;
}>) {
return (
<html lang="en" suppressHydrationWarning>
<body
className={`${albertSans.variable} ${albertSans.variable} antialiased`}
>
<ThemeProvider attribute="class" defaultTheme='dark' forcedTheme='dark'>
{children}
<Toaster />
</ThemeProvider>
</body>
</html>
);
return (
<html lang="en" suppressHydrationWarning>
<body
className={`${albertSans.variable} ${albertSans.variable} antialiased`}
>
<ThemeProvider attribute="class" defaultTheme="dark" forcedTheme="dark">
{children}
<Toaster />
</ThemeProvider>
</body>
</html>
);
}

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() {
return (
<main className="container mx-auto py-8">
<TableComponent />
</main>
);
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 (
<main className="container mx-auto p-4 max-w-5xl">
{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>
);
}

View file

@ -1,94 +1,104 @@
import pb from "@/app/lib/pocketbase";
import pb from "@/lib/pocketbase";
const { EMAIL, PASSWORD } = process.env;
async function authenticateSuperuser() {
if (!pb.authStore.isValid) {
await pb.collection("_superusers").authWithPassword(EMAIL!, PASSWORD!);
}
if (!EMAIL || !PASSWORD) {
throw new Error("Environment variables EMAIL and PASSWORD must be set");
}
if (!pb.authStore.isValid) {
await pb.collection("_superusers").authWithPassword(EMAIL, PASSWORD);
}
}
export async function GET(req: Request, context: { params: Promise<{ id: string }> }) {
try {
await authenticateSuperuser();
export async function GET(
req: Request,
context: { params: Promise<{ id: string }> },
) {
try {
await authenticateSuperuser();
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" } }
);
}
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" } },
);
}
const record = await pb.collection("budgetable").getOne(id);
return new Response(JSON.stringify(record), {
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" } }
);
}
const record = await pb.collection("budgetable").getOne(id);
return new Response(JSON.stringify(record), {
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" } },
);
}
}
export async function DELETE(req: Request, context: { params: Promise<{ id: string }> }) {
try {
await authenticateSuperuser();
export async function DELETE(
req: Request,
context: { params: Promise<{ id: string }> },
) {
try {
await authenticateSuperuser();
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" } }
);
}
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" } },
);
}
await pb.collection("budgetable").delete(id);
return new Response(
JSON.stringify({ success: true }),
{ 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" } }
);
}
await pb.collection("budgetable").delete(id);
return new Response(JSON.stringify({ success: true }), {
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" } },
);
}
}
export async function PUT(req: Request, context: { params: Promise<{ id: string }> }) {
try {
await authenticateSuperuser();
export async function PUT(
req: Request,
context: { params: Promise<{ id: string }> },
) {
try {
await authenticateSuperuser();
const id = (await context.params)?.id; // Use `context.params?.id`
if (!id) {
return new Response(
JSON.stringify({ error: { message: "Missing ID in request" } }),
{ status: 400, headers: { "Content-Type": "application/json" } }
);
}
const id = (await context.params)?.id; // Use `context.params?.id`
if (!id) {
return new Response(
JSON.stringify({ 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" } }
);
}
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" } },
);
}
const updatedRecord = await pb.collection("budgetable").update(id, body);
return new Response(
JSON.stringify(updatedRecord),
{ 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" } }
);
}
const updatedRecord = await pb.collection("budgetable").update(id, body);
return new Response(JSON.stringify(updatedRecord), {
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" } },
);
}
}

View file

@ -1,56 +1,59 @@
import pb from "@/app/lib/pocketbase";
import pb from "@/lib/pocketbase";
const { EMAIL, PASSWORD } = process.env;
async function authenticateSuperuser() {
if (!pb.authStore.isValid) {
await pb.collection("_superusers").authWithPassword(EMAIL!, PASSWORD!);
}
if (!EMAIL || !PASSWORD) {
throw new Error("Environment variables EMAIL and PASSWORD must be set");
}
if (!pb.authStore.isValid) {
await pb.collection("_superusers").authWithPassword(EMAIL, PASSWORD);
}
}
export async function GET() {
try {
await authenticateSuperuser();
const records = await pb.collection("budgetable").getFullList();
return new Response(JSON.stringify(records), {
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" },
}
);
}
try {
await authenticateSuperuser();
const records = await pb.collection("budgetable").getFullList();
return new Response(JSON.stringify(records), {
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" },
},
);
}
}
export async function POST(req: Request) {
try {
await authenticateSuperuser();
const data = await req.json();
const record = await pb.collection("budgetable").create(data);
return new Response(JSON.stringify(record), {
headers: { "Content-Type": "application/json" },
});
} catch (error) {
console.error("Error adding data:", error);
return new Response(
JSON.stringify({
error: {
message: "Failed to add data",
},
}),
{
status: 500,
headers: { "Content-Type": "application/json" },
}
);
}
try {
await authenticateSuperuser();
const data = await req.json();
const record = await pb.collection("budgetable").create(data);
return new Response(JSON.stringify(record), {
headers: { "Content-Type": "application/json" },
});
} catch (error) {
console.error("Error adding data:", error);
return new Response(
JSON.stringify({
error: {
message: "Failed to add data",
},
}),
{
status: 500,
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 { ThemeProvider as NextThemesProvider } from "next-themes"
import type * as React from "react";
import { ThemeProvider as NextThemesProvider } from "next-themes";
export function ThemeProvider({
children,
...props
children,
...props
}: 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,36 +1,36 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import type * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
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",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
"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",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
},
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
);
}
export { Badge, badgeVariants }
export { Badge, badgeVariants };

View file

@ -1,57 +1,57 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
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",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
"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",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
},
);
Button.displayName = "Button";
export { Button, buttonVariants }
export { Button, buttonVariants };

View file

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

View file

@ -1,122 +1,122 @@
"use client"
"use client";
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
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<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
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",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
<DialogPrimitive.Overlay
ref={ref}
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",
className,
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
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",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
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",
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({
className,
...props
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className,
)}
{...props}
/>
);
DialogHeader.displayName = "DialogHeader";
const DialogFooter = ({
className,
...props
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className,
)}
{...props}
/>
);
DialogFooter.displayName = "DialogFooter";
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className,
)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}
Dialog,
DialogPortal,
DialogOverlay,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
};

View file

@ -1,22 +1,22 @@
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">>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
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",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
({ className, type, ...props }, ref) => {
return (
<input
type={type}
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",
className,
)}
ref={ref}
{...props}
/>
);
},
);
Input.displayName = "Input";
export { Input }
export { Input };

View file

@ -1,15 +1,15 @@
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
function Skeleton({
className,
...props
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("animate-pulse rounded-md bg-primary/10", className)}
{...props}
/>
)
return (
<div
className={cn("animate-pulse rounded-md bg-primary/10", className)}
{...props}
/>
);
}
export { Skeleton }
export { Skeleton };

View file

@ -1,31 +1,31 @@
"use client"
"use client";
import { useTheme } from "next-themes"
import { Toaster as Sonner } from "sonner"
import { useTheme } from "next-themes";
import { Toaster as Sonner } from "sonner";
type ToasterProps = React.ComponentProps<typeof Sonner>
type ToasterProps = React.ComponentProps<typeof Sonner>;
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
const { theme = "system" } = useTheme();
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
toastOptions={{
classNames: {
toast:
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
description: "group-[.toast]:text-muted-foreground",
actionButton:
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
cancelButton:
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
},
}}
{...props}
/>
)
}
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
toastOptions={{
classNames: {
toast:
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
description: "group-[.toast]:text-muted-foreground",
actionButton:
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
cancelButton:
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
},
}}
{...props}
/>
);
};
export { Toaster }
export { Toaster };

View file

@ -1,120 +1,120 @@
import * as React from "react"
import * as React from "react";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
))
Table.displayName = "Table"
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
));
Table.displayName = "Table";
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
))
TableHeader.displayName = "TableHeader"
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
));
TableHeader.displayName = "TableHeader";
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
))
TableBody.displayName = "TableBody"
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
));
TableBody.displayName = "TableBody";
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
))
TableFooter.displayName = "TableFooter"
<tfoot
ref={ref}
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className,
)}
{...props}
/>
));
TableFooter.displayName = "TableFooter";
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
))
TableRow.displayName = "TableRow"
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className,
)}
{...props}
/>
));
TableRow.displayName = "TableRow";
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
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]",
className
)}
{...props}
/>
))
TableHead.displayName = "TableHead"
<th
ref={ref}
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]",
className,
)}
{...props}
/>
));
TableHead.displayName = "TableHead";
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn(
"p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
))
TableCell.displayName = "TableCell"
<td
ref={ref}
className={cn(
"p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className,
)}
{...props}
/>
));
TableCell.displayName = "TableCell";
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
))
TableCaption.displayName = "TableCaption"
<caption
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
));
TableCaption.displayName = "TableCaption";
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
};

View file

@ -1,129 +1,129 @@
"use client"
"use client";
import * as React from "react"
import * as ToastPrimitives from "@radix-ui/react-toast"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"
import * as React from "react";
import * as ToastPrimitives from "@radix-ui/react-toast";
import { cva, type VariantProps } from "class-variance-authority";
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<
React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
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]",
className
)}
{...props}
/>
))
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
<ToastPrimitives.Viewport
ref={ref}
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]",
className,
)}
{...props}
/>
));
ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
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",
{
variants: {
variant: {
default: "border bg-background text-foreground",
destructive:
"destructive group border-destructive bg-destructive text-destructive-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
"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",
{
variants: {
variant: {
default: "border bg-background text-foreground",
destructive:
"destructive group border-destructive bg-destructive text-destructive-foreground",
},
},
defaultVariants: {
variant: "default",
},
},
);
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
VariantProps<typeof toastVariants>
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => {
return (
<ToastPrimitives.Root
ref={ref}
className={cn(toastVariants({ variant }), className)}
{...props}
/>
)
})
Toast.displayName = ToastPrimitives.Root.displayName
return (
<ToastPrimitives.Root
ref={ref}
className={cn(toastVariants({ variant }), className)}
{...props}
/>
);
});
Toast.displayName = ToastPrimitives.Root.displayName;
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
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",
className
)}
{...props}
/>
))
ToastAction.displayName = ToastPrimitives.Action.displayName
<ToastPrimitives.Action
ref={ref}
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",
className,
)}
{...props}
/>
));
ToastAction.displayName = ToastPrimitives.Action.displayName;
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
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",
className
)}
toast-close=""
{...props}
>
<X className="h-4 w-4" />
</ToastPrimitives.Close>
))
ToastClose.displayName = ToastPrimitives.Close.displayName
<ToastPrimitives.Close
ref={ref}
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",
className,
)}
toast-close=""
{...props}
>
<X className="h-4 w-4" />
</ToastPrimitives.Close>
));
ToastClose.displayName = ToastPrimitives.Close.displayName;
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title
ref={ref}
className={cn("text-sm font-semibold [&+div]:text-xs", className)}
{...props}
/>
))
ToastTitle.displayName = ToastPrimitives.Title.displayName
<ToastPrimitives.Title
ref={ref}
className={cn("text-sm font-semibold [&+div]:text-xs", className)}
{...props}
/>
));
ToastTitle.displayName = ToastPrimitives.Title.displayName;
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description
ref={ref}
className={cn("text-sm opacity-90", className)}
{...props}
/>
))
ToastDescription.displayName = ToastPrimitives.Description.displayName
<ToastPrimitives.Description
ref={ref}
className={cn("text-sm opacity-90", className)}
{...props}
/>
));
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 {
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction,
}
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction,
};

View file

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

View file

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

View file

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