feat: integrate sonner for toast notifications and update project metadata

This commit is contained in:
Noste
2025-11-24 23:42:36 +01:00
parent a7388b8962
commit f33452dcb1
15 changed files with 1152 additions and 200 deletions
+2 -2
View File
@@ -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>
+482 -3
View File
@@ -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"
}
+2
View File
@@ -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"
+2
View File
@@ -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>
);
}
+5 -5
View File
@@ -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"
+24 -1
View File
@@ -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>
+15 -2
View File
@@ -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
+8 -6
View File
@@ -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',
+36
View File
@@ -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 };
+6 -6
View File
@@ -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';
+27
View File
@@ -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 };
+4 -1
View File
@@ -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
+85 -53
View File
@@ -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
View File
@@ -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>
+16 -16
View File
@@ -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>