mirror of
https://github.com/Noooste/garage-ui.git
synced 2026-07-26 07:48:13 +00:00
feat: integrate sonner for toast notifications and update project metadata
This commit is contained in:
+2
-2
@@ -2,9 +2,9 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/garage.png" />
|
||||
<link rel="icon" type="image/png" href="/garage.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>frontend</title>
|
||||
<title>Garage UI</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
Generated
+482
-3
@@ -9,6 +9,7 @@
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"axios": "^1.13.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
@@ -21,6 +22,7 @@
|
||||
"react-hook-form": "^7.66.1",
|
||||
"react-router-dom": "^7.9.6",
|
||||
"recharts": "^3.5.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"zod": "^4.1.13",
|
||||
"zustand": "^5.0.8"
|
||||
@@ -88,6 +90,7 @@
|
||||
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
"@babel/generator": "^7.28.5",
|
||||
@@ -927,6 +930,44 @@
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/core": {
|
||||
"version": "1.7.3",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz",
|
||||
"integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/utils": "^0.2.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/dom": {
|
||||
"version": "1.7.4",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz",
|
||||
"integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/core": "^1.7.3",
|
||||
"@floating-ui/utils": "^0.2.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/react-dom": {
|
||||
"version": "2.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.6.tgz",
|
||||
"integrity": "sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/dom": "^1.7.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/utils": {
|
||||
"version": "0.2.10",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz",
|
||||
"integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@hookform/resolvers": {
|
||||
"version": "5.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.2.2.tgz",
|
||||
@@ -1075,6 +1116,415 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/primitive": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
|
||||
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@radix-ui/react-arrow": {
|
||||
"version": "1.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz",
|
||||
"integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-primitive": "2.1.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-compose-refs": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
|
||||
"integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-context": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
|
||||
"integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-dismissable-layer": {
|
||||
"version": "1.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz",
|
||||
"integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.3",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-use-callback-ref": "1.1.1",
|
||||
"@radix-ui/react-use-escape-keydown": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-id": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz",
|
||||
"integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-popper": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz",
|
||||
"integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/react-dom": "^2.0.0",
|
||||
"@radix-ui/react-arrow": "1.1.7",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-use-callback-ref": "1.1.1",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.1",
|
||||
"@radix-ui/react-use-rect": "1.1.1",
|
||||
"@radix-ui/react-use-size": "1.1.1",
|
||||
"@radix-ui/rect": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-portal": {
|
||||
"version": "1.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz",
|
||||
"integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-presence": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz",
|
||||
"integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-primitive": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
|
||||
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "1.2.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-tooltip": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz",
|
||||
"integrity": "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.3",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-dismissable-layer": "1.1.11",
|
||||
"@radix-ui/react-id": "1.1.1",
|
||||
"@radix-ui/react-popper": "1.2.8",
|
||||
"@radix-ui/react-portal": "1.1.9",
|
||||
"@radix-ui/react-presence": "1.1.5",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-slot": "1.2.3",
|
||||
"@radix-ui/react-use-controllable-state": "1.2.2",
|
||||
"@radix-ui/react-visually-hidden": "1.2.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-callback-ref": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
|
||||
"integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-controllable-state": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz",
|
||||
"integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-use-effect-event": "0.0.2",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-effect-event": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz",
|
||||
"integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-escape-keydown": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz",
|
||||
"integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-use-callback-ref": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-layout-effect": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz",
|
||||
"integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-rect": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz",
|
||||
"integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/rect": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-size": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz",
|
||||
"integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-visually-hidden": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz",
|
||||
"integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-primitive": "2.1.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/rect": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz",
|
||||
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@reduxjs/toolkit": {
|
||||
"version": "2.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.0.tgz",
|
||||
@@ -1868,6 +2318,7 @@
|
||||
"integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~7.16.0"
|
||||
}
|
||||
@@ -1878,6 +2329,7 @@
|
||||
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
@@ -1886,8 +2338,9 @@
|
||||
"version": "19.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
|
||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
@@ -1944,6 +2397,7 @@
|
||||
"integrity": "sha512-lJi3PfxVmo0AkEY93ecfN+r8SofEqZNGByvHAI3GBLrvt1Cw6H5k1IM02nSzu0RfUafr2EvFSw0wAsZgubNplQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.47.0",
|
||||
"@typescript-eslint/types": "8.47.0",
|
||||
@@ -2195,6 +2649,7 @@
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -2371,6 +2826,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.8.25",
|
||||
"caniuse-lite": "^1.0.30001754",
|
||||
@@ -2876,6 +3332,7 @@
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.1.tgz",
|
||||
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -4179,6 +4636,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
@@ -4256,6 +4714,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
|
||||
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -4265,6 +4724,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
|
||||
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
@@ -4294,6 +4754,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.66.1.tgz",
|
||||
"integrity": "sha512-2KnjpgG2Rhbi+CIiIBQQ9Df6sMGH5ExNyFl4Hw9qO7pIqMBR8Bvu9RQyjl3JM4vehzCh9soiNUM/xYMswb2EiA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
@@ -4309,13 +4770,15 @@
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/react-redux": {
|
||||
"version": "9.2.0",
|
||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
||||
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/use-sync-external-store": "^0.0.6",
|
||||
"use-sync-external-store": "^1.4.0"
|
||||
@@ -4417,7 +4880,8 @@
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/redux-thunk": {
|
||||
"version": "3.1.0",
|
||||
@@ -4563,6 +5027,16 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/sonner": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz",
|
||||
"integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc",
|
||||
"react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
@@ -4675,6 +5149,7 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -4732,6 +5207,7 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -4848,6 +5324,7 @@
|
||||
"integrity": "sha512-NL8jTlbo0Tn4dUEXEsUg8KeyG/Lkmc4Fnzb8JXN/Ykm9G4HNImjtABMJgkQoVjOBN/j2WAwDTRytdqJbZsah7w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -4941,6 +5418,7 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -4996,6 +5474,7 @@
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.1.13.tgz",
|
||||
"integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"axios": "^1.13.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
@@ -23,6 +24,7 @@
|
||||
"react-hook-form": "^7.66.1",
|
||||
"react-router-dom": "^7.9.6",
|
||||
"recharts": "^3.5.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"zod": "^4.1.13",
|
||||
"zustand": "^5.0.8"
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Layout } from '@/components/layout/layout';
|
||||
import { Dashboard } from '@/pages/Dashboard';
|
||||
import { Buckets } from '@/pages/Buckets';
|
||||
import { AccessControl } from '@/pages/AccessControl';
|
||||
import { Toaster } from 'sonner';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
@@ -17,6 +18,7 @@ function App() {
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
<Toaster richColors position="bottom-right" />
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,13 +8,13 @@ interface HeaderProps {
|
||||
|
||||
export function Header({ title }: HeaderProps) {
|
||||
return (
|
||||
<header className="sticky top-0 z-40 border-b bg-background">
|
||||
<div className="flex h-16 items-center gap-4 px-6">
|
||||
<div className="flex-1">
|
||||
<h1 className="text-2xl font-semibold">{title}</h1>
|
||||
<header className="sticky top-0 z-40 border-b" style={{ backgroundColor: 'hsl(var(--background))' }}>
|
||||
<div className="flex h-16 items-center gap-2 sm:gap-4 px-4 sm:px-6 md:pl-6">
|
||||
<div className="flex-1 min-w-0 md:ml-0 ml-12">
|
||||
<h1 className="text-lg sm:text-xl md:text-2xl font-semibold truncate">{title}</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative w-64">
|
||||
<div className="relative hidden sm:block w-32 md:w-64">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="search"
|
||||
|
||||
@@ -1,10 +1,33 @@
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { Sidebar } from './sidebar';
|
||||
import { useState } from 'react';
|
||||
import { Menu } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
export function Layout() {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden">
|
||||
<Sidebar />
|
||||
{/* Mobile menu button */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="fixed top-4 left-4 z-50 md:hidden"
|
||||
onClick={() => setSidebarOpen(!sidebarOpen)}
|
||||
>
|
||||
<Menu className="h-6 w-6" />
|
||||
</Button>
|
||||
|
||||
{/* Mobile overlay */}
|
||||
{sidebarOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-40 md:hidden"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Sidebar isOpen={sidebarOpen} onClose={() => setSidebarOpen(false)} />
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
<Outlet />
|
||||
</main>
|
||||
|
||||
@@ -42,11 +42,23 @@ const navItems: NavItem[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export function Sidebar() {
|
||||
interface SidebarProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function Sidebar({ isOpen, onClose }: SidebarProps) {
|
||||
const location = useLocation();
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-64 flex-col border-r bg-card">
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-full w-64 flex-col border-r transition-transform duration-300 ease-in-out md:translate-x-0',
|
||||
'fixed md:static z-50',
|
||||
isOpen ? 'translate-x-0' : '-translate-x-full'
|
||||
)}
|
||||
style={{ backgroundColor: 'hsl(var(--background))' }}
|
||||
>
|
||||
<div className="flex h-16 items-center border-b px-6">
|
||||
<img src="/garage.png" alt="Garage UI Logo" className="h-8 w-8 mr-2" />
|
||||
<span className="text-lg font-semibold">Garage UI</span>
|
||||
@@ -60,6 +72,7 @@ export function Sidebar() {
|
||||
<Link
|
||||
key={item.href}
|
||||
to={item.href}
|
||||
onClick={onClose}
|
||||
className={cn(
|
||||
'flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
|
||||
isActive
|
||||
|
||||
@@ -7,12 +7,14 @@ const buttonVariants = cva(
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-[#ff9329] text-black hover:bg-[#e58625]',
|
||||
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive',
|
||||
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
|
||||
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
default: 'bg-[#ff9329] text-black hover:bg-[#e58625] cursor-pointer',
|
||||
default_disabled: 'bg-[#ff9329] text-black opacity-50 cursor-not-allowed',
|
||||
secondary: 'border border-[#ff9329] text-[#ff9329] cursor-pointer',
|
||||
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive cursor-pointer',
|
||||
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground cursor-pointer',
|
||||
outline_disabled: 'border border-input bg-background text-muted-foreground opacity-50 cursor-not-allowed',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground cursor-pointer',
|
||||
link: 'text-primary underline-offset-4 hover:underline cursor-pointer',
|
||||
},
|
||||
size: {
|
||||
default: 'h-10 px-4 py-2',
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface CheckboxProps
|
||||
extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
onCheckedChange?: (checked: boolean) => void;
|
||||
}
|
||||
|
||||
const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(
|
||||
({ className, onCheckedChange, onChange, ...props }, ref) => {
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (onCheckedChange) {
|
||||
onCheckedChange(e.target.checked);
|
||||
}
|
||||
if (onChange) {
|
||||
onChange(e);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<input
|
||||
type="checkbox"
|
||||
className={cn(
|
||||
'h-4 w-4 shrink-0 border border-primary rounded cursor-pointer ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 accent-primary',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
onChange={handleChange}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
Checkbox.displayName = 'Checkbox';
|
||||
|
||||
export { Checkbox };
|
||||
@@ -62,7 +62,7 @@ const DialogOverlay = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTML
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-black 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/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
className
|
||||
)}
|
||||
onClick={() => onOpenChange(false)}
|
||||
@@ -79,12 +79,12 @@ const DialogContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTML
|
||||
return (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<div className="fixed left-[50%] top-[50%] z-50 translate-x-[-50%] translate-y-[-50%] w-full max-w-lg">
|
||||
<div className="fixed left-[50%] top-[50%] z-50 translate-x-[-50%] translate-y-[-50%] w-[calc(100%-2rem)] sm:w-full max-w-lg">
|
||||
<div
|
||||
ref={ref}
|
||||
style={{ backgroundColor: 'hsl(var(--background))' }}
|
||||
className={cn(
|
||||
'relative p-6 shadow-lg duration-200 rounded-lg border',
|
||||
'relative p-4 sm:p-6 shadow-lg duration-200 rounded-lg border',
|
||||
'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',
|
||||
@@ -97,7 +97,7 @@ const DialogContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTML
|
||||
{children}
|
||||
<button
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="absolute right-4 top-4 rounded-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none"
|
||||
className="absolute right-4 top-4 rounded-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 cursor-pointer"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
@@ -121,7 +121,7 @@ const DialogFooter: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({
|
||||
...props
|
||||
}) => (
|
||||
<div
|
||||
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}
|
||||
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end space-y-2 space-y-reverse sm:space-y-0 sm:space-x-2', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
@@ -142,7 +142,7 @@ const DialogDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
|
||||
<p ref={ref} className={cn('text-xs text-muted-foreground', className)} {...props} />
|
||||
));
|
||||
DialogDescription.displayName = 'DialogDescription';
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import * as React from 'react';
|
||||
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const TooltipProvider = TooltipPrimitive.Provider;
|
||||
|
||||
const Tooltip = TooltipPrimitive.Root;
|
||||
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger;
|
||||
|
||||
const TooltipContent = React.forwardRef<
|
||||
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'z-50 overflow-hidden rounded-md border border-neutral-200 bg-neutral-950 px-3 py-1.5 text-sm text-neutral-50 shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:border-neutral-800 dark:bg-neutral-950 dark:text-neutral-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
||||
@@ -67,7 +67,10 @@ export const objectsApi = {
|
||||
|
||||
// Filter mock objects by prefix
|
||||
if (!_prefix) {
|
||||
return mockObjectsFlat.filter(obj => !obj.key.includes('/'));
|
||||
return mockObjectsFlat.filter(obj => {
|
||||
// Include root-level files (no slash) and root-level folders (single slash at end)
|
||||
return !obj.key.includes('/') || (obj.key.endsWith('/') && !obj.key.slice(0, -1).includes('/'));
|
||||
});
|
||||
}
|
||||
|
||||
// Get all objects with this prefix, but only direct children
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
ShieldX,
|
||||
Copy,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export function AccessControl() {
|
||||
const [keys, setKeys] = useState<AccessKey[]>([]);
|
||||
@@ -74,46 +75,74 @@ export function AccessControl() {
|
||||
}, [searchQuery, keys]);
|
||||
|
||||
const handleCreateKey = async () => {
|
||||
if (!newKeyName) return;
|
||||
if (!newKeyName) {
|
||||
toast.error('Please enter a key name');
|
||||
return;
|
||||
}
|
||||
|
||||
const permissions: Permission[] = [
|
||||
{
|
||||
resource: newKeyResource,
|
||||
actions: newKeyActions,
|
||||
effect: 'Allow',
|
||||
},
|
||||
];
|
||||
if (newKeyActions.length === 0) {
|
||||
toast.error('Please select at least one action');
|
||||
return;
|
||||
}
|
||||
|
||||
await accessApi.createKey(newKeyName, permissions);
|
||||
setCreateDialogOpen(false);
|
||||
setNewKeyName('');
|
||||
setNewKeyResource('*');
|
||||
setNewKeyActions(['GetObject']);
|
||||
try {
|
||||
const permissions: Permission[] = [
|
||||
{
|
||||
resource: newKeyResource,
|
||||
actions: newKeyActions,
|
||||
effect: 'Allow',
|
||||
},
|
||||
];
|
||||
|
||||
// Refresh keys list
|
||||
const data = await accessApi.listKeys();
|
||||
setKeys(data);
|
||||
await accessApi.createKey(newKeyName, permissions);
|
||||
setCreateDialogOpen(false);
|
||||
setNewKeyName('');
|
||||
setNewKeyResource('*');
|
||||
setNewKeyActions(['GetObject']);
|
||||
|
||||
// Refresh keys list
|
||||
const data = await accessApi.listKeys();
|
||||
setKeys(data);
|
||||
toast.success(`API Key "${newKeyName}" created successfully`);
|
||||
} catch (error) {
|
||||
toast.error('Failed to create API key. Please try again.');
|
||||
console.error('Create key error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteKey = async () => {
|
||||
if (!selectedKey) return;
|
||||
|
||||
await accessApi.deleteKey(selectedKey.accessKeyId);
|
||||
setDeleteDialogOpen(false);
|
||||
setSelectedKey(null);
|
||||
try {
|
||||
await accessApi.deleteKey(selectedKey.accessKeyId);
|
||||
const keyName = selectedKey.name;
|
||||
setDeleteDialogOpen(false);
|
||||
setSelectedKey(null);
|
||||
|
||||
// Refresh keys list
|
||||
const data = await accessApi.listKeys();
|
||||
setKeys(data);
|
||||
// Refresh keys list
|
||||
const data = await accessApi.listKeys();
|
||||
setKeys(data);
|
||||
toast.success(`API Key "${keyName}" deleted successfully`);
|
||||
} catch (error) {
|
||||
toast.error('Failed to delete API key. Please try again.');
|
||||
console.error('Delete key error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleKeyStatus = async (key: AccessKey) => {
|
||||
const newStatus = key.status === 'active' ? 'inactive' : 'active';
|
||||
await accessApi.updateKey(key.accessKeyId, { status: newStatus });
|
||||
|
||||
// Refresh keys list
|
||||
const data = await accessApi.listKeys();
|
||||
setKeys(data);
|
||||
try {
|
||||
await accessApi.updateKey(key.accessKeyId, { status: newStatus });
|
||||
|
||||
// Refresh keys list
|
||||
const data = await accessApi.listKeys();
|
||||
setKeys(data);
|
||||
toast.success(`API Key "${key.name}" ${newStatus === 'active' ? 'activated' : 'deactivated'} successfully`);
|
||||
} catch (error) {
|
||||
toast.error('Failed to update API key status. Please try again.');
|
||||
console.error('Toggle key status error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const availableActions = [
|
||||
@@ -131,16 +160,16 @@ export function AccessControl() {
|
||||
<Header
|
||||
title="Access Control"
|
||||
/>
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="p-4 sm:p-6 space-y-4 sm:space-y-6">
|
||||
<Tabs defaultValue="keys">
|
||||
<TabsList>
|
||||
<TabsTrigger value="keys">API Keys</TabsTrigger>
|
||||
<TabsTrigger value="policies">Bucket Policies</TabsTrigger>
|
||||
<TabsList className="w-full sm:w-auto">
|
||||
<TabsTrigger value="keys" className="flex-1 sm:flex-initial">API Keys</TabsTrigger>
|
||||
<TabsTrigger value="policies" className="flex-1 sm:flex-initial">Bucket Policies</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="keys" className="space-y-6 mt-6">
|
||||
<TabsContent value="keys" className="space-y-4 sm:space-y-6 mt-4 sm:mt-6">
|
||||
{/* Stats */}
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<div className="grid gap-3 sm:gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Keys</CardTitle>
|
||||
@@ -177,8 +206,8 @@ export function AccessControl() {
|
||||
</div>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="relative w-80">
|
||||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-3">
|
||||
<div className="relative flex-1 max-w-full sm:max-w-xs">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search keys..."
|
||||
@@ -187,23 +216,23 @@ export function AccessControl() {
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={() => setCreateDialogOpen(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
<Button onClick={() => setCreateDialogOpen(true)} className="w-full sm:w-auto">
|
||||
<Plus className="h-4 w-4" />
|
||||
Create Key
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Keys Table */}
|
||||
<div className="border rounded-lg">
|
||||
<div className="border rounded-lg overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Access Key ID</TableHead>
|
||||
<TableHead className="hidden sm:table-cell">Access Key ID</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead>Last Used</TableHead>
|
||||
<TableHead>Permissions</TableHead>
|
||||
<TableHead className="hidden md:table-cell">Created</TableHead>
|
||||
<TableHead className="hidden lg:table-cell">Last Used</TableHead>
|
||||
<TableHead className="hidden md:table-cell">Permissions</TableHead>
|
||||
<TableHead className="w-[50px]"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -217,17 +246,20 @@ export function AccessControl() {
|
||||
) : (
|
||||
filteredKeys.map((key) => (
|
||||
<TableRow key={key.accessKeyId}>
|
||||
<TableCell className="font-medium">{key.name}</TableCell>
|
||||
<TableCell>
|
||||
<TableCell className="font-medium truncate max-w-[150px]">{key.name}</TableCell>
|
||||
<TableCell className="hidden sm:table-cell">
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="text-xs bg-muted px-2 py-1 rounded">
|
||||
<code className="text-xs bg-muted px-2 py-1 rounded truncate max-w-[150px] block">
|
||||
{key.accessKeyId}
|
||||
</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => navigator.clipboard.writeText(key.accessKeyId)}
|
||||
className="h-6 w-6 flex-shrink-0"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(key.accessKeyId);
|
||||
toast.success('Access Key ID copied to clipboard');
|
||||
}}
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</Button>
|
||||
@@ -238,11 +270,11 @@ export function AccessControl() {
|
||||
{key.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{formatDate(key.createdAt)}</TableCell>
|
||||
<TableCell>
|
||||
<TableCell className="hidden md:table-cell">{formatDate(key.createdAt)}</TableCell>
|
||||
<TableCell className="hidden lg:table-cell">
|
||||
{key.lastUsed ? formatDate(key.lastUsed) : 'Never'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TableCell className="hidden md:table-cell">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{key.permissions.slice(0, 2).map((perm, idx) => (
|
||||
<Badge key={idx} variant="outline" className="text-xs">
|
||||
@@ -265,18 +297,18 @@ export function AccessControl() {
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
<Edit className="h-4 w-4" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleToggleKeyStatus(key)}>
|
||||
{key.status === 'active' ? (
|
||||
<>
|
||||
<ShieldX className="mr-2 h-4 w-4" />
|
||||
<ShieldX className="h-4 w-4" />
|
||||
Deactivate
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ShieldCheck className="mr-2 h-4 w-4" />
|
||||
<ShieldCheck className="h-4 w-4" />
|
||||
Activate
|
||||
</>
|
||||
)}
|
||||
@@ -352,7 +384,7 @@ export function AccessControl() {
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Actions</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
{availableActions.map((action) => (
|
||||
<label key={action} className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
|
||||
+438
-105
@@ -46,7 +46,16 @@ import {
|
||||
Eye,
|
||||
RotateCwIcon,
|
||||
FolderPlus,
|
||||
Trash,
|
||||
} from 'lucide-react';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export function Buckets() {
|
||||
const [buckets, setBuckets] = useState<Bucket[]>([]);
|
||||
@@ -69,6 +78,10 @@ export function Buckets() {
|
||||
const [selectedObject, setSelectedObject] = useState<S3Object | null>(null);
|
||||
const [createDirDialogOpen, setCreateDirDialogOpen] = useState(false);
|
||||
const [newDirName, setNewDirName] = useState('');
|
||||
const [sortColumn, setSortColumn] = useState<'name' | 'size' | 'modified'>('name');
|
||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
|
||||
const [selectedFileKeys, setSelectedFileKeys] = useState<Set<string>>(new Set());
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
|
||||
// Main area drag & drop
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
@@ -83,14 +96,20 @@ export function Buckets() {
|
||||
const uploadFiles = async (files: File[]) => {
|
||||
if (!viewingBucket) return;
|
||||
|
||||
for (const file of files) {
|
||||
const key = currentPath ? `${currentPath}${file.name}` : file.name;
|
||||
await objectsApi.upload(viewingBucket, key, file);
|
||||
}
|
||||
try {
|
||||
for (const file of files) {
|
||||
const key = currentPath ? `${currentPath}${file.name}` : file.name;
|
||||
await objectsApi.upload(viewingBucket, key, file);
|
||||
}
|
||||
|
||||
// Refresh objects list
|
||||
const data = await objectsApi.list(viewingBucket, currentPath);
|
||||
setObjects(data);
|
||||
// Refresh objects list
|
||||
const data = await objectsApi.list(viewingBucket, currentPath);
|
||||
setObjects(data);
|
||||
toast.success(`Successfully uploaded ${files.length} file${files.length > 1 ? 's' : ''}`);
|
||||
} catch (error) {
|
||||
toast.error('Failed to upload files. Please try again.');
|
||||
console.error('Upload error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -122,36 +141,88 @@ export function Buckets() {
|
||||
}
|
||||
}, [viewingBucket, currentPath]);
|
||||
|
||||
const sortObjects = (objList: S3Object[]): S3Object[] => {
|
||||
const sorted = [...objList].sort((a, b) => {
|
||||
// Always put folders before files
|
||||
const aIsFolder = a.isFolder ? 1 : 0;
|
||||
const bIsFolder = b.isFolder ? 1 : 0;
|
||||
if (aIsFolder !== bIsFolder) {
|
||||
return bIsFolder - aIsFolder; // Folders first (1 > 0)
|
||||
}
|
||||
|
||||
// Then sort by selected column
|
||||
let compareValue = 0;
|
||||
switch (sortColumn) {
|
||||
case 'name': {
|
||||
const aName = a.key.replace(currentPath, '').replace('/', '').toLowerCase();
|
||||
const bName = b.key.replace(currentPath, '').replace('/', '').toLowerCase();
|
||||
compareValue = aName.localeCompare(bName);
|
||||
break;
|
||||
}
|
||||
case 'size':
|
||||
compareValue = a.size - b.size;
|
||||
break;
|
||||
case 'modified': {
|
||||
const aDate = new Date(a.lastModified).getTime();
|
||||
const bDate = new Date(b.lastModified).getTime();
|
||||
compareValue = aDate - bDate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return sortDirection === 'asc' ? compareValue : -compareValue;
|
||||
});
|
||||
|
||||
return sorted;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const filtered = objects.filter((obj) =>
|
||||
obj.key.toLowerCase().includes(objectSearchQuery.toLowerCase())
|
||||
);
|
||||
setFilteredObjects(filtered);
|
||||
}, [objectSearchQuery, objects]);
|
||||
const sorted = sortObjects(filtered);
|
||||
setFilteredObjects(sorted);
|
||||
}, [objectSearchQuery, objects, sortColumn, sortDirection, currentPath]);
|
||||
|
||||
const handleCreateBucket = async () => {
|
||||
if (!newBucketName) return;
|
||||
if (!newBucketName) {
|
||||
toast.error('Please enter a bucket name');
|
||||
return;
|
||||
}
|
||||
|
||||
await bucketsApi.create(newBucketName);
|
||||
setCreateDialogOpen(false);
|
||||
setNewBucketName('');
|
||||
setShowCreatePreview(false);
|
||||
try {
|
||||
await bucketsApi.create(newBucketName);
|
||||
setCreateDialogOpen(false);
|
||||
setNewBucketName('');
|
||||
setShowCreatePreview(false);
|
||||
|
||||
// Refresh bucket list
|
||||
const data = await bucketsApi.list();
|
||||
setBuckets(data);
|
||||
// Refresh bucket list
|
||||
const data = await bucketsApi.list();
|
||||
setBuckets(data);
|
||||
toast.success(`Bucket "${newBucketName}" created successfully`);
|
||||
} catch (error) {
|
||||
toast.error('Failed to create bucket. Please try again.');
|
||||
console.error('Create bucket error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteBucket = async () => {
|
||||
if (!selectedBucket) return;
|
||||
|
||||
await bucketsApi.delete(selectedBucket.name);
|
||||
setDeleteBucketDialogOpen(false);
|
||||
setSelectedBucket(null);
|
||||
try {
|
||||
await bucketsApi.delete(selectedBucket.name);
|
||||
const bucketName = selectedBucket.name;
|
||||
setDeleteBucketDialogOpen(false);
|
||||
setSelectedBucket(null);
|
||||
|
||||
// Refresh bucket list
|
||||
const data = await bucketsApi.list();
|
||||
setBuckets(data);
|
||||
// Refresh bucket list
|
||||
const data = await bucketsApi.list();
|
||||
setBuckets(data);
|
||||
toast.success(`Bucket "${bucketName}" deleted successfully`);
|
||||
} catch (error) {
|
||||
toast.error('Failed to delete bucket. Please try again.');
|
||||
console.error('Delete bucket error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleViewBucket = (bucketName: string) => {
|
||||
@@ -174,34 +245,104 @@ export function Buckets() {
|
||||
const handleDeleteObject = async () => {
|
||||
if (!selectedObject || !viewingBucket) return;
|
||||
|
||||
await objectsApi.delete(viewingBucket, selectedObject.key);
|
||||
setDeleteObjectDialogOpen(false);
|
||||
setSelectedObject(null);
|
||||
try {
|
||||
await objectsApi.delete(viewingBucket, selectedObject.key);
|
||||
const objectName = selectedObject.key;
|
||||
setDeleteObjectDialogOpen(false);
|
||||
setSelectedObject(null);
|
||||
|
||||
// Refresh objects list
|
||||
const data = await objectsApi.list(viewingBucket, currentPath);
|
||||
setObjects(data);
|
||||
// Refresh objects list
|
||||
const data = await objectsApi.list(viewingBucket, currentPath);
|
||||
setObjects(data);
|
||||
toast.success(`Object "${objectName}" deleted successfully`);
|
||||
} catch (error) {
|
||||
toast.error('Failed to delete object. Please try again.');
|
||||
console.error('Delete object error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefreshObjects = async () => {
|
||||
if (!viewingBucket) return;
|
||||
const data = await objectsApi.list(viewingBucket, currentPath);
|
||||
setObjects(data);
|
||||
if (!viewingBucket || isRefreshing) return;
|
||||
try {
|
||||
setIsRefreshing(true);
|
||||
const data = await objectsApi.list(viewingBucket, currentPath);
|
||||
setObjects(data);
|
||||
toast.success('Objects refreshed successfully');
|
||||
} catch (error) {
|
||||
toast.error('Failed to refresh objects. Please try again.');
|
||||
console.error('Refresh error:', error);
|
||||
} finally {
|
||||
setTimeout(() => setIsRefreshing(false), 600);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleFileSelection = (key: string) => {
|
||||
const newSelected = new Set(selectedFileKeys);
|
||||
if (newSelected.has(key)) {
|
||||
newSelected.delete(key);
|
||||
} else {
|
||||
newSelected.add(key);
|
||||
}
|
||||
setSelectedFileKeys(newSelected);
|
||||
};
|
||||
|
||||
const handleSelectAllFiles = () => {
|
||||
const fileKeys = filteredObjects
|
||||
.filter(obj => !obj.isFolder)
|
||||
.map(obj => obj.key);
|
||||
|
||||
if (selectedFileKeys.size === fileKeys.length && fileKeys.length > 0) {
|
||||
// Deselect all
|
||||
setSelectedFileKeys(new Set());
|
||||
} else {
|
||||
// Select all files
|
||||
setSelectedFileKeys(new Set(fileKeys));
|
||||
}
|
||||
};
|
||||
|
||||
const handleBulkDeleteFiles = async () => {
|
||||
if (!viewingBucket || selectedFileKeys.size === 0) return;
|
||||
|
||||
try {
|
||||
const count = selectedFileKeys.size;
|
||||
for (const key of selectedFileKeys) {
|
||||
await objectsApi.delete(viewingBucket, key);
|
||||
}
|
||||
|
||||
setSelectedFileKeys(new Set());
|
||||
|
||||
// Refresh objects list
|
||||
const data = await objectsApi.list(viewingBucket, currentPath);
|
||||
setObjects(data);
|
||||
toast.success(`Successfully deleted ${count} file${count > 1 ? 's' : ''}`);
|
||||
} catch (error) {
|
||||
toast.error('Failed to delete files. Please try again.');
|
||||
console.error('Bulk delete error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateDirectory = async () => {
|
||||
if (!newDirName || !viewingBucket) return;
|
||||
if (!newDirName || !viewingBucket) {
|
||||
toast.error('Please enter a directory name');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a directory by uploading an empty object with a trailing slash
|
||||
const dirKey = currentPath ? `${currentPath}${newDirName}/` : `${newDirName}/`;
|
||||
await objectsApi.upload(viewingBucket, dirKey, new File([], ''));
|
||||
try {
|
||||
// Create a directory by uploading an empty object with a trailing slash
|
||||
const dirKey = currentPath ? `${currentPath}${newDirName}/` : `${newDirName}/`;
|
||||
await objectsApi.upload(viewingBucket, dirKey, new File([], ''));
|
||||
|
||||
setCreateDirDialogOpen(false);
|
||||
setNewDirName('');
|
||||
setCreateDirDialogOpen(false);
|
||||
setNewDirName('');
|
||||
|
||||
// Refresh objects list
|
||||
const data = await objectsApi.list(viewingBucket, currentPath);
|
||||
setObjects(data);
|
||||
// Refresh objects list
|
||||
const data = await objectsApi.list(viewingBucket, currentPath);
|
||||
setObjects(data);
|
||||
toast.success(`Directory "${newDirName}" created successfully`);
|
||||
} catch (error) {
|
||||
toast.error('Failed to create directory. Please try again.');
|
||||
console.error('Create directory error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const getBreadcrumbs = () => {
|
||||
@@ -218,6 +359,69 @@ export function Buckets() {
|
||||
return breadcrumbs;
|
||||
};
|
||||
|
||||
const getFileType = (filename: string): string => {
|
||||
if (!filename) return 'Unknown';
|
||||
|
||||
const extension = filename.split('.').pop()?.toLowerCase() || '';
|
||||
if (!extension) return 'File';
|
||||
|
||||
const typeMap: Record<string, string> = {
|
||||
// Images
|
||||
'png': 'Image',
|
||||
'jpg': 'Image',
|
||||
'jpeg': 'Image',
|
||||
'gif': 'Image',
|
||||
'svg': 'Image',
|
||||
'webp': 'Image',
|
||||
|
||||
// Documents
|
||||
'pdf': 'PDF',
|
||||
'doc': 'Document',
|
||||
'docx': 'Document',
|
||||
'xls': 'Spreadsheet',
|
||||
'xlsx': 'Spreadsheet',
|
||||
'ppt': 'Presentation',
|
||||
'pptx': 'Presentation',
|
||||
'txt': 'Text',
|
||||
|
||||
// Archives
|
||||
'zip': 'Archive',
|
||||
'rar': 'Archive',
|
||||
'gz': 'Archive',
|
||||
'tar': 'Archive',
|
||||
|
||||
// Video/Audio
|
||||
'mp4': 'Video',
|
||||
'avi': 'Video',
|
||||
'mov': 'Video',
|
||||
'mkv': 'Video',
|
||||
'webm': 'Video',
|
||||
'mp3': 'Audio',
|
||||
'wav': 'Audio',
|
||||
'flac': 'Audio',
|
||||
|
||||
// Code
|
||||
'js': 'JavaScript',
|
||||
'ts': 'TypeScript',
|
||||
'tsx': 'TypeScript',
|
||||
'jsx': 'JavaScript',
|
||||
'py': 'Python',
|
||||
'java': 'Java',
|
||||
'cpp': 'C++',
|
||||
'c': 'C',
|
||||
'html': 'HTML',
|
||||
'css': 'CSS',
|
||||
'json': 'JSON',
|
||||
'xml': 'XML',
|
||||
'sql': 'SQL',
|
||||
|
||||
// Data
|
||||
'csv': 'CSV',
|
||||
};
|
||||
|
||||
return typeMap[extension] || extension.toUpperCase();
|
||||
};
|
||||
|
||||
// If viewing a bucket's objects, show the objects view
|
||||
if (viewingBucket) {
|
||||
return (
|
||||
@@ -225,15 +429,16 @@ export function Buckets() {
|
||||
<Header
|
||||
title={`Objects in ${viewingBucket}`}
|
||||
/>
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="p-4 sm:p-6 space-y-4 sm:space-y-6">
|
||||
{/* Back Button */}
|
||||
<Button variant="outline" onClick={handleBackToBuckets}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Buckets
|
||||
<Button variant="outline" onClick={handleBackToBuckets} className="text-sm sm:text-base">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Back to Buckets</span>
|
||||
<span className="sm:hidden">Back</span>
|
||||
</Button>
|
||||
|
||||
{/* Breadcrumb Navigation */}
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<div className="flex items-center gap-2 text-xs sm:text-sm overflow-x-auto">
|
||||
<Home className="h-4 w-4 text-muted-foreground" />
|
||||
{getBreadcrumbs().map((crumb, index) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
@@ -253,8 +458,8 @@ export function Buckets() {
|
||||
</div>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="relative w-80">
|
||||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-3">
|
||||
<div className="relative flex-1 max-w-full sm:max-w-xs">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search objects..."
|
||||
@@ -263,17 +468,28 @@ export function Buckets() {
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button onClick={() => setShowUploadZone(!showUploadZone)}>
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
Upload
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{selectedFileKeys.size > 0 && (
|
||||
<Button
|
||||
onClick={handleBulkDeleteFiles}
|
||||
title={`Delete ${selectedFileKeys.size} selected file(s)`}
|
||||
// set border in red and background transparent and text color red
|
||||
className={"bg-transparent border border-red-500 text-red-500 hover:bg-red-500/5"}
|
||||
>
|
||||
<Trash className="h-4 w-4" />
|
||||
Delete {selectedFileKeys.size} file{selectedFileKeys.size !== 1 ? 's' : ''}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant={"secondary"} onClick={() => setShowUploadZone(!showUploadZone)} className="flex-1 sm:flex-initial">
|
||||
<Upload className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Upload</span>
|
||||
</Button>
|
||||
<Button onClick={() => setCreateDirDialogOpen(true)}>
|
||||
<FolderPlus className="mr-2 h-4 w-4" />
|
||||
Create Directory
|
||||
<Button onClick={() => setCreateDirDialogOpen(true)} className="flex-1 sm:flex-initial">
|
||||
<FolderPlus className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Add Directory</span>
|
||||
</Button>
|
||||
<Button variant="outline" size="icon" onClick={handleRefreshObjects} title="Refresh">
|
||||
<RotateCwIcon className="h-4 w-4" />
|
||||
<Button variant="outline" size="icon" onClick={handleRefreshObjects} title="Refresh" disabled={isRefreshing}>
|
||||
<RotateCwIcon className={`h-4 w-4 transition-transform duration-500 ${isRefreshing ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -343,7 +559,7 @@ export function Buckets() {
|
||||
{/* Objects Table with Drag & Drop */}
|
||||
<div
|
||||
{...getRootProps()}
|
||||
className={`border rounded-lg transition-colors ${
|
||||
className={`border rounded-lg transition-colors overflow-x-auto ${
|
||||
isDragActive
|
||||
? 'border-primary bg-primary/5 border-2'
|
||||
: 'border-border'
|
||||
@@ -353,17 +569,55 @@ export function Buckets() {
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Size</TableHead>
|
||||
<TableHead>Last Modified</TableHead>
|
||||
<TableHead>Storage Class</TableHead>
|
||||
<TableHead className="w-[50px]">
|
||||
<Checkbox
|
||||
checked={
|
||||
filteredObjects.filter(obj => !obj.isFolder).length > 0 &&
|
||||
selectedFileKeys.size === filteredObjects.filter(obj => !obj.isFolder).length
|
||||
}
|
||||
onCheckedChange={handleSelectAllFiles}
|
||||
aria-label="Select all files"
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="cursor-pointer hover:bg-muted/50" onClick={() => {
|
||||
if (sortColumn === 'name') {
|
||||
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
|
||||
} else {
|
||||
setSortColumn('name');
|
||||
setSortDirection('asc');
|
||||
}
|
||||
}}>
|
||||
Objects {sortColumn === 'name' && (sortDirection === 'asc' ? '↑' : '↓')}
|
||||
</TableHead>
|
||||
<TableHead className="hidden sm:table-cell">Type</TableHead>
|
||||
<TableHead className="hidden md:table-cell">Storage Class</TableHead>
|
||||
<TableHead className="cursor-pointer hover:bg-muted/50" onClick={() => {
|
||||
if (sortColumn === 'size') {
|
||||
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
|
||||
} else {
|
||||
setSortColumn('size');
|
||||
setSortDirection('asc');
|
||||
}
|
||||
}}>
|
||||
Size {sortColumn === 'size' && (sortDirection === 'asc' ? '↑' : '↓')}
|
||||
</TableHead>
|
||||
<TableHead className="cursor-pointer hover:bg-muted/50" onClick={() => {
|
||||
if (sortColumn === 'modified') {
|
||||
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
|
||||
} else {
|
||||
setSortColumn('modified');
|
||||
setSortDirection('asc');
|
||||
}
|
||||
}}>
|
||||
Modified {sortColumn === 'modified' && (sortDirection === 'asc' ? '↑' : '↓')}
|
||||
</TableHead>
|
||||
<TableHead className="w-[50px]"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredObjects.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center py-12 text-muted-foreground">
|
||||
<TableCell colSpan={7} className="text-center py-12 text-muted-foreground">
|
||||
{objectSearchQuery
|
||||
? 'No objects found matching your search'
|
||||
: isDragActive
|
||||
@@ -374,6 +628,22 @@ export function Buckets() {
|
||||
) : (
|
||||
filteredObjects.map((obj) => (
|
||||
<TableRow key={obj.key}>
|
||||
<TableCell className="w-[50px]">
|
||||
{obj.isFolder ? (
|
||||
<Checkbox
|
||||
disabled
|
||||
checked={false}
|
||||
className="opacity-50 cursor-not-allowed bg-muted"
|
||||
aria-label="Folders cannot be selected"
|
||||
/>
|
||||
) : (
|
||||
<Checkbox
|
||||
checked={selectedFileKeys.has(obj.key)}
|
||||
onCheckedChange={() => handleToggleFileSelection(obj.key)}
|
||||
aria-label={`Select file ${obj.key}`}
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
{obj.isFolder ? (
|
||||
@@ -384,7 +654,7 @@ export function Buckets() {
|
||||
{obj.isFolder ? (
|
||||
<button
|
||||
onClick={() => handleNavigateToFolder(obj.key)}
|
||||
className="font-medium hover:underline"
|
||||
className="font-medium cursor-pointer underline hover:text-primary"
|
||||
>
|
||||
{obj.key.replace(currentPath, '').replace('/', '')}
|
||||
</button>
|
||||
@@ -395,25 +665,92 @@ export function Buckets() {
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{obj.isFolder ? '---' : formatBytes(obj.size)}</TableCell>
|
||||
<TableCell>{formatDate(obj.lastModified)}</TableCell>
|
||||
<TableCell>
|
||||
<TableCell className="hidden sm:table-cell">
|
||||
{obj.isFolder ? 'Folder' : getFileType(obj.key.replace(currentPath, ''))}
|
||||
</TableCell>
|
||||
<TableCell className="hidden md:table-cell">
|
||||
{obj.storageClass && (
|
||||
<Badge variant="secondary">{obj.storageClass}</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{obj.isFolder ? null : formatBytes(obj.size)}</TableCell>
|
||||
<TableCell>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="decoration-dashed decoration-1 underline underline-offset-6 cursor-pointer text-muted-foreground hover:text-foreground transition-colors">
|
||||
{new Date(obj.lastModified).toLocaleDateString('en-GB', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
})} {new Date(obj.lastModified).toLocaleTimeString('en-GB', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
})} CET
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<div className="space-y-1 min-w-max">
|
||||
<div className="flex gap-3 items-center">
|
||||
<span className="text-sm text-gray-400 w-20 text-right">UTC</span>
|
||||
<span className="text-sm text-white">
|
||||
{new Date(obj.lastModified).toLocaleString('en-GB', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
timeZone: 'UTC',
|
||||
})} UTC
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-3 items-center">
|
||||
<span className="text-sm text-gray-400 w-20 text-right">Relative</span>
|
||||
<span className="text-sm text-white">
|
||||
{(() => {
|
||||
const now = new Date();
|
||||
const date = new Date(obj.lastModified);
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
if (diffMins < 1) return 'just now';
|
||||
if (diffMins < 60) return `${diffMins} minute${diffMins !== 1 ? 's' : ''} ago`;
|
||||
if (diffHours < 24) return `${diffHours} hour${diffHours !== 1 ? 's' : ''} ago`;
|
||||
if (diffDays < 7) return `${diffDays} day${diffDays !== 1 ? 's' : ''} ago`;
|
||||
if (diffDays < 30) return `${Math.floor(diffDays / 7)} week${Math.floor(diffDays / 7) !== 1 ? 's' : ''} ago`;
|
||||
return `${Math.floor(diffDays / 30)} month${Math.floor(diffDays / 30) !== 1 ? 's' : ''} ago`;
|
||||
})()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-3 items-center">
|
||||
<span className="text-sm text-gray-400 w-20 text-right">Timestamp</span>
|
||||
<span className="text-sm text-white font-mono">
|
||||
{new Date(obj.lastModified).toISOString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{!obj.isFolder && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
{/*make the button not affecting the row height*/}
|
||||
<Button variant="ghost" size="icon" className={"-m-3 top-1 relative"}>
|
||||
<Button variant="ghost" size="icon" className={"-m-6 top-1 relative"}>
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
<Download className="h-4 w-4" />
|
||||
Download
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
@@ -424,7 +761,7 @@ export function Buckets() {
|
||||
setDeleteObjectDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
@@ -501,36 +838,34 @@ export function Buckets() {
|
||||
return (
|
||||
<div>
|
||||
<Header title="Buckets" />
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="p-4 sm:p-6 space-y-4 sm:space-y-6">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative w-80">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search buckets..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-3">
|
||||
<div className="relative flex-1 max-w-full sm:max-w-xs">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search buckets..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={() => setCreateDialogOpen(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
<Button onClick={() => setCreateDialogOpen(true)} className="w-full sm:w-auto">
|
||||
<Plus className="h-4 w-4" />
|
||||
Create Bucket
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Buckets Table */}
|
||||
<div className="border rounded-lg">
|
||||
<div className="border rounded-lg overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Region</TableHead>
|
||||
<TableHead>Objects</TableHead>
|
||||
<TableHead className="hidden sm:table-cell">Region</TableHead>
|
||||
<TableHead className="hidden md:table-cell">Objects</TableHead>
|
||||
<TableHead>Size</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead className="hidden lg:table-cell">Created</TableHead>
|
||||
<TableHead className="w-[50px]"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -548,17 +883,17 @@ export function Buckets() {
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => handleViewBucket(bucket.name)}
|
||||
>
|
||||
<TableCell className="font-medium">{bucket.name}</TableCell>
|
||||
<TableCell>
|
||||
<TableCell className="font-medium truncate max-w-[200px]">{bucket.name}</TableCell>
|
||||
<TableCell className="hidden sm:table-cell">
|
||||
<Badge variant="secondary">{bucket.region || 'default'}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{bucket.objectCount?.toLocaleString() || 0}</TableCell>
|
||||
<TableCell className="hidden md:table-cell">{bucket.objectCount?.toLocaleString() || 0}</TableCell>
|
||||
<TableCell>{bucket.size ? formatBytes(bucket.size) : '0 B'}</TableCell>
|
||||
<TableCell>{formatDate(bucket.creationDate)}</TableCell>
|
||||
<TableCell className="hidden lg:table-cell">{formatDate(bucket.creationDate)}</TableCell>
|
||||
<TableCell>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger onClick={(e) => e.stopPropagation()}>
|
||||
<Button variant="ghost" size="icon">
|
||||
<Button variant="ghost" size="icon" className={"-m-3 top-1 relative"}>
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
@@ -567,11 +902,11 @@ export function Buckets() {
|
||||
e.stopPropagation();
|
||||
handleViewBucket(bucket.name);
|
||||
}}>
|
||||
<FolderIcon className="mr-2 h-4 w-4" />
|
||||
<FolderIcon className="h-4 w-4" />
|
||||
View Objects
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={(e) => e.stopPropagation()}>
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
<Settings className="h-4 w-4" />
|
||||
Settings
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
@@ -583,7 +918,7 @@ export function Buckets() {
|
||||
setDeleteBucketDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
@@ -619,19 +954,17 @@ export function Buckets() {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setCreateDialogOpen(false)}>
|
||||
<DialogFooter className={"space-y-2"}>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setCreateDialogOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowCreatePreview(true)}
|
||||
disabled={!newBucketName}
|
||||
variant={!newBucketName ? "default_disabled" : "default"}
|
||||
onClick={handleCreateBucket}
|
||||
>
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
Preview
|
||||
</Button>
|
||||
<Button onClick={handleCreateBucket} disabled={!newBucketName}>
|
||||
Create
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -54,9 +54,9 @@ export function Dashboard() {
|
||||
return (
|
||||
<div>
|
||||
<Header title="Dashboard" />
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="p-4 sm:p-6 space-y-4 sm:space-y-6">
|
||||
{/* Top Stats Grid */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="grid gap-3 sm:gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Storage</CardTitle>
|
||||
@@ -114,7 +114,7 @@ export function Dashboard() {
|
||||
metrics.requestMetrics.deleteRequests +
|
||||
metrics.requestMetrics.listRequests
|
||||
).toLocaleString()
|
||||
: '---'}
|
||||
: null}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
GET, PUT, DELETE, LIST
|
||||
@@ -124,7 +124,7 @@ export function Dashboard() {
|
||||
</div>
|
||||
|
||||
{/* Cluster Status */}
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<div className="grid gap-3 sm:gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Cluster Status</CardTitle>
|
||||
@@ -174,7 +174,7 @@ export function Dashboard() {
|
||||
</div>
|
||||
|
||||
{/* Charts Row 1 */}
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="grid gap-3 sm:gap-4 grid-cols-1 lg:grid-cols-2">
|
||||
{/* Bucket Usage Chart */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -217,9 +217,9 @@ export function Dashboard() {
|
||||
{metrics?.usageByBucket && metrics.usageByBucket.length > 0 ? (
|
||||
metrics.usageByBucket.map((bucket) => (
|
||||
<div key={bucket.bucketName} className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<div className="flex items-center justify-between text-sm flex-wrap gap-2">
|
||||
<span className="font-medium">{bucket.bucketName}</span>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2 sm:gap-4 text-xs sm:text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
{bucket.objectCount.toLocaleString()} objects
|
||||
</span>
|
||||
@@ -255,22 +255,22 @@ export function Dashboard() {
|
||||
{buckets.slice(0, 5).map((bucket) => (
|
||||
<div
|
||||
key={bucket.name}
|
||||
className="flex items-center justify-between py-3 border-b last:border-0"
|
||||
className="flex items-center justify-between py-3 border-b last:border-0 gap-3"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10 flex-shrink-0">
|
||||
<Database className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">{bucket.name}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium truncate">{bucket.name}</p>
|
||||
<p className="text-xs sm:text-sm text-muted-foreground">
|
||||
Created {new Date(bucket.creationDate).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="font-medium">{bucket.objectCount?.toLocaleString()} objects</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<div className="text-right flex-shrink-0">
|
||||
<p className="font-medium text-sm sm:text-base">{bucket.objectCount?.toLocaleString()} objects</p>
|
||||
<p className="text-xs sm:text-sm text-muted-foreground">
|
||||
{bucket.size ? formatBytes(bucket.size) : '---'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user