mirror of
https://github.com/luckfox-eng29/kvm.git
synced 2026-05-28 17:11:20 +02:00
Update App version to 0.1.1
Signed-off-by: luckfox-eng29 <eng29@luckfox.com>
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
import { FileManager } from "@/layout/components_side/SharedFolders/FileManager";
|
||||
|
||||
export default function DeviceFilePage() {
|
||||
return (
|
||||
<FileManager
|
||||
mediaType="local"
|
||||
returnTo="/device-files"
|
||||
listFilesMethod="listStorageFiles"
|
||||
getSpaceMethod="getStorageSpace"
|
||||
deleteFileMethod="deleteStorageFile"
|
||||
downloadUrlPrefix="/storage/download"
|
||||
/>
|
||||
);
|
||||
}
|
||||
506
ui/src/layout/components_side/SharedFolders/FileManager.tsx
Normal file
506
ui/src/layout/components_side/SharedFolders/FileManager.tsx
Normal file
@@ -0,0 +1,506 @@
|
||||
// StorageFilePage.tsx
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { LuRefreshCw } from "react-icons/lu";
|
||||
import { PlusCircleIcon } from "@heroicons/react/20/solid";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useReactAt } from 'i18n-auto-extractor/react'
|
||||
import { Typography , Button as AntdButton } from "antd";
|
||||
import OnSDCardSvg from "@assets/second/noSD.svg?react"
|
||||
import RefreshSvg from "@assets/second/refresh.svg?react"
|
||||
|
||||
import Card from "@components/Card";
|
||||
import { formatters } from "@/utils";
|
||||
import { DEVICE_API } from "@/ui.config";
|
||||
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
||||
import notifications from "@/notifications";
|
||||
import { FileUploader } from "@components/FileManager/FileUploader";
|
||||
import { PreUploadedImageItem } from "@components/PreUploadedImageItem";
|
||||
import { dark_bd_style, dark_bg_desktop, dark_font_style , text_primary_color } from "@/layout/theme_color";
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
export interface StorageFile {
|
||||
name: string;
|
||||
size: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface StorageSpace {
|
||||
bytesUsed: number;
|
||||
bytesFree: number;
|
||||
}
|
||||
|
||||
const LoadingOverlay: React.FC = () => {
|
||||
const { $at } = useReactAt();
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 bg-white/50 dark:bg-slate-800/50 flex items-center justify-center z-10 rounded-lg">
|
||||
<div className="bg-white dark:bg-slate-800 rounded-lg p-6 shadow-lg border border-slate-200 dark:border-slate-700">
|
||||
<div className="flex items-center gap-3">
|
||||
<LuRefreshCw className="h-5 w-5 animate-spin text-[rgba(22,152,217,1)] dark:text-[rgba(45,106,229,1)]" />
|
||||
<span className="text-sm font-medium">{$at("Processing...")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export interface StorageFiles {
|
||||
files: {
|
||||
filename: string;
|
||||
size: number;
|
||||
createdAt: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
interface StorageFilePageProps {
|
||||
mediaType: "local" | "sd";
|
||||
returnTo: string;
|
||||
|
||||
listFilesMethod: string;
|
||||
getSpaceMethod: string;
|
||||
deleteFileMethod: string;
|
||||
downloadUrlPrefix: string;
|
||||
|
||||
showSDManagement?: boolean;
|
||||
onResetSDStorage?: () => void;
|
||||
onUnmountSDStorage?: () => void;
|
||||
onMountSDStorage?: () => void;
|
||||
}
|
||||
|
||||
export const FileManager: React.FC<StorageFilePageProps> = ({
|
||||
mediaType,
|
||||
listFilesMethod,
|
||||
getSpaceMethod,
|
||||
deleteFileMethod,
|
||||
downloadUrlPrefix,
|
||||
showSDManagement = false,
|
||||
onResetSDStorage,
|
||||
onUnmountSDStorage,
|
||||
}) => {
|
||||
const { $at } = useReactAt();
|
||||
|
||||
const [onStorageFiles, setOnStorageFiles] = useState<StorageFile[]>([]);
|
||||
const [sdMountStatus, setSDMountStatus] = useState<"ok" | "none" | "fail" | null>(
|
||||
mediaType === "sd" ? null : "ok"
|
||||
);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const filesPerPage = 5;
|
||||
|
||||
const [send] = useJsonRpc();
|
||||
const [storageSpace, setStorageSpace] = useState<StorageSpace | null>(null);
|
||||
|
||||
const [uploadFile, setUploadFile] = useState<string | null>(null);
|
||||
|
||||
const percentageUsed = useMemo(() => {
|
||||
if (!storageSpace) return 0;
|
||||
return Number(
|
||||
(
|
||||
(storageSpace.bytesUsed / (storageSpace.bytesUsed + storageSpace.bytesFree)) *
|
||||
100
|
||||
).toFixed(1),
|
||||
);
|
||||
}, [storageSpace]);
|
||||
|
||||
const bytesUsed = useMemo(() => storageSpace?.bytesUsed || 0, [storageSpace]);
|
||||
const bytesFree = useMemo(() => storageSpace?.bytesFree || 0, [storageSpace]);
|
||||
|
||||
const syncStorage = useCallback(() => {
|
||||
if (mediaType === "sd") {
|
||||
send("getSDMountStatus", {}, res => {
|
||||
if ("error" in res) {
|
||||
notifications.error(`Failed to check SD card status: ${res.error}`);
|
||||
setSDMountStatus(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const { status } = res.result as { status: "ok" | "none" | "fail" };
|
||||
setSDMountStatus(status);
|
||||
|
||||
if (status !== "ok") return;
|
||||
|
||||
fetchFilesAndSpace();
|
||||
});
|
||||
} else {
|
||||
fetchFilesAndSpace();
|
||||
}
|
||||
}, [send, mediaType]);
|
||||
|
||||
const fetchFilesAndSpace = useCallback(() => {
|
||||
send(listFilesMethod, {}, res => {
|
||||
if ("error" in res) {
|
||||
notifications.error(`Error listing storage files: ${res.error}`);
|
||||
return;
|
||||
}
|
||||
const { files } = res.result as StorageFiles;
|
||||
const formattedFiles = files.map(file => ({
|
||||
name: file.filename,
|
||||
size: formatters.bytes(file.size),
|
||||
createdAt: formatters.date(new Date(file?.createdAt)),
|
||||
}));
|
||||
setOnStorageFiles(formattedFiles);
|
||||
});
|
||||
|
||||
send(getSpaceMethod, {}, res => {
|
||||
if ("error" in res) {
|
||||
notifications.error(`Error getting storage space: ${res.error}`);
|
||||
return;
|
||||
}
|
||||
const space = res.result as StorageSpace;
|
||||
setStorageSpace(space);
|
||||
});
|
||||
}, [send, listFilesMethod, getSpaceMethod]);
|
||||
|
||||
useEffect(() => {
|
||||
syncStorage();
|
||||
}, [syncStorage]);
|
||||
|
||||
const handleDeleteFile = useCallback((file: StorageFile) => {
|
||||
send(deleteFileMethod, { filename: file.name }, res => {
|
||||
if ("error" in res) {
|
||||
notifications.error(`Error deleting file: ${res.error}`);
|
||||
return;
|
||||
}
|
||||
syncStorage();
|
||||
});
|
||||
}, [send, deleteFileMethod, syncStorage]);
|
||||
|
||||
const handleDownloadFile = useCallback((file: StorageFile) => {
|
||||
const downloadUrl = `${DEVICE_API}${downloadUrlPrefix}?file=${encodeURIComponent(file.name)}`;
|
||||
const a = document.createElement("a");
|
||||
a.href = downloadUrl;
|
||||
a.download = file.name;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
}, [downloadUrlPrefix]);
|
||||
|
||||
const handleNewImageClick = useCallback((incompleteFileName?: string) => {
|
||||
if (incompleteFileName) {
|
||||
setUploadFile(incompleteFileName);
|
||||
} else {
|
||||
setUploadFile(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const indexOfLastFile = currentPage * filesPerPage;
|
||||
const indexOfFirstFile = indexOfLastFile - filesPerPage;
|
||||
const currentFiles = onStorageFiles.slice(indexOfFirstFile, indexOfLastFile);
|
||||
const totalPages = Math.ceil(onStorageFiles.length / filesPerPage);
|
||||
|
||||
const handlePreviousPage = () => setCurrentPage(prev => Math.max(prev - 1, 1));
|
||||
const handleNextPage = () => setCurrentPage(prev => Math.min(prev + 1, totalPages));
|
||||
|
||||
const handleUnmountWrapper = useCallback(async () => {
|
||||
if (onUnmountSDStorage) {
|
||||
setLoading(true);
|
||||
await onUnmountSDStorage();
|
||||
setSDMountStatus(null);
|
||||
syncStorage();
|
||||
setLoading(false);
|
||||
}
|
||||
}, [onUnmountSDStorage, syncStorage]);
|
||||
|
||||
const handleResetWrapper = useCallback(async () => {
|
||||
if (onResetSDStorage) {
|
||||
setLoading(true);
|
||||
await onResetSDStorage();
|
||||
setSDMountStatus(null);
|
||||
syncStorage();
|
||||
setLoading(false);
|
||||
}
|
||||
}, [onResetSDStorage, syncStorage]);
|
||||
|
||||
if (mediaType === "sd" && sdMountStatus && sdMountStatus !== "ok") {
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl py-8">
|
||||
<div className="w-full space-y-6 px-0.5">
|
||||
<Card>
|
||||
<div className="p-8 text-center relative">
|
||||
<div className="space-y-2">
|
||||
<OnSDCardSvg className="mx-auto h-[24px] w-[24px]" />
|
||||
<div className="space-y-2">
|
||||
<div className={"flex justify-center gap-3 pt-4"}>
|
||||
<h3 className="text-lg font-semibold text-black dark:text-white">
|
||||
{sdMountStatus === "none"
|
||||
? $at("No SD Card Detected")
|
||||
: $at("SD Card Mount Failed")}
|
||||
</h3>
|
||||
<div className={`w-[24px] h-[24px] border ${dark_bd_style} p-[5px] ${dark_bg_desktop} flex items-center justify-center`}
|
||||
onClick={handleResetWrapper}>
|
||||
<RefreshSvg className={`h-[12px] w-[12px] ${dark_font_style}`} />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-slate-700 dark:text-slate-300">
|
||||
{sdMountStatus === "none"
|
||||
? $at("Please insert an SD card and try again.")
|
||||
: $at("Please format the SD card and try again.")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{loading && <LoadingOverlay />}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-6 px-0.5">
|
||||
<Title level={5} style={{ marginBottom: "24px" }}>
|
||||
{ (mediaType === "sd")
|
||||
? $at("Manage Shared Folders in KVM MicroSD Card")
|
||||
: $at("Manage Shared Folders in KVM Storage")
|
||||
}
|
||||
</Title>
|
||||
|
||||
<FileListSection
|
||||
files={onStorageFiles}
|
||||
currentFiles={currentFiles}
|
||||
loading={loading}
|
||||
onDelete={handleDeleteFile}
|
||||
onDownload={handleDownloadFile}
|
||||
onNewImageClick={handleNewImageClick}
|
||||
showPagination={onStorageFiles.length > filesPerPage}
|
||||
paginationInfo={{
|
||||
indexOfFirstFile,
|
||||
indexOfLastFile,
|
||||
totalFiles: onStorageFiles.length,
|
||||
currentPage,
|
||||
totalPages
|
||||
}}
|
||||
onPreviousPage={handlePreviousPage}
|
||||
onNextPage={handleNextPage}
|
||||
/>
|
||||
|
||||
<hr className="border-slate-800/20 dark:border-slate-300/20" />
|
||||
<div className="animate-fadeIn space-y-2 opacity-0" style={{ animationDuration: "0.7s", animationDelay: "0.20s" }}>
|
||||
<StorageSpaceBar
|
||||
percentageUsed={percentageUsed}
|
||||
bytesUsed={bytesUsed}
|
||||
bytesFree={bytesFree}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ActionButtonsSection
|
||||
mediaType={mediaType}
|
||||
loading={loading}
|
||||
showSDManagement={showSDManagement}
|
||||
onNewImageClick={handleNewImageClick}
|
||||
onUnmountSDStorage={handleUnmountWrapper}
|
||||
syncStorage={syncStorage}
|
||||
/>
|
||||
|
||||
{uploadFile ? (
|
||||
<FileUploader
|
||||
key={`resume-${uploadFile}`}
|
||||
onBack={() => {
|
||||
setUploadFile(null);
|
||||
syncStorage();
|
||||
}}
|
||||
incompleteFileName={uploadFile}
|
||||
media={mediaType}
|
||||
/>
|
||||
) : (
|
||||
<FileUploader
|
||||
key="new-upload"
|
||||
onBack={syncStorage}
|
||||
media={mediaType}
|
||||
/>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
);
|
||||
};
|
||||
|
||||
interface FileListSectionProps {
|
||||
files: StorageFile[];
|
||||
currentFiles: StorageFile[];
|
||||
loading: boolean;
|
||||
onDelete: (file: StorageFile) => void;
|
||||
onDownload: (file: StorageFile) => void;
|
||||
onNewImageClick: (incompleteFileName?: string) => void;
|
||||
showPagination: boolean;
|
||||
paginationInfo: {
|
||||
indexOfFirstFile: number;
|
||||
indexOfLastFile: number;
|
||||
totalFiles: number;
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
};
|
||||
onPreviousPage: () => void;
|
||||
onNextPage: () => void;
|
||||
}
|
||||
|
||||
const FileListSection: React.FC<FileListSectionProps> = ({
|
||||
files,
|
||||
currentFiles,
|
||||
loading,
|
||||
onDelete,
|
||||
onDownload,
|
||||
onNewImageClick,
|
||||
showPagination,
|
||||
paginationInfo,
|
||||
onPreviousPage,
|
||||
onNextPage
|
||||
}) => {
|
||||
const { $at } = useReactAt();
|
||||
|
||||
if (files.length === 0) {
|
||||
return (
|
||||
<div className="w-full animate-fadeIn opacity-0"
|
||||
style={{ animationDuration: "0.7s", animationDelay: "0.1s" }}
|
||||
>
|
||||
<div className="relative">
|
||||
<Card>
|
||||
<div className="flex items-center justify-center py-8 text-center">
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<PlusCircleIcon className={`mx-auto h-6 w-6 ${text_primary_color}`} />
|
||||
<h3 className="text-sm leading-none font-semibold text-black dark:text-white">
|
||||
{$at("No files found")}
|
||||
</h3>
|
||||
<p className="text-xs leading-none text-slate-700 dark:text-slate-300">
|
||||
{$at("Get started by uploading your first file")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
{loading && <LoadingOverlay />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full animate-fadeIn opacity-0 px-0.5"
|
||||
style={{ animationDuration: "0.7s", animationDelay: "0.1s" }}
|
||||
>
|
||||
<div className="relative">
|
||||
<Card>
|
||||
<div className="w-full divide-y divide-slate-200 dark:divide-slate-700">
|
||||
{currentFiles.map((file, index) => (
|
||||
<PreUploadedImageItem
|
||||
key={index}
|
||||
name={file.name}
|
||||
size={file.size}
|
||||
uploadedAt={file.createdAt}
|
||||
isIncomplete={file.name.endsWith(".incomplete")}
|
||||
isSelected={false}
|
||||
onDownload={() => {
|
||||
if (window.confirm($at("Are you sure you want to download ") + file.name + "?")) {
|
||||
onDownload(file);
|
||||
}
|
||||
}}
|
||||
onDelete={() => {
|
||||
if (window.confirm($at("Are you sure you want to delete ") + file.name + "?")) {
|
||||
onDelete(file);
|
||||
}
|
||||
}}
|
||||
onContinueUpload={() => onNewImageClick(file.name)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{showPagination && (
|
||||
<div className="flex items-center justify-between px-4 py-3 bg-slate-50 dark:bg-slate-800">
|
||||
<p className="text-sm text-slate-700 dark:text-slate-300">
|
||||
{$at("Showing")} <span className="font-bold">{paginationInfo.indexOfFirstFile + 1}</span> {$at("to")}{" "}
|
||||
<span className="font-bold">
|
||||
{Math.min(paginationInfo.indexOfLastFile, paginationInfo.totalFiles)}
|
||||
</span>{" "}
|
||||
{$at("of")} <span className="font-bold">{paginationInfo.totalFiles}</span> {$at("results")}
|
||||
</p>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<AntdButton
|
||||
type="primary"
|
||||
onClick={onPreviousPage}
|
||||
disabled={paginationInfo.currentPage === 1}
|
||||
>{$at("Previous")}</AntdButton>
|
||||
<AntdButton
|
||||
type="primary"
|
||||
onClick={onNextPage}
|
||||
disabled={paginationInfo.currentPage === paginationInfo.totalPages}
|
||||
>{$at("Next")}</AntdButton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
{loading && <LoadingOverlay />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface StorageSpaceBarProps {
|
||||
percentageUsed: number;
|
||||
bytesUsed: number;
|
||||
bytesFree: number;
|
||||
}
|
||||
|
||||
export default function StorageSpaceBar({ percentageUsed, bytesUsed, bytesFree }: StorageSpaceBarProps) {
|
||||
const { $at } = useReactAt();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="font-medium text-black dark:text-white">{$at("Available space")}</span>
|
||||
<span className="text-slate-700 dark:text-slate-300">{percentageUsed}% {$at("used")}</span>
|
||||
</div>
|
||||
<div className="h-3.5 w-full overflow-hidden rounded-xs bg-slate-200 dark:bg-slate-700">
|
||||
<div
|
||||
className="h-full rounded-xs bg-[rgba(22,152,217,1)] transition-all duration-300 ease-in-out dark:bg-[rgba(45,106,229,1)]"
|
||||
style={{ width: `${percentageUsed}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm text-slate-600">
|
||||
<span className="text-slate-700 dark:text-slate-300">
|
||||
{formatters.bytes(bytesUsed)} {$at("used")}
|
||||
</span>
|
||||
<span className="text-slate-700 dark:text-slate-300">
|
||||
{formatters.bytes(bytesFree)} {$at("free")}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface ActionButtonsSectionProps {
|
||||
mediaType: "local" | "sd";
|
||||
loading: boolean;
|
||||
showSDManagement?: boolean;
|
||||
onNewImageClick: (incompleteFileName?: string) => void;
|
||||
onUnmountSDStorage?: () => void;
|
||||
syncStorage: () => void;
|
||||
}
|
||||
|
||||
const ActionButtonsSection: React.FC<ActionButtonsSectionProps> = ({
|
||||
mediaType,
|
||||
loading,
|
||||
showSDManagement,
|
||||
onUnmountSDStorage,
|
||||
}) => {
|
||||
const { $at } = useReactAt();
|
||||
|
||||
if (mediaType === "sd" && showSDManagement) {
|
||||
return (
|
||||
<div className="flex animate-fadeIn justify-between opacity-0"
|
||||
style={{ animationDuration: "0.7s", animationDelay: "0.25s" }}
|
||||
>
|
||||
|
||||
<AntdButton
|
||||
disabled={loading}
|
||||
type="primary"
|
||||
danger={true}
|
||||
onClick={onUnmountSDStorage}
|
||||
className="w-full text-red-500 dark:text-red-400 border-red-200 dark:border-red-800"
|
||||
>{$at("Unmount MicroSD Card")}</AntdButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
50
ui/src/layout/components_side/SharedFolders/SDFilePage.tsx
Normal file
50
ui/src/layout/components_side/SharedFolders/SDFilePage.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { FileManager } from "@/layout/components_side/SharedFolders/FileManager";
|
||||
import notifications from "@/notifications";
|
||||
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
||||
|
||||
export default function SDFilePage() {
|
||||
const [send] = useJsonRpc();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleResetSDStorage = async () => {
|
||||
setLoading(true);
|
||||
send("resetSDStorage", {}, res => {
|
||||
if ("error" in res) {
|
||||
notifications.error(`Failed to reset SD card`);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
});
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleUnmountSDStorage = async () => {
|
||||
setLoading(true);
|
||||
send("unmountSDStorage", {}, res => {
|
||||
if ("error" in res) {
|
||||
notifications.error(`Failed to unmount SD card`);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
});
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<FileManager
|
||||
mediaType="sd"
|
||||
returnTo="/sd-files"
|
||||
listFilesMethod="listSDStorageFiles"
|
||||
getSpaceMethod="getSDStorageSpace"
|
||||
deleteFileMethod="deleteSDStorageFile"
|
||||
downloadUrlPrefix="/storage/sd-download"
|
||||
showSDManagement={true}
|
||||
onResetSDStorage={handleResetSDStorage}
|
||||
onUnmountSDStorage={handleUnmountSDStorage}
|
||||
/>
|
||||
);
|
||||
}
|
||||
21
ui/src/layout/components_side/SharedFolders/index.tsx
Normal file
21
ui/src/layout/components_side/SharedFolders/index.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import React from "react";
|
||||
|
||||
import SideTabs from "@components/Sidebar/SideTabs";
|
||||
import DeviceFilePage from "@/layout/components_side/SharedFolders/DeviceFilePage";
|
||||
import SDFilePage from "@/layout/components_side/SharedFolders/SDFilePage";
|
||||
|
||||
|
||||
const SharedFolders: React.FC = () => {
|
||||
|
||||
return (
|
||||
<SideTabs
|
||||
tab1Label="KVM Storage"
|
||||
tab2Label="MicroSD Card"
|
||||
tab1Content={<DeviceFilePage />}
|
||||
tab2Content={<SDFilePage />}
|
||||
defaultActiveKey="1"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default SharedFolders;
|
||||
Reference in New Issue
Block a user