mirror of
https://github.com/skidoodle/budgetable.git
synced 2025-02-15 03:39:14 +01:00
Refactor: Break down single table component into multiple modular components
This commit is contained in:
parent
8c9688017b
commit
95380493cb
33 changed files with 1562 additions and 6642 deletions
30
biome.json
Normal file
30
biome.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
5423
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
@ -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
109
pnpm-lock.yaml
generated
|
@ -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:
|
||||||
|
|
|
@ -3,70 +3,70 @@
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
font-family: Arial, Helvetica, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
:root {
|
:root {
|
||||||
--background: 0 0% 100%;
|
--background: 0 0% 100%;
|
||||||
--foreground: 240 10% 3.9%;
|
--foreground: 240 10% 3.9%;
|
||||||
--card: 0 0% 100%;
|
--card: 0 0% 100%;
|
||||||
--card-foreground: 240 10% 3.9%;
|
--card-foreground: 240 10% 3.9%;
|
||||||
--popover: 0 0% 100%;
|
--popover: 0 0% 100%;
|
||||||
--popover-foreground: 240 10% 3.9%;
|
--popover-foreground: 240 10% 3.9%;
|
||||||
--primary: 240 5.9% 10%;
|
--primary: 240 5.9% 10%;
|
||||||
--primary-foreground: 0 0% 98%;
|
--primary-foreground: 0 0% 98%;
|
||||||
--secondary: 240 4.8% 95.9%;
|
--secondary: 240 4.8% 95.9%;
|
||||||
--secondary-foreground: 240 5.9% 10%;
|
--secondary-foreground: 240 5.9% 10%;
|
||||||
--muted: 240 4.8% 95.9%;
|
--muted: 240 4.8% 95.9%;
|
||||||
--muted-foreground: 240 3.8% 46.1%;
|
--muted-foreground: 240 3.8% 46.1%;
|
||||||
--accent: 240 4.8% 95.9%;
|
--accent: 240 4.8% 95.9%;
|
||||||
--accent-foreground: 240 5.9% 10%;
|
--accent-foreground: 240 5.9% 10%;
|
||||||
--destructive: 0 84.2% 60.2%;
|
--destructive: 0 84.2% 60.2%;
|
||||||
--destructive-foreground: 0 0% 98%;
|
--destructive-foreground: 0 0% 98%;
|
||||||
--border: 240 5.9% 90%;
|
--border: 240 5.9% 90%;
|
||||||
--input: 240 5.9% 90%;
|
--input: 240 5.9% 90%;
|
||||||
--ring: 240 10% 3.9%;
|
--ring: 240 10% 3.9%;
|
||||||
--chart-1: 12 76% 61%;
|
--chart-1: 12 76% 61%;
|
||||||
--chart-2: 173 58% 39%;
|
--chart-2: 173 58% 39%;
|
||||||
--chart-3: 197 37% 24%;
|
--chart-3: 197 37% 24%;
|
||||||
--chart-4: 43 74% 66%;
|
--chart-4: 43 74% 66%;
|
||||||
--chart-5: 27 87% 67%;
|
--chart-5: 27 87% 67%;
|
||||||
--radius: 0.5rem;
|
--radius: 0.5rem;
|
||||||
}
|
}
|
||||||
.dark {
|
.dark {
|
||||||
--background: 240 10% 3.9%;
|
--background: 240 10% 3.9%;
|
||||||
--foreground: 0 0% 98%;
|
--foreground: 0 0% 98%;
|
||||||
--card: 240 10% 3.9%;
|
--card: 240 10% 3.9%;
|
||||||
--card-foreground: 0 0% 98%;
|
--card-foreground: 0 0% 98%;
|
||||||
--popover: 240 10% 3.9%;
|
--popover: 240 10% 3.9%;
|
||||||
--popover-foreground: 0 0% 98%;
|
--popover-foreground: 0 0% 98%;
|
||||||
--primary: 0 0% 98%;
|
--primary: 0 0% 98%;
|
||||||
--primary-foreground: 240 5.9% 10%;
|
--primary-foreground: 240 5.9% 10%;
|
||||||
--secondary: 240 3.7% 15.9%;
|
--secondary: 240 3.7% 15.9%;
|
||||||
--secondary-foreground: 0 0% 98%;
|
--secondary-foreground: 0 0% 98%;
|
||||||
--muted: 240 3.7% 15.9%;
|
--muted: 240 3.7% 15.9%;
|
||||||
--muted-foreground: 240 5% 64.9%;
|
--muted-foreground: 240 5% 64.9%;
|
||||||
--accent: 240 3.7% 15.9%;
|
--accent: 240 3.7% 15.9%;
|
||||||
--accent-foreground: 0 0% 98%;
|
--accent-foreground: 0 0% 98%;
|
||||||
--destructive: 0 62.8% 30.6%;
|
--destructive: 0 62.8% 30.6%;
|
||||||
--destructive-foreground: 0 0% 98%;
|
--destructive-foreground: 0 0% 98%;
|
||||||
--border: 240 3.7% 15.9%;
|
--border: 240 3.7% 15.9%;
|
||||||
--input: 240 3.7% 15.9%;
|
--input: 240 3.7% 15.9%;
|
||||||
--ring: 240 4.9% 83.9%;
|
--ring: 240 4.9% 83.9%;
|
||||||
--chart-1: 220 70% 50%;
|
--chart-1: 220 70% 50%;
|
||||||
--chart-2: 160 60% 45%;
|
--chart-2: 160 60% 45%;
|
||||||
--chart-3: 30 80% 55%;
|
--chart-3: 30 80% 55%;
|
||||||
--chart-4: 280 65% 60%;
|
--chart-4: 280 65% 60%;
|
||||||
--chart-5: 340 75% 55%;
|
--chart-5: 340 75% 55%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
* {
|
* {
|
||||||
@apply border-border;
|
@apply border-border;
|
||||||
}
|
}
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,33 +1,33 @@
|
||||||
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({
|
||||||
variable: "--font-albert-sans",
|
variable: "--font-albert-sans",
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Budgetable",
|
title: "Budgetable",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="en" suppressHydrationWarning>
|
<html lang="en" suppressHydrationWarning>
|
||||||
<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>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
143
src/app/page.tsx
143
src/app/page.tsx
|
@ -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() {
|
||||||
return (
|
const [data, setData] = useState<Budgetable[]>([]);
|
||||||
<main className="container mx-auto py-8">
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
<TableComponent />
|
const [newRow, setNewRow] = useState<Budgetable>({
|
||||||
</main>
|
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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,94 +1,104 @@
|
||||||
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 (!pb.authStore.isValid) {
|
if (!EMAIL || !PASSWORD) {
|
||||||
await pb.collection("_superusers").authWithPassword(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 }> }) {
|
export async function GET(
|
||||||
try {
|
req: Request,
|
||||||
await authenticateSuperuser();
|
context: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
await authenticateSuperuser();
|
||||||
|
|
||||||
const id = (await context.params)?.id;
|
const id = (await context.params)?.id;
|
||||||
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" } },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const record = await pb.collection("budgetable").getOne(id);
|
const record = await pb.collection("budgetable").getOne(id);
|
||||||
return new Response(JSON.stringify(record), {
|
return new Response(JSON.stringify(record), {
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
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(
|
||||||
try {
|
req: Request,
|
||||||
await authenticateSuperuser();
|
context: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
await authenticateSuperuser();
|
||||||
|
|
||||||
const id = (await context.params)?.id;
|
const id = (await context.params)?.id;
|
||||||
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(
|
||||||
try {
|
req: Request,
|
||||||
await authenticateSuperuser();
|
context: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
await authenticateSuperuser();
|
||||||
|
|
||||||
const id = (await context.params)?.id; // Use `context.params?.id`
|
const id = (await context.params)?.id; // Use `context.params?.id`
|
||||||
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" } },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
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" } }
|
);
|
||||||
);
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,56 +1,59 @@
|
||||||
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 (!pb.authStore.isValid) {
|
if (!EMAIL || !PASSWORD) {
|
||||||
await pb.collection("_superusers").authWithPassword(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() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
await authenticateSuperuser();
|
await authenticateSuperuser();
|
||||||
const records = await pb.collection("budgetable").getFullList();
|
const records = await pb.collection("budgetable").getFullList();
|
||||||
return new Response(JSON.stringify(records), {
|
return new Response(JSON.stringify(records), {
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error fetching data:", error);
|
console.error("Error fetching data:", error);
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
error: {
|
error: {
|
||||||
message: "Failed to fetch data",
|
message: "Failed to fetch data",
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
status: 500,
|
status: 500,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
try {
|
try {
|
||||||
await authenticateSuperuser();
|
await authenticateSuperuser();
|
||||||
const data = await req.json();
|
const data = await req.json();
|
||||||
const record = await pb.collection("budgetable").create(data);
|
const record = await pb.collection("budgetable").create(data);
|
||||||
return new Response(JSON.stringify(record), {
|
return new Response(JSON.stringify(record), {
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error adding data:", error);
|
console.error("Error adding data:", error);
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
error: {
|
error: {
|
||||||
message: "Failed to add data",
|
message: "Failed to add data",
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
status: 500,
|
status: 500,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
30
src/components/delete-dialog.tsx
Normal file
30
src/components/delete-dialog.tsx
Normal 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
17
src/components/header.tsx
Normal 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;
|
5
src/components/loading-skeleton.tsx
Normal file
5
src/components/loading-skeleton.tsx
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
|
||||||
|
const LoadingSkeleton: React.FC = () => <Skeleton className="h-10 w-full" />;
|
||||||
|
|
||||||
|
export default LoadingSkeleton;
|
18
src/components/status-badge.tsx
Normal file
18
src/components/status-badge.tsx
Normal 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;
|
67
src/components/table-new-row.tsx
Normal file
67
src/components/table-new-row.tsx
Normal 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;
|
134
src/components/table-row.tsx
Normal file
134
src/components/table-row.tsx
Normal 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;
|
83
src/components/table-wrapper.tsx
Normal file
83
src/components/table-wrapper.tsx
Normal 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;
|
|
@ -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;
|
|
|
@ -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>;
|
||||||
}
|
}
|
||||||
|
|
14
src/components/total-display.tsx
Normal file
14
src/components/total-display.tsx
Normal 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;
|
|
@ -1,36 +1,36 @@
|
||||||
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",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default:
|
default:
|
||||||
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
|
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
|
||||||
secondary:
|
secondary:
|
||||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
destructive:
|
destructive:
|
||||||
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
|
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
|
||||||
outline: "text-foreground",
|
outline: "text-foreground",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
variant: "default",
|
variant: "default",
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
)
|
);
|
||||||
|
|
||||||
export interface BadgeProps
|
export interface BadgeProps
|
||||||
extends React.HTMLAttributes<HTMLDivElement>,
|
extends React.HTMLAttributes<HTMLDivElement>,
|
||||||
VariantProps<typeof badgeVariants> {}
|
VariantProps<typeof badgeVariants> {}
|
||||||
|
|
||||||
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 };
|
||||||
|
|
|
@ -1,57 +1,57 @@
|
||||||
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",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default:
|
default:
|
||||||
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||||
destructive:
|
destructive:
|
||||||
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||||
outline:
|
outline:
|
||||||
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
|
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||||
secondary:
|
secondary:
|
||||||
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
||||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||||
link: "text-primary underline-offset-4 hover:underline",
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
default: "h-9 px-4 py-2",
|
default: "h-9 px-4 py-2",
|
||||||
sm: "h-8 rounded-md px-3 text-xs",
|
sm: "h-8 rounded-md px-3 text-xs",
|
||||||
lg: "h-10 rounded-md px-8",
|
lg: "h-10 rounded-md px-8",
|
||||||
icon: "h-9 w-9",
|
icon: "h-9 w-9",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
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 };
|
||||||
|
|
|
@ -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<
|
const Card = React.forwardRef<
|
||||||
HTMLDivElement,
|
HTMLDivElement,
|
||||||
React.HTMLAttributes<HTMLDivElement>
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<div
|
<div
|
||||||
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,
|
||||||
React.HTMLAttributes<HTMLDivElement>
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
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,
|
||||||
React.HTMLAttributes<HTMLDivElement>
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
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,
|
||||||
React.HTMLAttributes<HTMLDivElement>
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
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,
|
||||||
React.HTMLAttributes<HTMLDivElement>
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
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,
|
||||||
|
};
|
||||||
|
|
|
@ -1,122 +1,122 @@
|
||||||
"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>,
|
||||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<DialogPrimitive.Overlay
|
<DialogPrimitive.Overlay
|
||||||
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>,
|
||||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||||
>(({ className, children, ...props }, ref) => (
|
>(({ className, children, ...props }, ref) => (
|
||||||
<DialogPortal>
|
<DialogPortal>
|
||||||
<DialogOverlay />
|
<DialogOverlay />
|
||||||
<DialogPrimitive.Content
|
<DialogPrimitive.Content
|
||||||
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}
|
||||||
>
|
>
|
||||||
{children}
|
{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">
|
<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" />
|
<X className="h-4 w-4" />
|
||||||
<span className="sr-only">Close</span>
|
<span className="sr-only">Close</span>
|
||||||
</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,
|
||||||
...props
|
...props
|
||||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
<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,
|
||||||
...props
|
...props
|
||||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
<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>,
|
||||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<DialogPrimitive.Title
|
<DialogPrimitive.Title
|
||||||
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>,
|
||||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<DialogPrimitive.Description
|
<DialogPrimitive.Description
|
||||||
ref={ref}
|
ref={ref}
|
||||||
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,
|
||||||
DialogPortal,
|
DialogPortal,
|
||||||
DialogOverlay,
|
DialogOverlay,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
DialogClose,
|
DialogClose,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
}
|
};
|
||||||
|
|
|
@ -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">>(
|
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
||||||
({ className, type, ...props }, ref) => {
|
({ className, type, ...props }, ref) => {
|
||||||
return (
|
return (
|
||||||
<input
|
<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 };
|
||||||
|
|
|
@ -1,15 +1,15 @@
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
function Skeleton({
|
function Skeleton({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
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 };
|
||||||
|
|
|
@ -1,31 +1,31 @@
|
||||||
"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
|
||||||
theme={theme as ToasterProps["theme"]}
|
theme={theme as ToasterProps["theme"]}
|
||||||
className="toaster group"
|
className="toaster group"
|
||||||
toastOptions={{
|
toastOptions={{
|
||||||
classNames: {
|
classNames: {
|
||||||
toast:
|
toast:
|
||||||
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
|
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
|
||||||
description: "group-[.toast]:text-muted-foreground",
|
description: "group-[.toast]:text-muted-foreground",
|
||||||
actionButton:
|
actionButton:
|
||||||
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
|
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
|
||||||
cancelButton:
|
cancelButton:
|
||||||
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
|
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export { Toaster }
|
export { Toaster };
|
||||||
|
|
|
@ -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<
|
const Table = React.forwardRef<
|
||||||
HTMLTableElement,
|
HTMLTableElement,
|
||||||
React.HTMLAttributes<HTMLTableElement>
|
React.HTMLAttributes<HTMLTableElement>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<div className="relative w-full overflow-auto">
|
<div className="relative w-full overflow-auto">
|
||||||
<table
|
<table
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn("w-full caption-bottom text-sm", className)}
|
className={cn("w-full caption-bottom text-sm", className)}
|
||||||
{...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,
|
||||||
React.HTMLAttributes<HTMLTableSectionElement>
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<tbody
|
<tbody
|
||||||
ref={ref}
|
ref={ref}
|
||||||
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,
|
||||||
React.HTMLAttributes<HTMLTableSectionElement>
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<tfoot
|
<tfoot
|
||||||
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,
|
||||||
React.HTMLAttributes<HTMLTableRowElement>
|
React.HTMLAttributes<HTMLTableRowElement>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<tr
|
<tr
|
||||||
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,
|
||||||
React.ThHTMLAttributes<HTMLTableCellElement>
|
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<th
|
<th
|
||||||
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,
|
||||||
React.TdHTMLAttributes<HTMLTableCellElement>
|
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<td
|
<td
|
||||||
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,
|
||||||
React.HTMLAttributes<HTMLTableCaptionElement>
|
React.HTMLAttributes<HTMLTableCaptionElement>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<caption
|
<caption
|
||||||
ref={ref}
|
ref={ref}
|
||||||
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,
|
||||||
TableHeader,
|
TableHeader,
|
||||||
TableBody,
|
TableBody,
|
||||||
TableFooter,
|
TableFooter,
|
||||||
TableHead,
|
TableHead,
|
||||||
TableRow,
|
TableRow,
|
||||||
TableCell,
|
TableCell,
|
||||||
TableCaption,
|
TableCaption,
|
||||||
}
|
};
|
||||||
|
|
|
@ -1,129 +1,129 @@
|
||||||
"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>,
|
||||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<ToastPrimitives.Viewport
|
<ToastPrimitives.Viewport
|
||||||
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",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: "border bg-background text-foreground",
|
default: "border bg-background text-foreground",
|
||||||
destructive:
|
destructive:
|
||||||
"destructive group border-destructive bg-destructive text-destructive-foreground",
|
"destructive group border-destructive bg-destructive text-destructive-foreground",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
variant: "default",
|
variant: "default",
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
)
|
);
|
||||||
|
|
||||||
const Toast = React.forwardRef<
|
const Toast = React.forwardRef<
|
||||||
React.ElementRef<typeof ToastPrimitives.Root>,
|
React.ElementRef<typeof ToastPrimitives.Root>,
|
||||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
|
||||||
VariantProps<typeof toastVariants>
|
VariantProps<typeof toastVariants>
|
||||||
>(({ className, variant, ...props }, ref) => {
|
>(({ className, variant, ...props }, ref) => {
|
||||||
return (
|
return (
|
||||||
<ToastPrimitives.Root
|
<ToastPrimitives.Root
|
||||||
ref={ref}
|
ref={ref}
|
||||||
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>,
|
||||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<ToastPrimitives.Action
|
<ToastPrimitives.Action
|
||||||
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>,
|
||||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<ToastPrimitives.Close
|
<ToastPrimitives.Close
|
||||||
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>,
|
||||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<ToastPrimitives.Title
|
<ToastPrimitives.Title
|
||||||
ref={ref}
|
ref={ref}
|
||||||
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>,
|
||||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<ToastPrimitives.Description
|
<ToastPrimitives.Description
|
||||||
ref={ref}
|
ref={ref}
|
||||||
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,
|
||||||
type ToastActionElement,
|
type ToastActionElement,
|
||||||
ToastProvider,
|
ToastProvider,
|
||||||
ToastViewport,
|
ToastViewport,
|
||||||
Toast,
|
Toast,
|
||||||
ToastTitle,
|
ToastTitle,
|
||||||
ToastDescription,
|
ToastDescription,
|
||||||
ToastClose,
|
ToastClose,
|
||||||
ToastAction,
|
ToastAction,
|
||||||
}
|
};
|
||||||
|
|
|
@ -1,35 +1,31 @@
|
||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import { useToast } from "@/hooks/use-toast"
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import {
|
import {
|
||||||
Toast,
|
Toast,
|
||||||
ToastClose,
|
ToastClose,
|
||||||
ToastDescription,
|
ToastDescription,
|
||||||
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 && <ToastDescription>{description}</ToastDescription>}
|
||||||
{description && (
|
</div>
|
||||||
<ToastDescription>{description}</ToastDescription>
|
{action}
|
||||||
)}
|
<ToastClose />
|
||||||
</div>
|
</Toast>
|
||||||
{action}
|
))}
|
||||||
<ToastClose />
|
<ToastViewport />
|
||||||
</Toast>
|
</ToastProvider>
|
||||||
)
|
);
|
||||||
})}
|
|
||||||
<ToastViewport />
|
|
||||||
</ToastProvider>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,194 +1,191 @@
|
||||||
"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) {
|
||||||
case "ADD_TOAST":
|
case "ADD_TOAST":
|
||||||
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 {
|
||||||
...state,
|
...state,
|
||||||
toasts: state.toasts.map((t) =>
|
toasts: state.toasts.map((t) =>
|
||||||
t.id === toastId || toastId === undefined
|
t.id === toastId || toastId === undefined
|
||||||
? {
|
? {
|
||||||
...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",
|
||||||
toast: {
|
toast: {
|
||||||
...props,
|
...props,
|
||||||
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 };
|
||||||
|
|
|
@ -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;
|
|
@ -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));
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue