mirror of
https://github.com/luckfox-eng29/kvm.git
synced 2026-05-27 00:25:09 +02:00
577 lines
21 KiB
TypeScript
577 lines
21 KiB
TypeScript
// 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;
|
|
onFormatSDStorage?: () => void;
|
|
onMountSDStorage?: () => void;
|
|
fsType?: 'exfat' | 'fat32';
|
|
onFsTypeChange?: (value: 'exfat' | 'fat32') => void;
|
|
}
|
|
|
|
export const FileManager: React.FC<StorageFilePageProps> = ({
|
|
mediaType,
|
|
listFilesMethod,
|
|
getSpaceMethod,
|
|
deleteFileMethod,
|
|
downloadUrlPrefix,
|
|
showSDManagement = false,
|
|
onResetSDStorage,
|
|
onUnmountSDStorage,
|
|
onFormatSDStorage,
|
|
fsType,
|
|
onFsTypeChange,
|
|
}) => {
|
|
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]);
|
|
|
|
const handleFormatWrapper = useCallback(async () => {
|
|
if (onFormatSDStorage) {
|
|
setLoading(true);
|
|
await onFormatSDStorage();
|
|
setSDMountStatus(null);
|
|
syncStorage();
|
|
setLoading(false);
|
|
}
|
|
}, [onFormatSDStorage, 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>
|
|
{sdMountStatus !== "none" && (
|
|
<div className="pt-2">
|
|
<div className="w-full space-y-2">
|
|
<p className="w-full text-left text-xs text-slate-700 dark:text-slate-300">
|
|
{$at("Choose the file system for MicroSD formatting")}
|
|
</p>
|
|
<select
|
|
value={fsType || "fat32"}
|
|
onChange={(e) => onFsTypeChange?.(e.target.value as 'exfat' | 'fat32')}
|
|
style={{ width: "100%", padding: "8px", borderRadius: "4px" }}
|
|
>
|
|
<option value="fat32">FAT32</option>
|
|
<option value="exfat">exFAT</option>
|
|
</select>
|
|
<AntdButton
|
|
disabled={loading}
|
|
danger={true}
|
|
type="primary"
|
|
onClick={handleFormatWrapper}
|
|
className="w-full text-red-500 dark:text-red-400 border-red-200 dark:border-red-800"
|
|
>
|
|
{$at("Format MicroSD Card")} ({(fsType || "fat32")})
|
|
</AntdButton>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</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}
|
|
onFormatSDStorage={handleFormatWrapper}
|
|
syncStorage={syncStorage}
|
|
fsType={fsType}
|
|
onFsTypeChange={onFsTypeChange}
|
|
/>
|
|
|
|
{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;
|
|
onFormatSDStorage?: () => void;
|
|
syncStorage: () => void;
|
|
fsType?: 'exfat' | 'fat32';
|
|
onFsTypeChange?: (value: 'exfat' | 'fat32') => void;
|
|
}
|
|
|
|
const ActionButtonsSection: React.FC<ActionButtonsSectionProps> = ({
|
|
mediaType,
|
|
loading,
|
|
showSDManagement,
|
|
onUnmountSDStorage,
|
|
onFormatSDStorage,
|
|
fsType,
|
|
onFsTypeChange,
|
|
}) => {
|
|
const { $at } = useReactAt();
|
|
|
|
if (mediaType === "sd" && showSDManagement) {
|
|
return (
|
|
<div className="animate-fadeIn space-y-2 opacity-0"
|
|
style={{ animationDuration: "0.7s", animationDelay: "0.25s" }}
|
|
>
|
|
<div className="w-full space-y-2">
|
|
<p className="w-full text-left text-xs text-slate-700 dark:text-slate-300">
|
|
{$at("Choose the file system for MicroSD formatting")}
|
|
</p>
|
|
<select
|
|
value={fsType || "fat32"}
|
|
onChange={(e) => onFsTypeChange?.(e.target.value as 'exfat' | 'fat32')}
|
|
style={{ width: "100%", padding: "8px", borderRadius: "4px" }}
|
|
>
|
|
<option value="fat32">FAT32</option>
|
|
<option value="exfat">exFAT</option>
|
|
</select>
|
|
<AntdButton
|
|
disabled={loading}
|
|
type="primary"
|
|
danger={true}
|
|
onClick={onFormatSDStorage}
|
|
className="w-full text-red-500 dark:text-red-400 border-red-200 dark:border-red-800"
|
|
>{$at("Format MicroSD Card")} ({(fsType || "fat32")})</AntdButton>
|
|
</div>
|
|
<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>
|
|
);
|
|
}
|
|
};
|