feat: add bucket browser

This commit is contained in:
Khairul Hidayat 2024-08-19 02:28:25 +07:00
parent 934e0c409c
commit 93a1dce5f7
35 changed files with 770 additions and 137 deletions

View File

@ -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) ] [ [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 ## 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. 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.

View File

@ -72,6 +72,8 @@ func (b *Browse) GetObjects(w http.ResponseWriter, r *http.Request) {
ObjectKey: &key, ObjectKey: &key,
LastModified: object.LastModified, LastModified: object.LastModified,
Size: object.Size, 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, "/") keys := strings.Split(key, "/")
w.Header().Set("Content-Type", *object.ContentType) 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 { if download {
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s", keys[len(keys)-1])) 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) 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) { func getBucketCredentials(bucket string) (aws.CredentialsProvider, error) {
cacheKey := fmt.Sprintf("key:%s", bucket) cacheKey := fmt.Sprintf("key:%s", bucket)
cacheData := utils.Cache.Get(cacheKey) cacheData := utils.Cache.Get(cacheKey)

View File

@ -14,6 +14,8 @@ func HandleApiRouter() *http.ServeMux {
browse := &Browse{} browse := &Browse{}
router.HandleFunc("GET /browse/{bucket}", browse.GetObjects) router.HandleFunc("GET /browse/{bucket}", browse.GetObjects)
router.HandleFunc("GET /browse/{bucket}/{key...}", browse.GetOneObject) 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) router.HandleFunc("/", ProxyHandler)

View File

@ -13,4 +13,6 @@ type BrowserObject struct {
ObjectKey *string `json:"objectKey"` ObjectKey *string `json:"objectKey"`
LastModified *time.Time `json:"lastModified"` LastModified *time.Time `json:"lastModified"`
Size *int64 `json:"size"` Size *int64 `json:"size"`
ViewUrl string `json:"viewUrl"`
DownloadUrl string `json:"downloadUrl"`
} }

View File

@ -6,3 +6,12 @@
[![image](img/buckets-overview.png)](img/buckets-overview.png) [![image](img/buckets-overview.png)](img/buckets-overview.png)
[![image](img/buckets-permissions.png)](img/buckets-permissions.png) [![image](img/buckets-permissions.png)](img/buckets-permissions.png)
[![image](img/keys.png)](img/keys.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)

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

BIN
misc/img/buckets-browse.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

BIN
misc/img/mobile-bucket-browse.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

BIN
misc/img/mobile-buckets.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

BIN
misc/img/mobile-cluster.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

BIN
misc/img/mobile-dashboard.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

View File

@ -17,6 +17,7 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dayjs": "^1.11.12", "dayjs": "^1.11.12",
"lucide-react": "^0.427.0", "lucide-react": "^0.427.0",
"mime": "^4.0.4",
"react": "^18.3.1", "react": "^18.3.1",
"react-daisyui": "^5.0.3", "react-daisyui": "^5.0.3",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",

10
pnpm-lock.yaml generated
View File

@ -23,6 +23,9 @@ importers:
lucide-react: lucide-react:
specifier: ^0.427.0 specifier: ^0.427.0
version: 0.427.0(react@18.3.1) version: 0.427.0(react@18.3.1)
mime:
specifier: ^4.0.4
version: 4.0.4
react: react:
specifier: ^18.3.1 specifier: ^18.3.1
version: 18.3.1 version: 18.3.1
@ -1192,6 +1195,11 @@ packages:
resolution: {integrity: sha512-LPP/3KorzCwBxfeUuZmaR6bG2kdeHSbe0P2tY3FLRU4vYrjYz5hI4QZwV0njUx3jeuKe67YukQ1LSPZBKDqO/Q==} resolution: {integrity: sha512-LPP/3KorzCwBxfeUuZmaR6bG2kdeHSbe0P2tY3FLRU4vYrjYz5hI4QZwV0njUx3jeuKe67YukQ1LSPZBKDqO/Q==}
engines: {node: '>=8.6'} engines: {node: '>=8.6'}
mime@4.0.4:
resolution: {integrity: sha512-v8yqInVjhXyqP6+Kw4fV3ZzeMRqEW6FotRsKXjRS5VMTNIuXsdRoAvklpoRgSqXm6o9VNH4/C0mgedko9DdLsQ==}
engines: {node: '>=16'}
hasBin: true
minimatch@3.1.2: minimatch@3.1.2:
resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
@ -2743,6 +2751,8 @@ snapshots:
braces: 3.0.3 braces: 3.0.3
picomatch: 2.3.1 picomatch: 2.3.1
mime@4.0.4: {}
minimatch@3.1.2: minimatch@3.1.2:
dependencies: dependencies:
brace-expansion: 1.1.11 brace-expansion: 1.1.11

View File

@ -8,7 +8,7 @@ export type Tab = {
name: string; name: string;
title?: string; title?: string;
icon?: LucideIcon; icon?: LucideIcon;
Component?: () => JSX.Element; Component?: () => JSX.Element | null;
}; };
type Props = { type Props = {
@ -36,13 +36,16 @@ const TabView = ({
<> <>
<Tabs <Tabs
variant="boxed" variant="boxed"
className={cn("w-auto inline-flex flex-row items-stretch", className)} className={cn(
"w-auto inline-flex flex-row items-stretch overflow-x-auto",
className
)}
> >
{tabs.map(({ icon: Icon, ...tab }) => ( {tabs.map(({ icon: Icon, ...tab }) => (
<Tabs.Tab <Tabs.Tab
key={tab.name} key={tab.name}
active={curTab === tab.name} active={curTab === tab.name}
className="flex flex-row items-center gap-x-2 h-auto" className="flex flex-row items-center gap-x-2 h-auto shrink-0"
onClick={() => { onClick={() => {
setSearchParams((params) => { setSearchParams((params) => {
params.set(name, tab.name); params.set(name, tab.name);

View File

@ -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<HTMLElement | null>(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 (
<Button
icon={ArrowUp}
className={cn(
"fixed bottom-4 right-4 invisible opacity-0 transition-opacity",
show && "visible opacity-100"
)}
onClick={onClick}
>
Top
</Button>
);
};
export default GotoTopButton;

View File

@ -46,6 +46,7 @@ export const ToggleField = <T extends FieldValues>({
{...props} {...props}
{...field} {...field}
className={inputClassName} className={inputClassName}
color={field.value ? "primary" : undefined}
checked={field.value || false} checked={field.value || false}
onChange={(e) => field.onChange(e.target.checked)} onChange={(e) => field.onChange(e.target.checked)}
/> />

View File

@ -1,3 +1,4 @@
import { useEffect, useRef } from "react";
import { createStore, useStore } from "zustand"; import { createStore, useStore } from "zustand";
export const createDisclosure = <T = any>() => { export const createDisclosure = <T = any>() => {
@ -6,9 +7,28 @@ export const createDisclosure = <T = any>() => {
isOpen: false, isOpen: false,
})); }));
const useDisclosure = () => {
const dialogRef = useRef<HTMLDialogElement>(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 { return {
store, store,
use: () => useStore(store), use: useDisclosure,
open: (data?: T | null) => store.setState({ isOpen: true, data }), open: (data?: T | null) => store.setState({ isOpen: true, data }),
close: () => store.setState({ isOpen: false }), close: () => store.setState({ isOpen: false }),
}; };

View File

@ -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 (
<>
<CreateFolderAction prefix={prefix} />
{/* <Button icon={FilePlus} color="ghost" /> */}
<Button
icon={UploadIcon}
color="ghost"
title="Upload File"
onClick={onUploadFile}
/>
{/* <Button icon={EllipsisVertical} color="ghost" /> */}
</>
);
};
type CreateFolderActionProps = {
prefix: string;
};
const CreateFolderAction = ({ prefix }: CreateFolderActionProps) => {
const { isOpen, onOpen, onClose } = useDisclosure();
const { bucketName } = useBucketContext();
const queryClient = useQueryClient();
const form = useForm<CreateFolderSchema>({
resolver: zodResolver(createFolderSchema),
defaultValues: { name: "" },
});
useEffect(() => {
if (isOpen) form.setFocus("name");
}, [isOpen]);
const createFolder = usePutObject(bucketName, {
onSuccess: () => {
toast.success("Folder created!");
queryClient.invalidateQueries({ queryKey: ["browse", bucketName] });
onClose();
form.reset();
},
onError: handleError,
});
const onSubmit = form.handleSubmit((values) => {
createFolder.mutate({ key: `${prefix}${values.name}/`, file: null });
});
return (
<>
<Button
icon={FolderPlus}
color="ghost"
onClick={onOpen}
title="Create Folder"
/>
<Modal open={isOpen}>
<Modal.Header>Create Folder</Modal.Header>
<Modal.Body>
<form onSubmit={onSubmit}>
<InputField form={form} name="name" title="Name" />
</form>
</Modal.Body>
<Modal.Actions>
<Button onClick={onClose}>Cancel</Button>
<Button
color="primary"
onClick={onSubmit}
disabled={createFolder.isPending}
>
Submit
</Button>
</Modal.Actions>
</Modal>
</>
);
};
export default Actions;

View File

@ -1,25 +1,36 @@
import { useParams } from "react-router-dom"; import { useSearchParams } from "react-router-dom";
import { Card } from "react-daisyui"; import { Card } from "react-daisyui";
import ObjectList from "./object-list"; import ObjectList from "./object-list";
import { useBucket } from "../hooks"; import { useEffect, useState } from "react";
import { useState } from "react";
import ObjectListNavigator from "./object-list-navigator"; import ObjectListNavigator from "./object-list-navigator";
import { import Actions from "./actions";
EllipsisVertical, import { useBucketContext } from "../context";
FilePlus, import ShareDialog from "./share-dialog";
FolderPlus,
UploadIcon, const getInitialPrefixes = (searchParams: URLSearchParams) => {
} from "lucide-react"; const prefix = searchParams.get("prefix");
import Button from "@/components/ui/button"; if (prefix) {
const paths = prefix.split("/").filter((p) => p);
return paths.map((_, i) => paths.slice(0, i + 1).join("/") + "/");
}
return [];
};
const BrowseTab = () => { const BrowseTab = () => {
const { id } = useParams(); const { bucket } = useBucketContext();
const { data: bucket } = useBucket(id); const [searchParams, setSearchParams] = useSearchParams();
const [prefixHistory, setPrefixHistory] = useState<string[]>(
getInitialPrefixes(searchParams)
);
const [curPrefix, setCurPrefix] = useState(prefixHistory.length - 1);
const [curPrefix, setCurPrefix] = useState(-1); useEffect(() => {
const [prefixHistory, setPrefixHistory] = useState<string[]>([]); const prefix = prefixHistory[curPrefix] || "";
const bucketName = bucket?.globalAliases[0]; const newParams = new URLSearchParams(searchParams);
newParams.set("prefix", prefix);
setSearchParams(newParams);
}, [curPrefix]);
const gotoPrefix = (prefix: string) => { const gotoPrefix = (prefix: string) => {
const history = prefixHistory.slice(0, curPrefix + 1); const history = prefixHistory.slice(0, curPrefix + 1);
@ -27,15 +38,12 @@ const BrowseTab = () => {
setCurPrefix(history.length); setCurPrefix(history.length);
}; };
if (!bucket) {
return null;
}
if (!bucket.keys.find((k) => k.permissions.read && k.permissions.write)) { if (!bucket.keys.find((k) => k.permissions.read && k.permissions.write)) {
return ( return (
<div className="p-4 min-h-[200px] flex flex-col justify-center"> <div className="p-4 min-h-[200px] flex flex-col items-center justify-center">
<p className="text-center"> <p className="text-center max-w-sm">
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.
</p> </p>
</div> </div>
); );
@ -43,29 +51,20 @@ const BrowseTab = () => {
return ( return (
<div> <div>
<Card> <Card className="pb-2">
<ObjectListNavigator <ObjectListNavigator
bucketName={bucketName}
curPrefix={curPrefix} curPrefix={curPrefix}
setCurPrefix={setCurPrefix} setCurPrefix={setCurPrefix}
prefixHistory={prefixHistory} prefixHistory={prefixHistory}
actions={ actions={<Actions prefix={prefixHistory[curPrefix] || ""} />}
<>
<Button icon={FolderPlus} color="ghost" />
<Button icon={FilePlus} color="ghost" />
<Button icon={UploadIcon} color="ghost" />
<Button icon={EllipsisVertical} color="ghost" />
</>
}
/> />
{bucketName ? ( <ObjectList
<ObjectList prefix={prefixHistory[curPrefix] || ""}
bucket={bucketName} onPrefixChange={gotoPrefix}
prefix={prefixHistory[curPrefix] || ""} />
onPrefixChange={gotoPrefix}
/> <ShareDialog />
) : null}
</Card> </Card>
</div> </div>
); );

View File

@ -1,6 +1,14 @@
import api from "@/lib/api"; import api from "@/lib/api";
import { useQuery } from "@tanstack/react-query"; import {
import { GetObjectsResult, UseBrowserObjectOptions } from "./types"; useMutation,
UseMutationOptions,
useQuery,
} from "@tanstack/react-query";
import {
GetObjectsResult,
PutObjectPayload,
UseBrowserObjectOptions,
} from "./types";
export const useBrowseObjects = ( export const useBrowseObjects = (
bucket: string, bucket: string,
@ -12,3 +20,30 @@ export const useBrowseObjects = (
api.get<GetObjectsResult>(`/browse/${bucket}`, { params: options }), api.get<GetObjectsResult>(`/browse/${bucket}`, { params: options }),
}); });
}; };
export const usePutObject = (
bucket: string,
options?: UseMutationOptions<any, Error, PutObjectPayload>
) => {
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<any, Error, string>
) => {
return useMutation({
mutationFn: (key) => api.delete(`/browse/${bucket}/${key}`),
...options,
});
};

View File

@ -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<Object, "objectKey" | "downloadUrl">;
};
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 (
<td className="!p-0 w-auto">
<span className="w-full flex flex-row justify-end pr-2">
{!isDirectory && (
<Button icon={DownloadIcon} color="ghost" onClick={onDownload} />
)}
<Dropdown end>
<Dropdown.Toggle button={false}>
<Button icon={EllipsisVertical} color="ghost" />
</Dropdown.Toggle>
<Dropdown.Menu className="bg-base-300 gap-y-1">
<Dropdown.Item
onClick={() =>
shareDialog.open({ key: object.objectKey, prefix })
}
>
<Share2 /> Share
</Dropdown.Item>
<Dropdown.Item
className="text-error bg-error/10"
onClick={onDelete}
>
<Trash /> Delete
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
</span>
</td>
);
};
export default ObjectActions;

View File

@ -1,10 +1,9 @@
import Button from "@/components/ui/button"; import Button from "@/components/ui/button";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { ChevronLeft, ChevronRight } from "lucide-react"; import { ChevronLeft, ChevronRight, Home, LucideIcon } from "lucide-react";
import { Fragment } from "react/jsx-runtime"; import { Fragment } from "react/jsx-runtime";
type Props = { type Props = {
bucketName?: string;
curPrefix: number; curPrefix: number;
setCurPrefix: React.Dispatch<React.SetStateAction<number>>; setCurPrefix: React.Dispatch<React.SetStateAction<number>>;
prefixHistory: string[]; prefixHistory: string[];
@ -12,7 +11,6 @@ type Props = {
}; };
const ObjectListNavigator = ({ const ObjectListNavigator = ({
bucketName,
curPrefix, curPrefix,
setCurPrefix, setCurPrefix,
prefixHistory, prefixHistory,
@ -45,16 +43,16 @@ const ObjectListNavigator = ({
/> />
</div> </div>
<div className="order-3 md:order-2 flex flex-row w-full overflow-x-auto items-center bg-base-200 h-10 flex-1 shrink-0 min-w-[80%] md:min-w-0 rounded-lg mx-2 pl-4"> <div className="order-3 md:order-2 flex flex-row w-full overflow-x-auto items-center bg-base-200 h-10 flex-1 shrink-0 min-w-[80%] md:min-w-0 rounded-lg mx-2 px-2">
<HistoryItem <HistoryItem
title={bucketName} icon={Home}
isActive={curPrefix === -1} isActive={curPrefix === -1}
onClick={() => setCurPrefix(-1)} onClick={() => setCurPrefix(-1)}
/> />
{prefixHistory.map((prefix, i) => ( {prefixHistory.map((prefix, i) => (
<Fragment key={prefix}> <Fragment key={prefix}>
<ChevronRight className="shrink-0" size={20} /> <ChevronRight className="shrink-0" size={18} />
<HistoryItem <HistoryItem
title={prefix title={prefix
.substring(0, prefix.lastIndexOf("/")) .substring(0, prefix.lastIndexOf("/"))
@ -75,13 +73,19 @@ const ObjectListNavigator = ({
}; };
type HistoryItemProps = { type HistoryItemProps = {
icon?: LucideIcon;
title?: string; title?: string;
isActive: boolean; isActive: boolean;
onClick: () => void; onClick: () => void;
}; };
const HistoryItem = ({ title, isActive, onClick }: HistoryItemProps) => { const HistoryItem = ({
if (!title) { icon: Icon,
title,
isActive,
onClick,
}: HistoryItemProps) => {
if (!title && !Icon) {
return null; return null;
} }
@ -92,8 +96,13 @@ const HistoryItem = ({ title, isActive, onClick }: HistoryItemProps) => {
e.preventDefault(); e.preventDefault();
onClick(); 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 ? <Icon size={18} /> : null}
{title} {title}
</a> </a>
); );

View File

@ -1,27 +1,29 @@
import { Table } from "react-daisyui"; import { Table } from "react-daisyui";
import { useBrowseObjects } from "./hooks"; import { useBrowseObjects } from "./hooks";
import { dayjs, readableBytes } from "@/lib/utils"; import { dayjs, readableBytes } from "@/lib/utils";
import mime from "mime/lite";
import { Object } from "./types"; import { Object } from "./types";
import { API_URL } from "@/lib/api"; 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 = { type Props = {
bucket: string;
prefix?: string; prefix?: string;
onPrefixChange?: (prefix: string) => void; onPrefixChange?: (prefix: string) => void;
}; };
const ObjectList = ({ bucket, prefix, onPrefixChange }: Props) => { const ObjectList = ({ prefix, onPrefixChange }: Props) => {
const { data } = useBrowseObjects(bucket, { prefix }); const { bucketName } = useBucketContext();
const { data } = useBrowseObjects(bucketName, { prefix, limit: 1000 });
const onObjectClick = (object: Object) => { const onObjectClick = (object: Object) => {
window.open( window.open(API_URL + object.viewUrl, "_blank");
API_URL + `/browse/${bucket}/${data?.prefix}${object.objectKey}?view=1`,
"_blank"
);
}; };
return ( return (
<div className="overflow-x-auto"> <div className="overflow-x-auto pb-32">
<Table> <Table>
<Table.Head> <Table.Head>
<span>Name</span> <span>Name</span>
@ -30,37 +32,114 @@ const ObjectList = ({ bucket, prefix, onPrefixChange }: Props) => {
</Table.Head> </Table.Head>
<Table.Body> <Table.Body>
{!data?.prefixes?.length && !data?.objects?.length && (
<tr>
<td className="text-center py-8" colSpan={3}>
No objects
</td>
</tr>
)}
{data?.prefixes.map((prefix) => ( {data?.prefixes.map((prefix) => (
<Table.Row <tr
key={prefix} key={prefix}
className="hover:bg-neutral cursor-pointer" className="hover:bg-neutral/60 hover:text-neutral-content group"
role="button"
onClick={() => onPrefixChange?.(prefix)}
> >
<span> <td
{prefix.substring(0, prefix.lastIndexOf("/")).split("/").pop()} className="cursor-pointer"
</span> role="button"
<span /> onClick={() => onPrefixChange?.(prefix)}
<span /> >
</Table.Row> <span className="flex items-center gap-2 font-normal">
<Folder size={20} className="text-primary" />
{prefix
.substring(0, prefix.lastIndexOf("/"))
.split("/")
.pop()}
</span>
</td>
<td colSpan={2} />
<ObjectActions object={{ objectKey: prefix, downloadUrl: "" }} />
</tr>
))} ))}
{data?.objects.map((object) => ( {data?.objects.map((object) => {
<Table.Row const extIdx = object.objectKey.lastIndexOf(".");
key={object.objectKey} const filename =
className="hover:bg-neutral cursor-pointer" extIdx >= 0
role="button" ? object.objectKey.substring(0, extIdx)
onClick={() => onObjectClick(object)} : object.objectKey;
> const ext = extIdx >= 0 ? object.objectKey.substring(extIdx) : null;
<span>{object.objectKey}</span>
<span>{readableBytes(object.size)}</span> return (
<span>{dayjs(object.lastModified).fromNow()}</span> <tr
</Table.Row> key={object.objectKey}
))} className="hover:bg-neutral/60 hover:text-neutral-content group"
>
<td
className="cursor-pointer"
role="button"
onClick={() => onObjectClick(object)}
>
<span className="flex items-center font-normal w-full">
<FilePreview ext={ext?.substring(1)} object={object} />
<span className="truncate max-w-[40vw]">{filename}</span>
{ext && <span className="text-base-content/60">{ext}</span>}
</span>
</td>
<td className="whitespace-nowrap">
{readableBytes(object.size)}
</td>
<td className="whitespace-nowrap">
{dayjs(object.lastModified).fromNow()}
</td>
<ObjectActions prefix={data.prefix} object={object} />
</tr>
);
})}
</Table.Body> </Table.Body>
</Table> </Table>
<GotoTopButton />
</div> </div>
); );
}; };
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 (
<img
src={API_URL + object.viewUrl}
alt={object.objectKey}
className="size-5 object-cover overflow-hidden mr-2"
/>
);
}
if (type === "text") {
Icon = FileType;
}
return (
<Icon
size={20}
className="text-base-content/60 group-hover:text-neutral-content/80 mr-2"
/>
);
};
export default ObjectList; export default ObjectList;

View File

@ -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<typeof createFolderSchema>;

View File

@ -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 (
<Modal ref={dialogRef} open={isOpen} backdrop>
<Modal.Header className="truncate">Share {data?.key || ""}</Modal.Header>
<Modal.Body>
{!bucket.websiteAccess && (
<Alert className="mb-4 items-start text-sm">
<FileWarningIcon className="mt-1" />
Sharing is only available for buckets with enabled website access.
</Alert>
)}
<div className="flex flex-row overflow-x-auto pb-2">
{domains.map((item) => (
<Checkbox
key={item}
label={item}
checked={item === domain}
onChange={() => setDomain(item)}
/>
))}
</div>
<div className="relative mt-2">
<Input
value={url}
className="w-full pr-12"
onFocus={(e) => e.target.select()}
/>
<Button
icon={Copy}
onClick={() => copyToClipboard(url)}
className="absolute top-0 right-0"
color="ghost"
/>
</div>
</Modal.Body>
<Modal.Actions>
<Button onClick={() => shareDialog.close()}>Close</Button>
</Modal.Actions>
</Modal>
);
};
export default ShareDialog;

View File

@ -15,4 +15,11 @@ export type Object = {
objectKey: string; objectKey: string;
lastModified: Date; lastModified: Date;
size: number; size: number;
viewUrl: string;
downloadUrl: string;
};
export type PutObjectPayload = {
key: string;
file: File | null;
}; };

View File

@ -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;
};

View File

@ -1,7 +1,6 @@
import { Modal } from "react-daisyui"; import { Modal } from "react-daisyui";
import { Plus } from "lucide-react"; import { Plus } from "lucide-react";
import Chips from "@/components/ui/chips"; import Chips from "@/components/ui/chips";
import { Bucket } from "../../types";
import { useDisclosure } from "@/hooks/useDisclosure"; import { useDisclosure } from "@/hooks/useDisclosure";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
@ -13,12 +12,11 @@ import { handleError } from "@/lib/utils";
import { InputField } from "@/components/ui/input"; import { InputField } from "@/components/ui/input";
import { useEffect } from "react"; import { useEffect } from "react";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { useBucketContext } from "../context";
type Props = { const AliasesSection = () => {
data?: Bucket; const { bucket: data } = useBucketContext();
};
const AliasesSection = ({ data }: Props) => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const removeAlias = useRemoveAlias(data?.id, { const removeAlias = useRemoveAlias(data?.id, {
onSuccess: () => { onSuccess: () => {

View File

@ -4,15 +4,13 @@ import { QuotaSchema, quotaSchema } from "../schema";
import { useEffect } from "react"; import { useEffect } from "react";
import { useDebounce } from "@/hooks/useDebounce"; import { useDebounce } from "@/hooks/useDebounce";
import { useUpdateBucket } from "../hooks"; import { useUpdateBucket } from "../hooks";
import { Bucket } from "../../types";
import { InputField } from "@/components/ui/input"; import { InputField } from "@/components/ui/input";
import { ToggleField } from "@/components/ui/toggle"; import { ToggleField } from "@/components/ui/toggle";
import { useBucketContext } from "../context";
type Props = { const QuotaSection = () => {
data?: Bucket; const { bucket: data } = useBucketContext();
};
const QuotaSection = ({ data }: Props) => {
const form = useForm<QuotaSchema>({ const form = useForm<QuotaSchema>({
resolver: zodResolver(quotaSchema), resolver: zodResolver(quotaSchema),
}); });
@ -23,7 +21,7 @@ const QuotaSection = ({ data }: Props) => {
const onChange = useDebounce((values: DeepPartial<QuotaSchema>) => { const onChange = useDebounce((values: DeepPartial<QuotaSchema>) => {
const { enabled } = values; const { enabled } = values;
const maxObjects = Number(values.maxObjects); 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 = { const data = {
maxObjects: enabled && maxObjects > 0 ? maxObjects : null, maxObjects: enabled && maxObjects > 0 ? maxObjects : null,
@ -37,9 +35,7 @@ const QuotaSection = ({ data }: Props) => {
form.reset({ form.reset({
enabled: enabled:
data?.quotas?.maxSize != null || data?.quotas?.maxObjects != null, data?.quotas?.maxSize != null || data?.quotas?.maxObjects != null,
maxSize: data?.quotas?.maxSize maxSize: data?.quotas?.maxSize ? data?.quotas?.maxSize / 1024 ** 3 : null,
? data?.quotas?.maxSize / 1024 / 1024
: null,
maxObjects: data?.quotas?.maxObjects || null, maxObjects: data?.quotas?.maxObjects || null,
}); });

View File

@ -1,24 +1,22 @@
import { Card } from "react-daisyui"; import { Card } from "react-daisyui";
import { useParams } from "react-router-dom";
import { useBucket } from "../hooks";
import { ChartPie, ChartScatter } from "lucide-react"; import { ChartPie, ChartScatter } from "lucide-react";
import { readableBytes } from "@/lib/utils"; import { readableBytes } from "@/lib/utils";
import WebsiteAccessSection from "./overview-website-access"; import WebsiteAccessSection from "./overview-website-access";
import AliasesSection from "./overview-aliases"; import AliasesSection from "./overview-aliases";
import QuotaSection from "./overview-quota"; import QuotaSection from "./overview-quota";
import { useBucketContext } from "../context";
const OverviewTab = () => { const OverviewTab = () => {
const { id } = useParams(); const { bucket: data } = useBucketContext();
const { data } = useBucket(id);
return ( return (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 md:gap-8 items-start"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-4 md:gap-8 items-start">
<Card className="card-body gap-0 items-start order-2 md:order-1"> <Card className="card-body gap-0 items-start order-2 md:order-1">
<Card.Title>Summary</Card.Title> <Card.Title>Summary</Card.Title>
<AliasesSection data={data} /> <AliasesSection />
<WebsiteAccessSection data={data} /> <WebsiteAccessSection />
<QuotaSection data={data} /> <QuotaSection />
</Card> </Card>
<Card className="card-body order-1 md:order-2"> <Card className="card-body order-1 md:order-2">

View File

@ -7,20 +7,16 @@ import { useUpdateBucket } from "../hooks";
import { useConfig } from "@/hooks/useConfig"; import { useConfig } from "@/hooks/useConfig";
import { Info, LinkIcon } from "lucide-react"; import { Info, LinkIcon } from "lucide-react";
import Button from "@/components/ui/button"; import Button from "@/components/ui/button";
import { Bucket } from "../../types";
import { InputField } from "@/components/ui/input"; import { InputField } from "@/components/ui/input";
import { ToggleField } from "@/components/ui/toggle"; import { ToggleField } from "@/components/ui/toggle";
import { useBucketContext } from "../context";
type Props = { const WebsiteAccessSection = () => {
data?: Bucket; const { bucket: data, bucketName } = useBucketContext();
};
const WebsiteAccessSection = ({ data }: Props) => {
const { data: config } = useConfig(); const { data: config } = useConfig();
const form = useForm<WebsiteConfigSchema>({ const form = useForm<WebsiteConfigSchema>({
resolver: zodResolver(websiteConfigSchema), resolver: zodResolver(websiteConfigSchema),
}); });
const bucketName = data?.globalAliases[0] || "";
const isEnabled = useWatch({ control: form.control, name: "websiteAccess" }); const isEnabled = useWatch({ control: form.control, name: "websiteAccess" });
const websitePort = config?.s3_web?.bind_addr?.split(":").pop() || "80"; const websitePort = config?.s3_web?.bind_addr?.split(":").pop() || "80";

View File

@ -7,6 +7,7 @@ import OverviewTab from "./overview/overview-tab";
import PermissionsTab from "./permissions/permissions-tab"; import PermissionsTab from "./permissions/permissions-tab";
import MenuButton from "./components/menu-button"; import MenuButton from "./components/menu-button";
import BrowseTab from "./browse/browse-tab"; import BrowseTab from "./browse/browse-tab";
import { BucketContext } from "./context";
const tabs: Tab[] = [ const tabs: Tab[] = [
{ {
@ -31,7 +32,7 @@ const tabs: Tab[] = [
const ManageBucketPage = () => { const ManageBucketPage = () => {
const { id } = useParams(); const { id } = useParams();
const { data } = useBucket(id); const { data, refetch } = useBucket(id);
const name = data?.globalAliases[0]; const name = data?.globalAliases[0];
@ -40,9 +41,16 @@ const ManageBucketPage = () => {
<Page <Page
title={name || "Manage Bucket"} title={name || "Manage Bucket"}
prev="/buckets" prev="/buckets"
actions={<MenuButton />} actions={data ? <MenuButton /> : undefined}
/> />
<TabView tabs={tabs} className="bg-base-100 h-14 px-1.5" />
{data && (
<BucketContext.Provider
value={{ bucket: data, refetch, bucketName: name || "" }}
>
<TabView tabs={tabs} className="bg-base-100 h-14 px-1.5" />
</BucketContext.Provider>
)}
</div> </div>
); );
}; };

View File

@ -12,13 +12,14 @@ import { useAllowKey } from "../hooks";
import { toast } from "sonner"; import { toast } from "sonner";
import { handleError } from "@/lib/utils"; import { handleError } from "@/lib/utils";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { useBucketContext } from "../context";
type Props = { type Props = {
id?: string;
currentKeys?: string[]; currentKeys?: string[];
}; };
const AllowKeyDialog = ({ id, currentKeys }: Props) => { const AllowKeyDialog = ({ currentKeys }: Props) => {
const { bucket } = useBucketContext();
const { dialogRef, isOpen, onOpen, onClose } = useDisclosure(); const { dialogRef, isOpen, onOpen, onClose } = useDisclosure();
const { data: keys } = useKeys(); const { data: keys } = useKeys();
const form = useForm<AllowKeysSchema>({ const form = useForm<AllowKeysSchema>({
@ -30,12 +31,12 @@ const AllowKeyDialog = ({ id, currentKeys }: Props) => {
}); });
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const allowKey = useAllowKey(id, { const allowKey = useAllowKey(bucket.id, {
onSuccess: () => { onSuccess: () => {
form.reset(); form.reset();
onClose(); onClose();
toast.success("Key allowed!"); toast.success("Key allowed!");
queryClient.invalidateQueries({ queryKey: ["bucket", id] }); queryClient.invalidateQueries({ queryKey: ["bucket", bucket.id] });
}, },
onError: handleError, onError: handleError,
}); });

View File

@ -1,5 +1,4 @@
import { useParams } from "react-router-dom"; import { useDenyKey } from "../hooks";
import { useBucket, useDenyKey } from "../hooks";
import { Card, Checkbox, Table } from "react-daisyui"; import { Card, Checkbox, Table } from "react-daisyui";
import Button from "@/components/ui/button"; import Button from "@/components/ui/button";
import { Trash } from "lucide-react"; import { Trash } from "lucide-react";
@ -7,12 +6,12 @@ import AllowKeyDialog from "./allow-key-dialog";
import { useMemo } from "react"; import { useMemo } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { handleError } from "@/lib/utils"; import { handleError } from "@/lib/utils";
import { useBucketContext } from "../context";
const PermissionsTab = () => { const PermissionsTab = () => {
const { id } = useParams(); const { bucket, refetch } = useBucketContext();
const { data, refetch } = useBucket(id);
const denyKey = useDenyKey(id, { const denyKey = useDenyKey(bucket.id, {
onSuccess: () => { onSuccess: () => {
toast.success("Key removed!"); toast.success("Key removed!");
refetch(); refetch();
@ -21,13 +20,13 @@ const PermissionsTab = () => {
}); });
const keys = useMemo(() => { const keys = useMemo(() => {
return data?.keys.filter( return bucket?.keys.filter(
(key) => (key) =>
key.permissions.read !== false || key.permissions.read !== false ||
key.permissions.write !== false || key.permissions.write !== false ||
key.permissions.owner !== false key.permissions.owner !== false
); );
}, [data?.keys]); }, [bucket?.keys]);
const onRemove = (id: string) => { const onRemove = (id: string) => {
if (window.confirm("Are you sure you want to remove this key?")) { if (window.confirm("Are you sure you want to remove this key?")) {
@ -43,10 +42,7 @@ const PermissionsTab = () => {
<Card className="card-body"> <Card className="card-body">
<div className="flex flex-row items-center gap-2"> <div className="flex flex-row items-center gap-2">
<Card.Title className="flex-1 truncate">Access Keys</Card.Title> <Card.Title className="flex-1 truncate">Access Keys</Card.Title>
<AllowKeyDialog <AllowKeyDialog currentKeys={keys?.map((key) => key.accessKeyId)} />
id={id}
currentKeys={keys?.map((key) => key.accessKeyId)}
/>
</div> </div>
<div className="overflow-x-auto"> <div className="overflow-x-auto">

View File

@ -10,16 +10,22 @@ const BucketsPage = () => {
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const items = useMemo(() => { const items = useMemo(() => {
if (!search?.length) { let buckets = data || [];
return 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(); buckets = buckets.sort((a, b) =>
return data?.filter( a.globalAliases[0].localeCompare(b.globalAliases[0])
(bucket) =>
bucket.id.includes(q) ||
bucket.globalAliases.find((alias) => alias.includes(q))
); );
return buckets;
}, [data, search]); }, [data, search]);
return ( return (