diff --git a/src/api_client/api.ts b/src/api_client/api.ts index b944857a..92132d63 100644 --- a/src/api_client/api.ts +++ b/src/api_client/api.ts @@ -32,7 +32,7 @@ import type { IUploadOptions, IUploadResponse } from "../store/upload/upload.zod import { UploadExistResponse, UploadResponse } from "../store/upload/upload.zod"; import type { IApiUserListResponse, IManageUser, IUser } from "../store/user/user.zod"; import { ManageUser, UserSchema } from "../store/user/user.zod"; -import type { ServerStatsResponseType } from "../store/util/util.zod"; +import type { ServerStatsResponseType, StorageStatsResponseType } from "../store/util/util.zod"; import type { IWorkerAvailabilityResponse } from "../store/worker/worker.zod"; // eslint-disable-next-line import/no-cycle import { Server } from "./apiClient"; @@ -61,6 +61,7 @@ export enum Endpoints { notThisPerson = "notThisPerson", setFacesPersonLabel = "setFacesPersonLabel", fetchServerStats = "fetchServerStats", + fetchStorageStats = "fetchStorageStats", } const baseQuery = fetchBaseQuery({ @@ -247,6 +248,11 @@ export const api = createApi({ url: `serverstats`, }), }), + [Endpoints.fetchStorageStats]: builder.query({ + query: () => ({ + url: `storagestats`, + }), + }), }), }); @@ -271,4 +277,5 @@ export const { useManageUpdateUserMutation, useIsFirstTimeSetupQuery, useFetchServerStatsQuery, + useFetchStorageStatsQuery, } = api; diff --git a/src/components/menubars/SideMenuNarrow.tsx b/src/components/menubars/SideMenuNarrow.tsx index 042a6d90..7d570328 100644 --- a/src/components/menubars/SideMenuNarrow.tsx +++ b/src/components/menubars/SideMenuNarrow.tsx @@ -1,15 +1,29 @@ -import { ActionIcon, Menu, Navbar } from "@mantine/core"; +import { ActionIcon, Center, Loader, Menu, Navbar, Progress, Text, Tooltip } from "@mantine/core"; import { useMediaQuery } from "@mantine/hooks"; import React, { useState } from "react"; import { useTranslation } from "react-i18next"; import { push } from "redux-first-history"; -import { Book, ChevronRight, Heart } from "tabler-icons-react"; +import storage from "redux-persist/lib/storage"; +import { Book, ChevronRight, Cloud, Heart } from "tabler-icons-react"; +import { useFetchStorageStatsQuery } from "../../api_client/api"; import { selectAuthAccess, selectIsAuthenticated } from "../../store/auth/authSelectors"; import { useAppDispatch, useAppSelector } from "../../store/store"; import { DOCUMENTATION_LINK, LEFT_MENU_WIDTH, SUPPORT_LINK } from "../../ui-constants"; import { getNavigationItems, navigationStyles } from "./navigation"; +function formatBytes(bytes, decimals = 2) { + if (!+bytes) return "0 Bytes"; + + const k = 1024; + const dm = decimals < 0 ? 0 : decimals; + const sizes = ["Bytes", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB"]; + + const i = Math.floor(Math.log(bytes) / Math.log(k)); + + return `${parseFloat((bytes / k ** i).toFixed(dm))} ${sizes[i]}`; +} + export function SideMenuNarrow(): JSX.Element { const isAuthenticated = useAppSelector(selectIsAuthenticated); const canAccess = useAppSelector(selectAuthAccess); @@ -17,6 +31,8 @@ export function SideMenuNarrow(): JSX.Element { const { classes, cx } = navigationStyles(); const [active, setActive] = useState("/"); + const { data: storageStats, isLoading } = useFetchStorageStatsQuery(); + const { t } = useTranslation(); const matches = useMediaQuery("(min-width: 700px)"); @@ -93,6 +109,32 @@ export function SideMenuNarrow(): JSX.Element { {links} +
+
+ + + + {t("storage")} +
+ {isLoading && ( +
+ +
+ )} + {!isLoading && ( + + + + )} +
diff --git a/src/components/menubars/navigation.tsx b/src/components/menubars/navigation.tsx index dd3928ff..e4292c58 100644 --- a/src/components/menubars/navigation.tsx +++ b/src/components/menubars/navigation.tsx @@ -113,6 +113,25 @@ export const navigationStyles = createStyles((theme, _params, getRef) => { display: "block", }, + text: { + ...theme.fn.focusStyles(), + display: "flex", + alignItems: "center", + textDecoration: "none", + fontSize: theme.fontSizes.sm, + color: theme.colorScheme === "dark" ? theme.colors.dark[1] : theme.colors.gray[7], + padding: `${theme.spacing.xs}px ${theme.spacing.sm}px`, + borderRadius: theme.radius.sm, + fontWeight: 500, + }, + + hover: { + "&:hover": { + backgroundColor: theme.colorScheme === "dark" ? theme.colors.dark[6] : theme.colors.gray[0], + color: theme.colorScheme === "dark" ? theme.white : theme.black, + }, + }, + link: { ...theme.fn.focusStyles(), display: "flex", diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 4f85b4aa..6d804ed6 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -544,6 +544,8 @@ "save": "Save", "modify": "Modify", "supportus": "Support us", + "storage": "Storage", + "storagetooltip": "{{usedstorage}} of {{totalstorage}} used", "docs": "Documentation", "deletefaceexplanation": "This action will permanently delete the faces and all its associated data. This action cannot be undone.", "deletealbumexplanation": "This action will permanently delete the album. This action cannot be undone.", diff --git a/src/store/util/util.zod.ts b/src/store/util/util.zod.ts index dd628627..4835d396 100644 --- a/src/store/util/util.zod.ts +++ b/src/store/util/util.zod.ts @@ -4,3 +4,11 @@ export type ServerStatsResponseType = z.infer; // To-Do: Add a type for this export const ServerStatsResponse = z.any(); + +export type StorageStatsResponseType = z.infer; + +export const StorageStatsResponse = z.object({ + used_storage: z.number(), + total_storage: z.number(), + free_storage: z.number(), +}); diff --git a/src/store/util/utilSlice.ts b/src/store/util/utilSlice.ts index 07ba07b8..b2b204cc 100644 --- a/src/store/util/utilSlice.ts +++ b/src/store/util/utilSlice.ts @@ -2,9 +2,11 @@ import type { PayloadAction } from "@reduxjs/toolkit"; import { createSlice } from "@reduxjs/toolkit"; import { api } from "../../api_client/api"; +import type { StorageStatsResponseType } from "./util.zod"; const initialState = { serverStats: null, + storageStats: {} as StorageStatsResponseType, }; const utilSlice = createSlice({ @@ -21,6 +23,14 @@ const utilSlice = createSlice({ ...state, error: payload, })) + .addMatcher(api.endpoints.fetchStorageStats.matchFulfilled, (state, { payload }) => ({ + ...state, + storageStats: payload, + })) + .addMatcher(api.endpoints.fetchStorageStats.matchRejected, (state, { payload }) => ({ + ...state, + error: payload, + })) .addDefaultCase(state => state); }, });