mirror of
https://github.com/khairul169/garage-webui.git
synced 2025-04-27 22:39:31 +07:00
feat: fix responsive layout, add theme switcher
This commit is contained in:
parent
43af9c8658
commit
f503b261f5
@ -1,9 +1,10 @@
|
|||||||
import { PageContextProvider } from "@/context/page-context";
|
import { PageContextProvider } from "@/context/page-context";
|
||||||
import Router from "./router";
|
import Router from "./router";
|
||||||
import "./styles.css";
|
|
||||||
import { Toaster } from "sonner";
|
import { Toaster } from "sonner";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import ThemeProvider from "@/components/containers/theme-provider";
|
||||||
|
import "./styles.css";
|
||||||
|
|
||||||
const App = () => {
|
const App = () => {
|
||||||
const [queryClient] = useState(() => new QueryClient());
|
const [queryClient] = useState(() => new QueryClient());
|
||||||
@ -14,6 +15,7 @@ const App = () => {
|
|||||||
<Router />
|
<Router />
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
<Toaster richColors />
|
<Toaster richColors />
|
||||||
|
<ThemeProvider />
|
||||||
</PageContextProvider>
|
</PageContextProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
16
src/app/themes.ts
Normal file
16
src/app/themes.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
// https://daisyui.com/docs/themes/
|
||||||
|
|
||||||
|
export const themes = [
|
||||||
|
"pastel",
|
||||||
|
"dark",
|
||||||
|
"dracula",
|
||||||
|
"cupcake",
|
||||||
|
"dim",
|
||||||
|
"night",
|
||||||
|
"nord",
|
||||||
|
"corporate",
|
||||||
|
"valentine",
|
||||||
|
"winter",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type Themes = (typeof themes)[number];
|
@ -1,12 +1,16 @@
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn, ucfirst } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
ArchiveIcon,
|
ArchiveIcon,
|
||||||
HardDrive,
|
HardDrive,
|
||||||
KeySquare,
|
KeySquare,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
|
Palette,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Menu } from "react-daisyui";
|
import { Dropdown, Menu } from "react-daisyui";
|
||||||
import { Link, useLocation } from "react-router-dom";
|
import { Link, useLocation } from "react-router-dom";
|
||||||
|
import Button from "../ui/button";
|
||||||
|
import { themes } from "@/app/themes";
|
||||||
|
import appStore from "@/stores/app-store";
|
||||||
|
|
||||||
const pages = [
|
const pages = [
|
||||||
{ icon: LayoutDashboard, title: "Dashboard", path: "/", exact: true },
|
{ icon: LayoutDashboard, title: "Dashboard", path: "/", exact: true },
|
||||||
@ -19,7 +23,7 @@ const Sidebar = () => {
|
|||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="bg-base-100 border-r border-base-300/30 w-[220px] overflow-y-auto">
|
<aside className="bg-base-100 border-r border-base-300/30 w-[80%] md:w-[250px] flex flex-col items-stretch overflow-hidden h-full">
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<img
|
<img
|
||||||
src="https://garagehq.deuxfleurs.fr/images/garage-logo.svg"
|
src="https://garagehq.deuxfleurs.fr/images/garage-logo.svg"
|
||||||
@ -28,7 +32,8 @@ const Sidebar = () => {
|
|||||||
/>
|
/>
|
||||||
<p className="text-sm font-medium text-center">WebUI</p>
|
<p className="text-sm font-medium text-center">WebUI</p>
|
||||||
</div>
|
</div>
|
||||||
<Menu className="gap-y-1">
|
|
||||||
|
<Menu className="gap-y-1 flex-1 overflow-y-auto">
|
||||||
{pages.map((page) => {
|
{pages.map((page) => {
|
||||||
const isActive = page.exact
|
const isActive = page.exact
|
||||||
? pathname === page.path
|
? pathname === page.path
|
||||||
@ -50,6 +55,22 @@ const Sidebar = () => {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</Menu>
|
</Menu>
|
||||||
|
|
||||||
|
<Dropdown className="my-2 mx-4" vertical="top">
|
||||||
|
<Dropdown.Toggle button={false}>
|
||||||
|
<Button icon={Palette} color="ghost">
|
||||||
|
Theme
|
||||||
|
</Button>
|
||||||
|
</Dropdown.Toggle>
|
||||||
|
|
||||||
|
<Dropdown.Menu className="max-h-[200px] overflow-y-auto">
|
||||||
|
{themes.map((theme) => (
|
||||||
|
<Dropdown.Item key={theme} onClick={() => appStore.setTheme(theme)}>
|
||||||
|
{ucfirst(theme)}
|
||||||
|
</Dropdown.Item>
|
||||||
|
))}
|
||||||
|
</Dropdown.Menu>
|
||||||
|
</Dropdown>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
15
src/components/containers/theme-provider.tsx
Normal file
15
src/components/containers/theme-provider.tsx
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import appStore from "@/stores/app-store";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useStore } from "zustand";
|
||||||
|
|
||||||
|
const ThemeProvider = () => {
|
||||||
|
const theme = useStore(appStore, (i) => i.theme);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.documentElement.setAttribute("data-theme", theme);
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ThemeProvider;
|
@ -1,29 +1,46 @@
|
|||||||
import { PageContext } from "@/context/page-context";
|
import { PageContext } from "@/context/page-context";
|
||||||
import { Suspense, useContext } from "react";
|
import { Suspense, useContext, useEffect } from "react";
|
||||||
import { Outlet, useNavigate } from "react-router-dom";
|
import { Outlet, useLocation, useNavigate } from "react-router-dom";
|
||||||
import Sidebar from "../containers/sidebar";
|
import Sidebar from "../containers/sidebar";
|
||||||
import { ArrowLeft } from "lucide-react";
|
import { ArrowLeft, MenuIcon } from "lucide-react";
|
||||||
import Button from "../ui/button";
|
import Button from "../ui/button";
|
||||||
|
import { useDisclosure } from "@/hooks/useDisclosure";
|
||||||
|
import { Drawer } from "react-daisyui";
|
||||||
|
|
||||||
const MainLayout = () => {
|
const MainLayout = () => {
|
||||||
|
const sidebar = useDisclosure();
|
||||||
|
const { pathname } = useLocation();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (sidebar.isOpen) {
|
||||||
|
sidebar.onClose();
|
||||||
|
}
|
||||||
|
}, [pathname]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-row items-stretch h-screen overflow-hidden">
|
<Drawer
|
||||||
<Sidebar />
|
open={sidebar.isOpen}
|
||||||
|
onClickOverlay={sidebar.onClose}
|
||||||
|
className="md:drawer-open h-screen"
|
||||||
|
side={<Sidebar />}
|
||||||
|
contentClassName="flex flex-col overflow-hidden"
|
||||||
|
>
|
||||||
|
<Header onSidebarOpen={sidebar.onOpen} />
|
||||||
|
|
||||||
<div className="flex-1 flex flex-col overflow-hidden">
|
<main className="flex-1 overflow-y-auto p-4 md:p-8">
|
||||||
<Header />
|
<Suspense>
|
||||||
|
<Outlet />
|
||||||
<main className="flex-1 overflow-y-auto p-4 md:p-8">
|
</Suspense>
|
||||||
<Suspense>
|
</main>
|
||||||
<Outlet />
|
</Drawer>
|
||||||
</Suspense>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const Header = () => {
|
type HeaderProps = {
|
||||||
|
onSidebarOpen: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Header = ({ onSidebarOpen }: HeaderProps) => {
|
||||||
const page = useContext(PageContext);
|
const page = useContext(PageContext);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
@ -35,11 +52,18 @@ const Header = () => {
|
|||||||
onClick={() => navigate(page.prev!, { replace: true })}
|
onClick={() => navigate(page.prev!, { replace: true })}
|
||||||
color="ghost"
|
color="ghost"
|
||||||
shape="circle"
|
shape="circle"
|
||||||
className="-ml-4"
|
className="-mx-2"
|
||||||
>
|
>
|
||||||
<ArrowLeft />
|
<ArrowLeft />
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : (
|
||||||
|
<Button
|
||||||
|
icon={MenuIcon}
|
||||||
|
color="ghost"
|
||||||
|
className="md:hidden -mx-2"
|
||||||
|
onClick={onSidebarOpen}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<h1 className="text-xl flex-1 truncate">{page?.title || "Dashboard"}</h1>
|
<h1 className="text-xl flex-1 truncate">{page?.title || "Dashboard"}</h1>
|
||||||
|
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { ComponentPropsWithoutRef } from "react";
|
import { ComponentPropsWithoutRef, forwardRef } from "react";
|
||||||
import BaseSelect from "react-select";
|
import BaseSelect from "react-select";
|
||||||
import Creatable from "react-select/creatable";
|
import Creatable from "react-select/creatable";
|
||||||
|
|
||||||
@ -8,11 +8,12 @@ type Props = ComponentPropsWithoutRef<typeof BaseSelect> & {
|
|||||||
onCreateOption?: (inputValue: string) => void;
|
onCreateOption?: (inputValue: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const Select = ({ creatable, ...props }: Props) => {
|
const Select = forwardRef<any, Props>(({ creatable, ...props }, ref) => {
|
||||||
const Comp = creatable ? Creatable : BaseSelect;
|
const Comp = creatable ? Creatable : BaseSelect;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Comp
|
<Comp
|
||||||
|
ref={ref}
|
||||||
unstyled
|
unstyled
|
||||||
classNames={{
|
classNames={{
|
||||||
control: (p) =>
|
control: (p) =>
|
||||||
@ -42,6 +43,6 @@ const Select = ({ creatable, ...props }: Props) => {
|
|||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
||||||
export default Select;
|
export default Select;
|
||||||
|
51
src/components/ui/toggle.tsx
Normal file
51
src/components/ui/toggle.tsx
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import React, { forwardRef } from "react";
|
||||||
|
import { Toggle as BaseToggle } from "react-daisyui";
|
||||||
|
import FormControl from "./form-control";
|
||||||
|
import { FieldValues } from "react-hook-form";
|
||||||
|
|
||||||
|
type ToggleProps = Omit<
|
||||||
|
React.ComponentPropsWithoutRef<typeof BaseToggle>,
|
||||||
|
"form"
|
||||||
|
> & { label?: string };
|
||||||
|
|
||||||
|
const Toggle = forwardRef<HTMLInputElement, ToggleProps>(
|
||||||
|
({ label, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<label className="inline-flex justify-start label label-text gap-2 cursor-pointer">
|
||||||
|
<BaseToggle ref={ref} {...props} />
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
type ToggleFieldProps<T extends FieldValues> = Omit<
|
||||||
|
React.ComponentPropsWithoutRef<typeof FormControl<T>>,
|
||||||
|
"render"
|
||||||
|
> &
|
||||||
|
ToggleProps & {
|
||||||
|
inputClassName?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ToggleField = <T extends FieldValues>({
|
||||||
|
form,
|
||||||
|
name,
|
||||||
|
title,
|
||||||
|
className,
|
||||||
|
inputClassName,
|
||||||
|
...props
|
||||||
|
}: ToggleFieldProps<T>) => {
|
||||||
|
return (
|
||||||
|
<FormControl
|
||||||
|
form={form}
|
||||||
|
name={name}
|
||||||
|
title={title}
|
||||||
|
className={className}
|
||||||
|
render={(field) => (
|
||||||
|
<Toggle {...props} {...field} className={inputClassName} />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Toggle;
|
@ -13,7 +13,7 @@ export const ucfirst = (text?: string | null) => {
|
|||||||
export const readableBytes = (bytes?: number | null, divider = 1024) => {
|
export const readableBytes = (bytes?: number | null, divider = 1024) => {
|
||||||
if (bytes == null || Number.isNaN(bytes)) return "n/a";
|
if (bytes == null || Number.isNaN(bytes)) return "n/a";
|
||||||
|
|
||||||
const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
|
const sizes = ["B", "KB", "MB", "GB", "TB"];
|
||||||
const i = Math.max(0, Math.floor(Math.log(bytes) / Math.log(divider)));
|
const i = Math.max(0, Math.floor(Math.log(bytes) / Math.log(divider)));
|
||||||
|
|
||||||
return `${(bytes / Math.pow(divider, i)).toFixed(1)} ${sizes[i]}`;
|
return `${(bytes / Math.pow(divider, i)).toFixed(1)} ${sizes[i]}`;
|
||||||
|
@ -27,7 +27,7 @@ const MenuButton = () => {
|
|||||||
return (
|
return (
|
||||||
<Dropdown end>
|
<Dropdown end>
|
||||||
<Dropdown.Toggle button={false}>
|
<Dropdown.Toggle button={false}>
|
||||||
<Button icon={EllipsisVertical} />
|
<Button icon={EllipsisVertical} color="ghost" />
|
||||||
</Dropdown.Toggle>
|
</Dropdown.Toggle>
|
||||||
|
|
||||||
<Dropdown.Menu>
|
<Dropdown.Menu>
|
||||||
|
@ -1,32 +1,111 @@
|
|||||||
import { Button } 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 { Bucket } from "../../types";
|
||||||
|
import { useDisclosure } from "@/hooks/useDisclosure";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { AddAliasSchema, addAliasSchema } from "../schema";
|
||||||
|
import Button from "@/components/ui/button";
|
||||||
|
import { useAddAlias, useRemoveAlias } from "../hooks";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { handleError } from "@/lib/utils";
|
||||||
|
import { InputField } from "@/components/ui/input";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
data?: Bucket;
|
data?: Bucket;
|
||||||
};
|
};
|
||||||
|
|
||||||
const AliasesSection = ({ data }: Props) => {
|
const AliasesSection = ({ data }: Props) => {
|
||||||
const aliases = data?.globalAliases?.slice(1);
|
const queryClient = useQueryClient();
|
||||||
|
const removeAlias = useRemoveAlias(data?.id, {
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Alias removed!");
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["bucket", data?.id] });
|
||||||
|
},
|
||||||
|
onError: handleError,
|
||||||
|
});
|
||||||
|
|
||||||
|
const onRemoveAlias = (alias: string) => {
|
||||||
|
if (window.confirm("Are you sure you want to remove this alias?")) {
|
||||||
|
removeAlias.mutate(alias);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const aliases = data?.globalAliases || [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<p className="inline label label-text">Aliases</p>
|
<p className="inline label label-text">Aliases</p>
|
||||||
|
|
||||||
<div className="flex flex-row flex-wrap gap-2 mt-1">
|
<div className="flex flex-row flex-wrap gap-2 mt-2">
|
||||||
{aliases?.map((alias: string) => (
|
{aliases.map((alias: string) => (
|
||||||
<Chips key={alias} onRemove={() => {}}>
|
<Chips key={alias} onRemove={() => onRemoveAlias(alias)}>
|
||||||
{alias}
|
{alias}
|
||||||
</Chips>
|
</Chips>
|
||||||
))}
|
))}
|
||||||
<Button size="sm">
|
<AddAliasDialog id={data?.id} />
|
||||||
<Plus className="-ml-1" size={18} />
|
|
||||||
Add Alias
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const AddAliasDialog = ({ id }: { id?: string }) => {
|
||||||
|
const { dialogRef, isOpen, onOpen, onClose } = useDisclosure();
|
||||||
|
const form = useForm<AddAliasSchema>({
|
||||||
|
resolver: zodResolver(addAliasSchema),
|
||||||
|
defaultValues: { alias: "" },
|
||||||
|
});
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) form.setFocus("alias");
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const addAlias = useAddAlias(id, {
|
||||||
|
onSuccess: () => {
|
||||||
|
form.reset();
|
||||||
|
onClose();
|
||||||
|
toast.success("Alias added!");
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["bucket", id] });
|
||||||
|
},
|
||||||
|
onError: handleError,
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = form.handleSubmit((values) => {
|
||||||
|
addAlias.mutate(values.alias);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button size="sm" onClick={onOpen} icon={Plus}>
|
||||||
|
Add Alias
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Modal ref={dialogRef} open={isOpen}>
|
||||||
|
<Modal.Header>Add Alias</Modal.Header>
|
||||||
|
|
||||||
|
<Modal.Body>
|
||||||
|
<form onSubmit={onSubmit}>
|
||||||
|
<InputField form={form} name="alias" title="Name" />
|
||||||
|
</form>
|
||||||
|
</Modal.Body>
|
||||||
|
|
||||||
|
<Modal.Actions>
|
||||||
|
<Button onClick={onClose}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
color="primary"
|
||||||
|
onClick={onSubmit}
|
||||||
|
disabled={addAlias.isPending}
|
||||||
|
>
|
||||||
|
Submit
|
||||||
|
</Button>
|
||||||
|
</Modal.Actions>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default AliasesSection;
|
export default AliasesSection;
|
||||||
|
@ -1,12 +1,12 @@
|
|||||||
import { Controller, DeepPartial, useForm, useWatch } from "react-hook-form";
|
import { DeepPartial, useForm, useWatch } from "react-hook-form";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { QuotaSchema, quotaSchema } from "../schema";
|
import { QuotaSchema, quotaSchema } from "../schema";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { Input, Toggle } from "react-daisyui";
|
|
||||||
import FormControl from "@/components/ui/form-control";
|
|
||||||
import { useDebounce } from "@/hooks/useDebounce";
|
import { useDebounce } from "@/hooks/useDebounce";
|
||||||
import { useUpdateBucket } from "../hooks";
|
import { useUpdateBucket } from "../hooks";
|
||||||
import { Bucket } from "../../types";
|
import { Bucket } from "../../types";
|
||||||
|
import { InputField } from "@/components/ui/input";
|
||||||
|
import { ToggleField } from "@/components/ui/toggle";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
data?: Bucket;
|
data?: Bucket;
|
||||||
@ -49,44 +49,22 @@ const QuotaSection = ({ data }: Props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
<p className="label label-text py-0">Quotas</p>
|
<ToggleField form={form} name="enabled" title="Quotas" label="Enabled" />
|
||||||
|
|
||||||
<label className="inline-flex label label-text gap-2 cursor-pointer">
|
|
||||||
<Controller
|
|
||||||
control={form.control}
|
|
||||||
name="enabled"
|
|
||||||
render={({ field }) => (
|
|
||||||
<Toggle {...(field as any)} checked={field.value} />
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
Enabled
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{isEnabled && (
|
{isEnabled && (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<FormControl
|
<InputField
|
||||||
form={form}
|
form={form}
|
||||||
name="maxObjects"
|
name="maxObjects"
|
||||||
title="Max Objects"
|
title="Max Objects"
|
||||||
render={(field) => (
|
type="number"
|
||||||
<Input
|
|
||||||
{...field}
|
|
||||||
type="number"
|
|
||||||
value={String(field.value || "")}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
<FormControl
|
|
||||||
|
<InputField
|
||||||
form={form}
|
form={form}
|
||||||
name="maxSize"
|
name="maxSize"
|
||||||
title="Max Size (GB)"
|
title="Max Size (GB)"
|
||||||
render={(field) => (
|
type="number"
|
||||||
<Input
|
|
||||||
{...field}
|
|
||||||
type="number"
|
|
||||||
value={String(field.value || "")}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
@ -1,15 +1,15 @@
|
|||||||
import { Controller, DeepPartial, useForm, useWatch } from "react-hook-form";
|
import { DeepPartial, useForm, useWatch } from "react-hook-form";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { websiteConfigSchema, WebsiteConfigSchema } from "../schema";
|
import { websiteConfigSchema, WebsiteConfigSchema } from "../schema";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { Input, Toggle } from "react-daisyui";
|
|
||||||
import FormControl from "@/components/ui/form-control";
|
|
||||||
import { useDebounce } from "@/hooks/useDebounce";
|
import { useDebounce } from "@/hooks/useDebounce";
|
||||||
import { useUpdateBucket } from "../hooks";
|
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 { Bucket } from "../../types";
|
||||||
|
import { InputField } from "@/components/ui/input";
|
||||||
|
import { ToggleField } from "@/components/ui/toggle";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
data?: Bucket;
|
data?: Bucket;
|
||||||
@ -72,39 +72,24 @@ const WebsiteAccessSection = ({ data }: Props) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label className="inline-flex label label-text gap-2 cursor-pointer">
|
<ToggleField form={form} name="websiteAccess" label="Enabled" />
|
||||||
<Controller
|
|
||||||
control={form.control}
|
|
||||||
name="websiteAccess"
|
|
||||||
render={({ field }) => (
|
|
||||||
<Toggle {...(field as any)} checked={field.value} />
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
Enabled
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{isEnabled && (
|
{isEnabled && (
|
||||||
<>
|
<>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<FormControl
|
<InputField
|
||||||
form={form}
|
form={form}
|
||||||
name="websiteConfig.indexDocument"
|
name="websiteConfig.indexDocument"
|
||||||
title="Index Document"
|
title="Index Document"
|
||||||
render={(field) => (
|
|
||||||
<Input {...field} value={String(field.value || "")} />
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
<FormControl
|
<InputField
|
||||||
form={form}
|
form={form}
|
||||||
name="websiteConfig.errorDocument"
|
name="websiteConfig.errorDocument"
|
||||||
title="Error Document"
|
title="Error Document"
|
||||||
render={(field) => (
|
|
||||||
<Input {...field} value={String(field.value || "")} />
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 alert flex flex-row flex-wrap">
|
<div className="mt-4 alert flex flex-row flex-wrap text-sm gap-x-2 gap-y-1">
|
||||||
<a
|
<a
|
||||||
href={`http://${bucketName}`}
|
href={`http://${bucketName}`}
|
||||||
className="inline-flex items-center flex-row gap-2 font-medium hover:link"
|
className="inline-flex items-center flex-row gap-2 font-medium hover:link"
|
||||||
|
@ -1,5 +1,10 @@
|
|||||||
import api from "@/lib/api";
|
import api from "@/lib/api";
|
||||||
import { MutationOptions, useMutation, useQuery } from "@tanstack/react-query";
|
import {
|
||||||
|
MutationOptions,
|
||||||
|
useMutation,
|
||||||
|
UseMutationOptions,
|
||||||
|
useQuery,
|
||||||
|
} from "@tanstack/react-query";
|
||||||
import { Bucket, Permissions } from "../types";
|
import { Bucket, Permissions } from "../types";
|
||||||
|
|
||||||
export const useBucket = (id?: string | null) => {
|
export const useBucket = (id?: string | null) => {
|
||||||
@ -18,6 +23,34 @@ export const useUpdateBucket = (id?: string | null) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const useAddAlias = (
|
||||||
|
bucketId?: string | null,
|
||||||
|
options?: UseMutationOptions<any, Error, string>
|
||||||
|
) => {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (alias: string) => {
|
||||||
|
return api.put("/v1/bucket/alias/global", {
|
||||||
|
params: { id: bucketId, alias },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useRemoveAlias = (
|
||||||
|
bucketId?: string | null,
|
||||||
|
options?: UseMutationOptions<any, Error, string>
|
||||||
|
) => {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (alias: string) => {
|
||||||
|
return api.delete("/v1/bucket/alias/global", {
|
||||||
|
params: { id: bucketId, alias },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
export const useAllowKey = (
|
export const useAllowKey = (
|
||||||
bucketId?: string | null,
|
bucketId?: string | null,
|
||||||
options?: MutationOptions<
|
options?: MutationOptions<
|
||||||
|
@ -1,5 +1,11 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const addAliasSchema = z.object({
|
||||||
|
alias: z.string().min(1, "Alias is required"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type AddAliasSchema = z.infer<typeof addAliasSchema>;
|
||||||
|
|
||||||
export const websiteConfigSchema = z.object({
|
export const websiteConfigSchema = z.object({
|
||||||
websiteAccess: z.boolean(),
|
websiteAccess: z.boolean(),
|
||||||
websiteConfig: z
|
websiteConfig: z
|
||||||
|
@ -12,7 +12,7 @@ const BucketsPage = () => {
|
|||||||
<Page title="Buckets" />
|
<Page title="Buckets" />
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div className="flex flex-row items-center gap-2">
|
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2">
|
||||||
<Input placeholder="Search..." />
|
<Input placeholder="Search..." />
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
<CreateBucketDialog />
|
<CreateBucketDialog />
|
||||||
|
@ -128,7 +128,7 @@ const NodesList = ({ nodes }: NodeListProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-row items-center my-2 gap-4">
|
<div className="flex flex-col sm:flex-row items-stretch sm:items-center my-2 gap-x-4 gap-y-2">
|
||||||
<Input
|
<Input
|
||||||
placeholder="Search..."
|
placeholder="Search..."
|
||||||
value={filter.search}
|
value={filter.search}
|
||||||
@ -185,7 +185,7 @@ const NodesList = ({ nodes }: NodeListProps) => {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="w-full overflow-x-auto min-h-[400px] pb-16">
|
<div className="w-full overflow-x-auto min-h-[400px] pb-16">
|
||||||
<Table size="sm">
|
<Table size="sm" className="min-w-[800px]">
|
||||||
<Table.Head>
|
<Table.Head>
|
||||||
<span>#</span>
|
<span>#</span>
|
||||||
<span>ID</span>
|
<span>ID</span>
|
||||||
|
@ -41,7 +41,7 @@ type DetailItemProps = {
|
|||||||
const DetailItem = ({ title, value }: DetailItemProps) => {
|
const DetailItem = ({ title, value }: DetailItemProps) => {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-row items-start max-w-xl gap-3 text-left text-sm">
|
<div className="flex flex-row items-start max-w-xl gap-3 text-left text-sm">
|
||||||
<div className="shrink-0 w-[200px]">
|
<div className="shrink-0 w-1/3 max-w-[200px]">
|
||||||
<p className="text-base-content/80">{title}</p>
|
<p className="text-base-content/80">{title}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 truncate">
|
<div className="flex-1 truncate">
|
||||||
|
@ -30,7 +30,7 @@ const StatsCard = ({
|
|||||||
{typeof value === "undefined" ? "..." : value}
|
{typeof value === "undefined" ? "..." : value}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<p className="text-sm mt-0.5">{title}</p>
|
<p className="text-sm mt-0.5 truncate">{title}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -10,17 +10,25 @@ import {
|
|||||||
HardDrive,
|
HardDrive,
|
||||||
HardDriveUpload,
|
HardDriveUpload,
|
||||||
Leaf,
|
Leaf,
|
||||||
|
PieChart,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { cn, ucfirst } from "@/lib/utils";
|
import { cn, readableBytes, ucfirst } from "@/lib/utils";
|
||||||
|
import { useBuckets } from "../buckets/hooks";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
|
||||||
const HomePage = () => {
|
const HomePage = () => {
|
||||||
const { data: health } = useNodesHealth();
|
const { data: health } = useNodesHealth();
|
||||||
|
const { data: buckets } = useBuckets();
|
||||||
|
|
||||||
|
const totalUsage = useMemo(() => {
|
||||||
|
return buckets?.reduce((acc, bucket) => acc + bucket.bytes, 0);
|
||||||
|
}, [buckets]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container">
|
<div className="container">
|
||||||
<Page title="Dashboard" />
|
<Page title="Dashboard" />
|
||||||
|
|
||||||
<section className="grid grid-cols-1 md:grid-cols-3 gap-4 md:gap-6">
|
<section className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 md:gap-6">
|
||||||
<StatsCard
|
<StatsCard
|
||||||
title="Status"
|
title="Status"
|
||||||
icon={Leaf}
|
icon={Leaf}
|
||||||
@ -65,6 +73,11 @@ const HomePage = () => {
|
|||||||
icon={FileCheck}
|
icon={FileCheck}
|
||||||
value={health?.partitionsAllOk}
|
value={health?.partitionsAllOk}
|
||||||
/>
|
/>
|
||||||
|
<StatsCard
|
||||||
|
title="Total Usage"
|
||||||
|
icon={PieChart}
|
||||||
|
value={readableBytes(totalUsage)}
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -28,7 +28,7 @@ const KeysPage = () => {
|
|||||||
<div className="container">
|
<div className="container">
|
||||||
<Page title="Keys" />
|
<Page title="Keys" />
|
||||||
|
|
||||||
<div className="flex flex-row items-center gap-2">
|
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2">
|
||||||
<Input placeholder="Search..." />
|
<Input placeholder="Search..." />
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
<CreateKeyDialog />
|
<CreateKeyDialog />
|
||||||
|
25
src/stores/app-store.ts
Normal file
25
src/stores/app-store.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { Themes } from "@/app/themes";
|
||||||
|
import { createStore } from "zustand";
|
||||||
|
import { persist } from "zustand/middleware";
|
||||||
|
|
||||||
|
type AppState = {
|
||||||
|
theme: Themes;
|
||||||
|
};
|
||||||
|
|
||||||
|
const store = createStore(
|
||||||
|
persist<AppState>(
|
||||||
|
() => ({
|
||||||
|
theme: "pastel",
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: "appdata",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const appStore = {
|
||||||
|
...store,
|
||||||
|
setTheme: (theme: AppState["theme"]) => store.setState({ theme }),
|
||||||
|
};
|
||||||
|
|
||||||
|
export default appStore;
|
@ -11,6 +11,6 @@ export default {
|
|||||||
},
|
},
|
||||||
plugins: [require("daisyui")],
|
plugins: [require("daisyui")],
|
||||||
daisyui: {
|
daisyui: {
|
||||||
themes: ["light", "dark", "cupcake", "pastel", "dracula"],
|
themes: require("./src/app/themes").themes,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
Loading…
x
Reference in New Issue
Block a user