Files
kvm/ui/src/layout/components_side/SharedFolders/FileManager.tsx
2026-05-16 16:39:28 +08:00

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