diff --git a/README.md b/README.md index 1f59745..401b028 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,14 @@ A simple admin web UI for [Garage](https://garagehq.deuxfleurs.fr/), a self-host [ [Screenshots](misc/SCREENSHOTS.md) | [Install Garage](https://garagehq.deuxfleurs.fr/documentation/quick-start/) | [Garage Git](https://git.deuxfleurs.fr/Deuxfleurs/garage) ] +## Features + +- Garage health status +- Cluster & layout management +- Create, update, or view bucket information +- Integrated objects/bucket browser +- Create & assign access keys + ## Installation The Garage Web UI is available as a single executable binary and docker image. You can install it using the command line or with Docker Compose. diff --git a/backend/router/browse.go b/backend/router/browse.go index 2e70631..a643fbb 100644 --- a/backend/router/browse.go +++ b/backend/router/browse.go @@ -72,6 +72,8 @@ func (b *Browse) GetObjects(w http.ResponseWriter, r *http.Request) { ObjectKey: &key, LastModified: object.LastModified, Size: object.Size, + ViewUrl: fmt.Sprintf("/browse/%s/%s?view=1", bucket, *object.Key), + DownloadUrl: fmt.Sprintf("/browse/%s/%s?dl=1", bucket, *object.Key), }) } @@ -111,6 +113,11 @@ func (b *Browse) GetOneObject(w http.ResponseWriter, r *http.Request) { keys := strings.Split(key, "/") w.Header().Set("Content-Type", *object.ContentType) + w.Header().Set("Content-Length", strconv.FormatInt(*object.ContentLength, 10)) + w.Header().Set("Cache-Control", "max-age=86400") + w.Header().Set("Last-Modified", object.LastModified.Format(time.RFC1123)) + w.Header().Set("Etag", *object.ETag) + if download { w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s", keys[len(keys)-1])) } @@ -128,6 +135,74 @@ func (b *Browse) GetOneObject(w http.ResponseWriter, r *http.Request) { utils.ResponseSuccess(w, object) } +func (b *Browse) PutObject(w http.ResponseWriter, r *http.Request) { + bucket := r.PathValue("bucket") + key := r.PathValue("key") + isDirectory := strings.HasSuffix(key, "/") + + file, headers, err := r.FormFile("file") + if err != nil && !isDirectory { + utils.ResponseError(w, err) + return + } + + if file != nil { + defer file.Close() + } + + client, err := getS3Client(bucket) + if err != nil { + utils.ResponseError(w, err) + return + } + + var contentType string = "" + var size int64 = 0 + + if file != nil { + contentType = headers.Header.Get("Content-Type") + size = headers.Size + } + + result, err := client.PutObject(context.Background(), &s3.PutObjectInput{ + Bucket: aws.String(bucket), + Key: aws.String(key), + Body: file, + ContentLength: aws.Int64(size), + ContentType: aws.String(contentType), + }) + + if err != nil { + utils.ResponseError(w, fmt.Errorf("cannot put object: %w", err)) + return + } + + utils.ResponseSuccess(w, result) +} + +func (b *Browse) DeleteObject(w http.ResponseWriter, r *http.Request) { + bucket := r.PathValue("bucket") + key := r.PathValue("key") + + client, err := getS3Client(bucket) + if err != nil { + utils.ResponseError(w, err) + return + } + + _, err = client.DeleteObject(context.Background(), &s3.DeleteObjectInput{ + Bucket: aws.String(bucket), + Key: aws.String(key), + }) + + if err != nil { + utils.ResponseError(w, fmt.Errorf("cannot delete object: %w", err)) + return + } + + utils.ResponseSuccess(w, nil) +} + func getBucketCredentials(bucket string) (aws.CredentialsProvider, error) { cacheKey := fmt.Sprintf("key:%s", bucket) cacheData := utils.Cache.Get(cacheKey) diff --git a/backend/router/router.go b/backend/router/router.go index b9282af..e295620 100644 --- a/backend/router/router.go +++ b/backend/router/router.go @@ -14,6 +14,8 @@ func HandleApiRouter() *http.ServeMux { browse := &Browse{} router.HandleFunc("GET /browse/{bucket}", browse.GetObjects) router.HandleFunc("GET /browse/{bucket}/{key...}", browse.GetOneObject) + router.HandleFunc("PUT /browse/{bucket}/{key...}", browse.PutObject) + router.HandleFunc("DELETE /browse/{bucket}/{key...}", browse.DeleteObject) router.HandleFunc("/", ProxyHandler) diff --git a/backend/schema/browse.go b/backend/schema/browse.go index 3eb7941..3a7eb51 100644 --- a/backend/schema/browse.go +++ b/backend/schema/browse.go @@ -13,4 +13,6 @@ type BrowserObject struct { ObjectKey *string `json:"objectKey"` LastModified *time.Time `json:"lastModified"` Size *int64 `json:"size"` + ViewUrl string `json:"viewUrl"` + DownloadUrl string `json:"downloadUrl"` } diff --git a/misc/SCREENSHOTS.md b/misc/SCREENSHOTS.md index 580e4c3..d6c32df 100644 --- a/misc/SCREENSHOTS.md +++ b/misc/SCREENSHOTS.md @@ -6,3 +6,12 @@ [![image](img/buckets-overview.png)](img/buckets-overview.png) [![image](img/buckets-permissions.png)](img/buckets-permissions.png) [![image](img/keys.png)](img/keys.png) +[![image](img/buckets-browse.png)](img/buckets-browse.png) +[![image](img/buckets-browse-sharing.png)](img/buckets-browse-sharing.png) + +### Mobile + +[![image](img/mobile-dashboard.png)](img/mobile-dashboard.png) +[![image](img/mobile-cluster.png)](img/mobile-cluster.png) +[![image](img/mobile-buckets.png)](img/mobile-buckets.png) +[![image](img/mobile-bucket-browse.png)](img/mobile-bucket-browse.png) diff --git a/misc/img/buckets-browse-sharing.png b/misc/img/buckets-browse-sharing.png new file mode 100755 index 0000000..70f6805 Binary files /dev/null and b/misc/img/buckets-browse-sharing.png differ diff --git a/misc/img/buckets-browse.png b/misc/img/buckets-browse.png new file mode 100755 index 0000000..bf293a8 Binary files /dev/null and b/misc/img/buckets-browse.png differ diff --git a/misc/img/mobile-bucket-browse.png b/misc/img/mobile-bucket-browse.png new file mode 100755 index 0000000..6ad7819 Binary files /dev/null and b/misc/img/mobile-bucket-browse.png differ diff --git a/misc/img/mobile-buckets.png b/misc/img/mobile-buckets.png new file mode 100755 index 0000000..b5f6fdd Binary files /dev/null and b/misc/img/mobile-buckets.png differ diff --git a/misc/img/mobile-cluster.png b/misc/img/mobile-cluster.png new file mode 100755 index 0000000..183a83b Binary files /dev/null and b/misc/img/mobile-cluster.png differ diff --git a/misc/img/mobile-dashboard.png b/misc/img/mobile-dashboard.png new file mode 100755 index 0000000..0d80b0b Binary files /dev/null and b/misc/img/mobile-dashboard.png differ diff --git a/package.json b/package.json index a447946..ee5e94b 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "clsx": "^2.1.1", "dayjs": "^1.11.12", "lucide-react": "^0.427.0", + "mime": "^4.0.4", "react": "^18.3.1", "react-daisyui": "^5.0.3", "react-dom": "^18.3.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b0818b1..80180ca 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: lucide-react: specifier: ^0.427.0 version: 0.427.0(react@18.3.1) + mime: + specifier: ^4.0.4 + version: 4.0.4 react: specifier: ^18.3.1 version: 18.3.1 @@ -1192,6 +1195,11 @@ packages: resolution: {integrity: sha512-LPP/3KorzCwBxfeUuZmaR6bG2kdeHSbe0P2tY3FLRU4vYrjYz5hI4QZwV0njUx3jeuKe67YukQ1LSPZBKDqO/Q==} engines: {node: '>=8.6'} + mime@4.0.4: + resolution: {integrity: sha512-v8yqInVjhXyqP6+Kw4fV3ZzeMRqEW6FotRsKXjRS5VMTNIuXsdRoAvklpoRgSqXm6o9VNH4/C0mgedko9DdLsQ==} + engines: {node: '>=16'} + hasBin: true + minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} @@ -2743,6 +2751,8 @@ snapshots: braces: 3.0.3 picomatch: 2.3.1 + mime@4.0.4: {} + minimatch@3.1.2: dependencies: brace-expansion: 1.1.11 diff --git a/src/components/containers/tab-view.tsx b/src/components/containers/tab-view.tsx index 91caff9..f1a95ca 100644 --- a/src/components/containers/tab-view.tsx +++ b/src/components/containers/tab-view.tsx @@ -8,7 +8,7 @@ export type Tab = { name: string; title?: string; icon?: LucideIcon; - Component?: () => JSX.Element; + Component?: () => JSX.Element | null; }; type Props = { @@ -36,13 +36,16 @@ const TabView = ({ <> {tabs.map(({ icon: Icon, ...tab }) => ( { setSearchParams((params) => { params.set(name, tab.name); diff --git a/src/components/ui/goto-top-btn.tsx b/src/components/ui/goto-top-btn.tsx new file mode 100644 index 0000000..bd58bdf --- /dev/null +++ b/src/components/ui/goto-top-btn.tsx @@ -0,0 +1,49 @@ +import { useEffect, useRef, useState } from "react"; +import Button from "./button"; +import { ArrowUp } from "lucide-react"; +import { cn } from "@/lib/utils"; + +const GotoTopButton = () => { + const mainElRef = useRef(null); + const [show, setShow] = useState(false); + + useEffect(() => { + const mainEl = document.querySelector("main"); + if (!mainEl) return; + + mainElRef.current = mainEl; + + const onScroll = () => { + if (mainEl.scrollTop > 300) { + setShow(true); + } else { + setShow(false); + } + }; + + mainEl.addEventListener("scroll", onScroll); + return () => mainEl.removeEventListener("scroll", onScroll); + }, []); + + const onClick = () => { + const mainEl = mainElRef.current; + if (!mainEl) return; + + mainEl.scrollTo({ top: 0, behavior: "smooth" }); + }; + + return ( + + ); +}; + +export default GotoTopButton; diff --git a/src/components/ui/toggle.tsx b/src/components/ui/toggle.tsx index 6a3d1a0..27025f6 100644 --- a/src/components/ui/toggle.tsx +++ b/src/components/ui/toggle.tsx @@ -46,6 +46,7 @@ export const ToggleField = ({ {...props} {...field} className={inputClassName} + color={field.value ? "primary" : undefined} checked={field.value || false} onChange={(e) => field.onChange(e.target.checked)} /> diff --git a/src/lib/disclosure.ts b/src/lib/disclosure.ts index 9e30b92..5bf90ce 100644 --- a/src/lib/disclosure.ts +++ b/src/lib/disclosure.ts @@ -1,3 +1,4 @@ +import { useEffect, useRef } from "react"; import { createStore, useStore } from "zustand"; export const createDisclosure = () => { @@ -6,9 +7,28 @@ export const createDisclosure = () => { isOpen: false, })); + const useDisclosure = () => { + const dialogRef = useRef(null); + const data = useStore(store); + + useEffect(() => { + const dlg = dialogRef.current; + if (!dlg || !data.isOpen) return; + + const onDialogClose = () => { + store.setState({ isOpen: false }); + }; + + dlg.addEventListener("close", onDialogClose); + return () => dlg.removeEventListener("close", onDialogClose); + }, [dialogRef, data.isOpen]); + + return { ...data, dialogRef } as const; + }; + return { store, - use: () => useStore(store), + use: useDisclosure, open: (data?: T | null) => store.setState({ isOpen: true, data }), close: () => store.setState({ isOpen: false }), }; diff --git a/src/pages/buckets/manage/browse/actions.tsx b/src/pages/buckets/manage/browse/actions.tsx new file mode 100644 index 0000000..dc9e623 --- /dev/null +++ b/src/pages/buckets/manage/browse/actions.tsx @@ -0,0 +1,143 @@ +import { + EllipsisVertical, + FilePlus, + FolderPlus, + UploadIcon, +} from "lucide-react"; +import Button from "@/components/ui/button"; +import { usePutObject } from "./hooks"; +import { toast } from "sonner"; +import { handleError } from "@/lib/utils"; +import { useQueryClient } from "@tanstack/react-query"; +import { useBucketContext } from "../context"; +import { useDisclosure } from "@/hooks/useDisclosure"; +import { Modal } from "react-daisyui"; +import { createFolderSchema, CreateFolderSchema } from "./schema"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { InputField } from "@/components/ui/input"; +import { useEffect } from "react"; + +type Props = { + prefix: string; +}; + +const Actions = ({ prefix }: Props) => { + const { bucketName } = useBucketContext(); + const queryClient = useQueryClient(); + + const putObject = usePutObject(bucketName, { + onSuccess: () => { + toast.success("File uploaded!"); + queryClient.invalidateQueries({ queryKey: ["browse", bucketName] }); + }, + onError: handleError, + }); + + const onUploadFile = () => { + const input = document.createElement("input"); + input.type = "file"; + input.multiple = true; + + input.onchange = (e) => { + const files = (e.target as HTMLInputElement).files; + if (!files?.length) { + return; + } + + if (files.length > 20) { + toast.error("You can only upload up to 20 files at a time"); + return; + } + + for (const file of files) { + const key = prefix + file.name; + putObject.mutate({ key, file }); + } + }; + + input.click(); + input.remove(); + }; + + return ( + <> + + {/* + + + + + ); +}; + +export default Actions; diff --git a/src/pages/buckets/manage/browse/browse-tab.tsx b/src/pages/buckets/manage/browse/browse-tab.tsx index 2e0fdce..45e78d7 100644 --- a/src/pages/buckets/manage/browse/browse-tab.tsx +++ b/src/pages/buckets/manage/browse/browse-tab.tsx @@ -1,25 +1,36 @@ -import { useParams } from "react-router-dom"; +import { useSearchParams } from "react-router-dom"; import { Card } from "react-daisyui"; import ObjectList from "./object-list"; -import { useBucket } from "../hooks"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import ObjectListNavigator from "./object-list-navigator"; -import { - EllipsisVertical, - FilePlus, - FolderPlus, - UploadIcon, -} from "lucide-react"; -import Button from "@/components/ui/button"; +import Actions from "./actions"; +import { useBucketContext } from "../context"; +import ShareDialog from "./share-dialog"; + +const getInitialPrefixes = (searchParams: URLSearchParams) => { + const prefix = searchParams.get("prefix"); + if (prefix) { + const paths = prefix.split("/").filter((p) => p); + return paths.map((_, i) => paths.slice(0, i + 1).join("/") + "/"); + } + return []; +}; const BrowseTab = () => { - const { id } = useParams(); - const { data: bucket } = useBucket(id); + const { bucket } = useBucketContext(); + const [searchParams, setSearchParams] = useSearchParams(); + const [prefixHistory, setPrefixHistory] = useState( + getInitialPrefixes(searchParams) + ); + const [curPrefix, setCurPrefix] = useState(prefixHistory.length - 1); - const [curPrefix, setCurPrefix] = useState(-1); - const [prefixHistory, setPrefixHistory] = useState([]); - const bucketName = bucket?.globalAliases[0]; + useEffect(() => { + const prefix = prefixHistory[curPrefix] || ""; + const newParams = new URLSearchParams(searchParams); + newParams.set("prefix", prefix); + setSearchParams(newParams); + }, [curPrefix]); const gotoPrefix = (prefix: string) => { const history = prefixHistory.slice(0, curPrefix + 1); @@ -27,15 +38,12 @@ const BrowseTab = () => { setCurPrefix(history.length); }; - if (!bucket) { - return null; - } - if (!bucket.keys.find((k) => k.permissions.read && k.permissions.write)) { return ( -
-

- You need to add a key to your bucket to be able to browse it. +

+

+ You need to add a key with read & write access to your bucket to be + able to browse it.

); @@ -43,29 +51,20 @@ const BrowseTab = () => { return (
- + -
); diff --git a/src/pages/buckets/manage/browse/hooks.ts b/src/pages/buckets/manage/browse/hooks.ts index 8b18f58..8133c44 100644 --- a/src/pages/buckets/manage/browse/hooks.ts +++ b/src/pages/buckets/manage/browse/hooks.ts @@ -1,6 +1,14 @@ import api from "@/lib/api"; -import { useQuery } from "@tanstack/react-query"; -import { GetObjectsResult, UseBrowserObjectOptions } from "./types"; +import { + useMutation, + UseMutationOptions, + useQuery, +} from "@tanstack/react-query"; +import { + GetObjectsResult, + PutObjectPayload, + UseBrowserObjectOptions, +} from "./types"; export const useBrowseObjects = ( bucket: string, @@ -12,3 +20,30 @@ export const useBrowseObjects = ( api.get(`/browse/${bucket}`, { params: options }), }); }; + +export const usePutObject = ( + bucket: string, + options?: UseMutationOptions +) => { + return useMutation({ + mutationFn: async (body) => { + const formData = new FormData(); + if (body.file) { + formData.append("file", body.file); + } + + return api.put(`/browse/${bucket}/${body.key}`, { body: formData }); + }, + ...options, + }); +}; + +export const useDeleteObject = ( + bucket: string, + options?: UseMutationOptions +) => { + return useMutation({ + mutationFn: (key) => api.delete(`/browse/${bucket}/${key}`), + ...options, + }); +}; diff --git a/src/pages/buckets/manage/browse/object-actions.tsx b/src/pages/buckets/manage/browse/object-actions.tsx new file mode 100644 index 0000000..cc73920 --- /dev/null +++ b/src/pages/buckets/manage/browse/object-actions.tsx @@ -0,0 +1,74 @@ +import { Dropdown, Modal } from "react-daisyui"; +import { Object } from "./types"; +import Button from "@/components/ui/button"; +import { DownloadIcon, EllipsisVertical, Share2, Trash } from "lucide-react"; +import { useDeleteObject } from "./hooks"; +import { useBucketContext } from "../context"; +import { useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { handleError } from "@/lib/utils"; +import { API_URL } from "@/lib/api"; +import { shareDialog } from "./share-dialog"; + +type Props = { + prefix?: string; + object: Pick; +}; + +const ObjectActions = ({ prefix = "", object }: Props) => { + const { bucketName } = useBucketContext(); + const queryClient = useQueryClient(); + const isDirectory = object.objectKey.endsWith("/"); + + const deleteObject = useDeleteObject(bucketName, { + onSuccess: () => { + toast.success("Object deleted!"); + queryClient.invalidateQueries({ queryKey: ["browse", bucketName] }); + }, + onError: handleError, + }); + + const onDownload = () => { + window.open(API_URL + object.downloadUrl, "_blank"); + }; + + const onDelete = () => { + if (window.confirm("Are you sure you want to delete this object?")) { + deleteObject.mutate(prefix + object.objectKey); + } + }; + + return ( + + + {!isDirectory && ( +
-
+
setCurPrefix(-1)} /> {prefixHistory.map((prefix, i) => ( - + void; }; -const HistoryItem = ({ title, isActive, onClick }: HistoryItemProps) => { - if (!title) { +const HistoryItem = ({ + icon: Icon, + title, + isActive, + onClick, +}: HistoryItemProps) => { + if (!title && !Icon) { return null; } @@ -92,8 +96,13 @@ const HistoryItem = ({ title, isActive, onClick }: HistoryItemProps) => { e.preventDefault(); onClick(); }} - className={cn("px-2 rounded-sm shrink-0", isActive && "bg-neutral")} + className={cn( + "px-2 rounded-md shrink-0 max-w-[150px] truncate", + isActive && "bg-neutral", + Icon ? "py-1" : null + )} > + {Icon ? : null} {title} ); diff --git a/src/pages/buckets/manage/browse/object-list.tsx b/src/pages/buckets/manage/browse/object-list.tsx index ebecea3..6b1184a 100644 --- a/src/pages/buckets/manage/browse/object-list.tsx +++ b/src/pages/buckets/manage/browse/object-list.tsx @@ -1,27 +1,29 @@ import { Table } from "react-daisyui"; import { useBrowseObjects } from "./hooks"; import { dayjs, readableBytes } from "@/lib/utils"; +import mime from "mime/lite"; import { Object } from "./types"; import { API_URL } from "@/lib/api"; +import { FileArchive, FileIcon, FileType, Folder } from "lucide-react"; +import { useBucketContext } from "../context"; +import ObjectActions from "./object-actions"; +import GotoTopButton from "@/components/ui/goto-top-btn"; type Props = { - bucket: string; prefix?: string; onPrefixChange?: (prefix: string) => void; }; -const ObjectList = ({ bucket, prefix, onPrefixChange }: Props) => { - const { data } = useBrowseObjects(bucket, { prefix }); +const ObjectList = ({ prefix, onPrefixChange }: Props) => { + const { bucketName } = useBucketContext(); + const { data } = useBrowseObjects(bucketName, { prefix, limit: 1000 }); const onObjectClick = (object: Object) => { - window.open( - API_URL + `/browse/${bucket}/${data?.prefix}${object.objectKey}?view=1`, - "_blank" - ); + window.open(API_URL + object.viewUrl, "_blank"); }; return ( -
+
Name @@ -30,37 +32,114 @@ const ObjectList = ({ bucket, prefix, onPrefixChange }: Props) => { + {!data?.prefixes?.length && !data?.objects?.length && ( + + + + )} + {data?.prefixes.map((prefix) => ( - onPrefixChange?.(prefix)} + className="hover:bg-neutral/60 hover:text-neutral-content group" > - - {prefix.substring(0, prefix.lastIndexOf("/")).split("/").pop()} - - - - + + ))} - {data?.objects.map((object) => ( - onObjectClick(object)} - > - {object.objectKey} - {readableBytes(object.size)} - {dayjs(object.lastModified).fromNow()} - - ))} + {data?.objects.map((object) => { + const extIdx = object.objectKey.lastIndexOf("."); + const filename = + extIdx >= 0 + ? object.objectKey.substring(0, extIdx) + : object.objectKey; + const ext = extIdx >= 0 ? object.objectKey.substring(extIdx) : null; + + return ( + + + + + + + ); + })}
+ No objects +
onPrefixChange?.(prefix)} + > + + + {prefix + .substring(0, prefix.lastIndexOf("/")) + .split("/") + .pop()} + + + +
onObjectClick(object)} + > + + + {filename} + {ext && {ext}} + + + {readableBytes(object.size)} + + {dayjs(object.lastModified).fromNow()} +
+ +
); }; +type FilePreviewProps = { + ext?: string | null; + object: Object; +}; + +const FilePreview = ({ ext, object }: FilePreviewProps) => { + const type = mime.getType(ext || "")?.split("/")[0]; + let Icon = FileIcon; + + if ( + ["zip", "rar", "7z", "iso", "tar", "gz", "bz2", "xz"].includes(ext || "") + ) { + Icon = FileArchive; + } + + if (type === "image") { + return ( + {object.objectKey} + ); + } + + if (type === "text") { + Icon = FileType; + } + + return ( + + ); +}; + export default ObjectList; diff --git a/src/pages/buckets/manage/browse/schema.ts b/src/pages/buckets/manage/browse/schema.ts new file mode 100644 index 0000000..52436d0 --- /dev/null +++ b/src/pages/buckets/manage/browse/schema.ts @@ -0,0 +1,10 @@ +import { z } from "zod"; + +export const createFolderSchema = z.object({ + name: z + .string() + .min(1, "Folder Name is required") + .regex(/^[a-zA-Z0-9_-]+$/, "Folder Name invalid"), +}); + +export type CreateFolderSchema = z.infer; diff --git a/src/pages/buckets/manage/browse/share-dialog.tsx b/src/pages/buckets/manage/browse/share-dialog.tsx new file mode 100644 index 0000000..fc98e98 --- /dev/null +++ b/src/pages/buckets/manage/browse/share-dialog.tsx @@ -0,0 +1,79 @@ +import { createDisclosure } from "@/lib/disclosure"; +import { Alert, Modal } from "react-daisyui"; +import { useBucketContext } from "../context"; +import { useConfig } from "@/hooks/useConfig"; +import { useEffect, useMemo, useState } from "react"; +import Input from "@/components/ui/input"; +import Button from "@/components/ui/button"; +import { Copy, FileWarningIcon } from "lucide-react"; +import { copyToClipboard } from "@/lib/utils"; +import Checkbox from "@/components/ui/checkbox"; + +export const shareDialog = createDisclosure<{ key: string; prefix: string }>(); + +const ShareDialog = () => { + const { isOpen, data, dialogRef } = shareDialog.use(); + const { bucket, bucketName } = useBucketContext(); + const { data: config } = useConfig(); + const [domain, setDomain] = useState(bucketName); + + const websitePort = config?.s3_web?.bind_addr?.split(":").pop() || "80"; + const rootDomain = config?.s3_web?.root_domain; + + const domains = useMemo( + () => [ + bucketName, + bucketName + rootDomain, + bucketName + rootDomain + `:${websitePort}`, + ], + [bucketName, config?.s3_web] + ); + + useEffect(() => { + setDomain(bucketName); + }, [domains]); + + const url = "http://" + domain + "/" + data?.prefix + data?.key; + + return ( + + Share {data?.key || ""} + + {!bucket.websiteAccess && ( + + + Sharing is only available for buckets with enabled website access. + + )} +
+ {domains.map((item) => ( + setDomain(item)} + /> + ))} +
+
+ e.target.select()} + /> +
+
+ + + +
+ ); +}; + +export default ShareDialog; diff --git a/src/pages/buckets/manage/browse/types.ts b/src/pages/buckets/manage/browse/types.ts index 1dd36f0..565b8ec 100644 --- a/src/pages/buckets/manage/browse/types.ts +++ b/src/pages/buckets/manage/browse/types.ts @@ -15,4 +15,11 @@ export type Object = { objectKey: string; lastModified: Date; size: number; + viewUrl: string; + downloadUrl: string; +}; + +export type PutObjectPayload = { + key: string; + file: File | null; }; diff --git a/src/pages/buckets/manage/context.ts b/src/pages/buckets/manage/context.ts new file mode 100644 index 0000000..2d2cbd2 --- /dev/null +++ b/src/pages/buckets/manage/context.ts @@ -0,0 +1,19 @@ +import { createContext, useContext } from "react"; +import { Bucket } from "../types"; + +export const BucketContext = createContext<{ + bucket: Bucket; + refetch: () => void; + bucketName: string; +} | null>(null); + +export const useBucketContext = () => { + const bucket = useContext(BucketContext); + if (!bucket) { + throw new Error( + "BucketContext must be used within a BucketContextProvider" + ); + } + + return bucket; +}; diff --git a/src/pages/buckets/manage/overview/overview-aliases.tsx b/src/pages/buckets/manage/overview/overview-aliases.tsx index 75f6281..ee5d36c 100644 --- a/src/pages/buckets/manage/overview/overview-aliases.tsx +++ b/src/pages/buckets/manage/overview/overview-aliases.tsx @@ -1,7 +1,6 @@ import { Modal } from "react-daisyui"; import { Plus } from "lucide-react"; import Chips from "@/components/ui/chips"; -import { Bucket } from "../../types"; import { useDisclosure } from "@/hooks/useDisclosure"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; @@ -13,12 +12,11 @@ import { handleError } from "@/lib/utils"; import { InputField } from "@/components/ui/input"; import { useEffect } from "react"; import { useQueryClient } from "@tanstack/react-query"; +import { useBucketContext } from "../context"; -type Props = { - data?: Bucket; -}; +const AliasesSection = () => { + const { bucket: data } = useBucketContext(); -const AliasesSection = ({ data }: Props) => { const queryClient = useQueryClient(); const removeAlias = useRemoveAlias(data?.id, { onSuccess: () => { diff --git a/src/pages/buckets/manage/overview/overview-quota.tsx b/src/pages/buckets/manage/overview/overview-quota.tsx index c18eb49..9b0061b 100644 --- a/src/pages/buckets/manage/overview/overview-quota.tsx +++ b/src/pages/buckets/manage/overview/overview-quota.tsx @@ -4,15 +4,13 @@ import { QuotaSchema, quotaSchema } from "../schema"; import { useEffect } from "react"; import { useDebounce } from "@/hooks/useDebounce"; import { useUpdateBucket } from "../hooks"; -import { Bucket } from "../../types"; import { InputField } from "@/components/ui/input"; import { ToggleField } from "@/components/ui/toggle"; +import { useBucketContext } from "../context"; -type Props = { - data?: Bucket; -}; +const QuotaSection = () => { + const { bucket: data } = useBucketContext(); -const QuotaSection = ({ data }: Props) => { const form = useForm({ resolver: zodResolver(quotaSchema), }); @@ -23,7 +21,7 @@ const QuotaSection = ({ data }: Props) => { const onChange = useDebounce((values: DeepPartial) => { const { enabled } = values; const maxObjects = Number(values.maxObjects); - const maxSize = Math.round(Number(values.maxSize) * 1024 * 1024); + const maxSize = Math.round(Number(values.maxSize) * 1024 ** 3); const data = { maxObjects: enabled && maxObjects > 0 ? maxObjects : null, @@ -37,9 +35,7 @@ const QuotaSection = ({ data }: Props) => { form.reset({ enabled: data?.quotas?.maxSize != null || data?.quotas?.maxObjects != null, - maxSize: data?.quotas?.maxSize - ? data?.quotas?.maxSize / 1024 / 1024 - : null, + maxSize: data?.quotas?.maxSize ? data?.quotas?.maxSize / 1024 ** 3 : null, maxObjects: data?.quotas?.maxObjects || null, }); diff --git a/src/pages/buckets/manage/overview/overview-tab.tsx b/src/pages/buckets/manage/overview/overview-tab.tsx index 83a7c5d..05b89a3 100644 --- a/src/pages/buckets/manage/overview/overview-tab.tsx +++ b/src/pages/buckets/manage/overview/overview-tab.tsx @@ -1,24 +1,22 @@ import { Card } from "react-daisyui"; -import { useParams } from "react-router-dom"; -import { useBucket } from "../hooks"; import { ChartPie, ChartScatter } from "lucide-react"; import { readableBytes } from "@/lib/utils"; import WebsiteAccessSection from "./overview-website-access"; import AliasesSection from "./overview-aliases"; import QuotaSection from "./overview-quota"; +import { useBucketContext } from "../context"; const OverviewTab = () => { - const { id } = useParams(); - const { data } = useBucket(id); + const { bucket: data } = useBucketContext(); return (
Summary - - - + + + diff --git a/src/pages/buckets/manage/overview/overview-website-access.tsx b/src/pages/buckets/manage/overview/overview-website-access.tsx index 47d8dbc..6be03a8 100644 --- a/src/pages/buckets/manage/overview/overview-website-access.tsx +++ b/src/pages/buckets/manage/overview/overview-website-access.tsx @@ -7,20 +7,16 @@ import { useUpdateBucket } from "../hooks"; import { useConfig } from "@/hooks/useConfig"; import { Info, LinkIcon } from "lucide-react"; import Button from "@/components/ui/button"; -import { Bucket } from "../../types"; import { InputField } from "@/components/ui/input"; import { ToggleField } from "@/components/ui/toggle"; +import { useBucketContext } from "../context"; -type Props = { - data?: Bucket; -}; - -const WebsiteAccessSection = ({ data }: Props) => { +const WebsiteAccessSection = () => { + const { bucket: data, bucketName } = useBucketContext(); const { data: config } = useConfig(); const form = useForm({ resolver: zodResolver(websiteConfigSchema), }); - const bucketName = data?.globalAliases[0] || ""; const isEnabled = useWatch({ control: form.control, name: "websiteAccess" }); const websitePort = config?.s3_web?.bind_addr?.split(":").pop() || "80"; diff --git a/src/pages/buckets/manage/page.tsx b/src/pages/buckets/manage/page.tsx index 4695d41..1314056 100644 --- a/src/pages/buckets/manage/page.tsx +++ b/src/pages/buckets/manage/page.tsx @@ -7,6 +7,7 @@ import OverviewTab from "./overview/overview-tab"; import PermissionsTab from "./permissions/permissions-tab"; import MenuButton from "./components/menu-button"; import BrowseTab from "./browse/browse-tab"; +import { BucketContext } from "./context"; const tabs: Tab[] = [ { @@ -31,7 +32,7 @@ const tabs: Tab[] = [ const ManageBucketPage = () => { const { id } = useParams(); - const { data } = useBucket(id); + const { data, refetch } = useBucket(id); const name = data?.globalAliases[0]; @@ -40,9 +41,16 @@ const ManageBucketPage = () => { } + actions={data ? : undefined} /> - + + {data && ( + + + + )}
); }; diff --git a/src/pages/buckets/manage/permissions/allow-key-dialog.tsx b/src/pages/buckets/manage/permissions/allow-key-dialog.tsx index ce9e6cc..62347fc 100644 --- a/src/pages/buckets/manage/permissions/allow-key-dialog.tsx +++ b/src/pages/buckets/manage/permissions/allow-key-dialog.tsx @@ -12,13 +12,14 @@ import { useAllowKey } from "../hooks"; import { toast } from "sonner"; import { handleError } from "@/lib/utils"; import { useQueryClient } from "@tanstack/react-query"; +import { useBucketContext } from "../context"; type Props = { - id?: string; currentKeys?: string[]; }; -const AllowKeyDialog = ({ id, currentKeys }: Props) => { +const AllowKeyDialog = ({ currentKeys }: Props) => { + const { bucket } = useBucketContext(); const { dialogRef, isOpen, onOpen, onClose } = useDisclosure(); const { data: keys } = useKeys(); const form = useForm({ @@ -30,12 +31,12 @@ const AllowKeyDialog = ({ id, currentKeys }: Props) => { }); const queryClient = useQueryClient(); - const allowKey = useAllowKey(id, { + const allowKey = useAllowKey(bucket.id, { onSuccess: () => { form.reset(); onClose(); toast.success("Key allowed!"); - queryClient.invalidateQueries({ queryKey: ["bucket", id] }); + queryClient.invalidateQueries({ queryKey: ["bucket", bucket.id] }); }, onError: handleError, }); diff --git a/src/pages/buckets/manage/permissions/permissions-tab.tsx b/src/pages/buckets/manage/permissions/permissions-tab.tsx index 612debe..0df8b61 100644 --- a/src/pages/buckets/manage/permissions/permissions-tab.tsx +++ b/src/pages/buckets/manage/permissions/permissions-tab.tsx @@ -1,5 +1,4 @@ -import { useParams } from "react-router-dom"; -import { useBucket, useDenyKey } from "../hooks"; +import { useDenyKey } from "../hooks"; import { Card, Checkbox, Table } from "react-daisyui"; import Button from "@/components/ui/button"; import { Trash } from "lucide-react"; @@ -7,12 +6,12 @@ import AllowKeyDialog from "./allow-key-dialog"; import { useMemo } from "react"; import { toast } from "sonner"; import { handleError } from "@/lib/utils"; +import { useBucketContext } from "../context"; const PermissionsTab = () => { - const { id } = useParams(); - const { data, refetch } = useBucket(id); + const { bucket, refetch } = useBucketContext(); - const denyKey = useDenyKey(id, { + const denyKey = useDenyKey(bucket.id, { onSuccess: () => { toast.success("Key removed!"); refetch(); @@ -21,13 +20,13 @@ const PermissionsTab = () => { }); const keys = useMemo(() => { - return data?.keys.filter( + return bucket?.keys.filter( (key) => key.permissions.read !== false || key.permissions.write !== false || key.permissions.owner !== false ); - }, [data?.keys]); + }, [bucket?.keys]); const onRemove = (id: string) => { if (window.confirm("Are you sure you want to remove this key?")) { @@ -43,10 +42,7 @@ const PermissionsTab = () => {
Access Keys - key.accessKeyId)} - /> + key.accessKeyId)} />
diff --git a/src/pages/buckets/page.tsx b/src/pages/buckets/page.tsx index 5240543..229a331 100644 --- a/src/pages/buckets/page.tsx +++ b/src/pages/buckets/page.tsx @@ -10,16 +10,22 @@ const BucketsPage = () => { const [search, setSearch] = useState(""); const items = useMemo(() => { - if (!search?.length) { - return data; + let buckets = data || []; + + if (search?.length > 0) { + const q = search.toLowerCase(); + buckets = buckets.filter( + (bucket) => + bucket.id.includes(q) || + bucket.globalAliases.find((alias) => alias.includes(q)) + ); } - const q = search.toLowerCase(); - return data?.filter( - (bucket) => - bucket.id.includes(q) || - bucket.globalAliases.find((alias) => alias.includes(q)) + buckets = buckets.sort((a, b) => + a.globalAliases[0].localeCompare(b.globalAliases[0]) ); + + return buckets; }, [data, search]); return (