feat(fe/rows): add rows view into files panel

This commit is contained in:
hexxa 2021-12-21 20:04:57 +08:00 committed by Hexxa
parent 87832ee1b2
commit 3550a3a77d
11 changed files with 535 additions and 130 deletions

View file

@ -55,6 +55,7 @@
"react": "^16.8.6",
"react-copy-to-clipboard": "^5.0.1",
"react-dom": "^16.8.6",
"react-icons": "4.3.1",
"react-svg": "^8.0.6",
"throttle-debounce": "^2.1.0",
"webpack-bundle-analyzer": "^4.4.2",

View file

@ -18,6 +18,7 @@ import { MockSettingsClient } from "../../client/settings_mock";
import { controlName as panelTabs } from "../root_frame";
import { settingsDialogCtrl } from "../layers";
import { settingsTabsCtrl } from "../dialog_settings";
import { filesViewCtrl } from "../panel_files";
describe("Login", () => {
initMockWorker();
@ -122,6 +123,7 @@ describe("Login", () => {
[settingsDialogCtrl]: ctrlOff,
[settingsTabsCtrl]: "preferencePane",
[sharingCtrl]: ctrlOff,
[filesViewCtrl]: "rows",
}),
options: Map<string, Set<string>>({
[panelTabs]: Set<string>([
@ -132,6 +134,7 @@ describe("Login", () => {
[settingsDialogCtrl]: Set<string>([ctrlOn, ctrlOff]),
[settingsTabsCtrl]: Set<string>(["preferencePane", "managementPane"]),
[sharingCtrl]: Set<string>([ctrlOn, ctrlOff]),
[filesViewCtrl]: Set<string>(["rows", "table"]),
}),
},
});

View file

@ -4,6 +4,7 @@ import { UploadEntry } from "../worker/interface";
import { MsgPackage } from "../i18n/msger";
import { User, MetadataResp } from "../client";
import { settingsDialogCtrl } from "./layers";
import { filesViewCtrl } from "./panel_files";
import { FilesProps } from "./panel_files";
import { UploadingsProps } from "./panel_uploadings";
import { SharingsProps } from "./panel_sharings";
@ -112,6 +113,7 @@ export function initState(): ICoreState {
[settingsDialogCtrl]: "off",
[settingsTabsCtrl]: "preferencePane",
[sharingCtrl]: "off",
[filesViewCtrl]: "rows",
}),
options: Map<string, Set<string>>({
[panelTabs]: Set<string>([
@ -122,6 +124,7 @@ export function initState(): ICoreState {
[settingsDialogCtrl]: Set<string>(["on", "off"]),
[settingsTabsCtrl]: Set<string>(["preferencePane", "managementPane"]),
[sharingCtrl]: Set<string>(["on", "off"]),
[filesViewCtrl]: Set<string>(["rows", "table"]),
}),
},
},

View file

@ -0,0 +1,123 @@
import * as React from "react";
import { List, Map, Set } from "immutable";
import { RiArrowUpDownFill } from "@react-icons/all-files/ri/RiArrowUpDownFill";
import { Flexbox } from "./flexbox";
export interface Row {
elem: React.ReactNode; // element to display
val: Object; // original object value
sortVals: List<string>; // sortable values in order
}
export interface Props {
sortKeys: List<string>; // display names in order for sorting
rows: List<Row>;
id?: string;
style?: React.CSSProperties;
className?: string;
updateRows?: (rows: Object) => void; // this is a callback which update state with re-sorted rows
}
export interface State {
orders: List<boolean>; // asc = true, desc = false
}
export class Rows extends React.Component<Props, State, {}> {
constructor(p: Props) {
super(p);
this.state = {
orders: p.sortKeys.map((_: string, i: number) => {
return false;
}),
};
}
sortRows = (key: number) => {
if (this.props.updateRows == null) {
return;
}
const sortOption = this.props.sortKeys.get(key);
if (sortOption == null) {
return;
}
const currentOrder = this.state.orders.get(key);
if (currentOrder == null) {
return;
}
const expectedOrder = !currentOrder;
const sortedRows = this.props.rows.sort((row1: Row, row2: Row) => {
const val1 = row1.sortVals.get(key);
const val2 = row2.sortVals.get(key);
if (val1 == null || val2 == null) {
// elements without the sort key will be moved to the last
if (val1 == null && val2 != null) {
return 1;
} else if (val1 != null && val2 == null) {
return -1;
}
return 0;
} else if (val1 < val2) {
return expectedOrder ? -1 : 1;
} else if (val1 === val2) {
return 0;
}
return expectedOrder ? 1 : -1;
});
const sortedItems = sortedRows.map((row: Row): Object => {
return row.val;
});
const newOrders = this.state.orders.set(key, !currentOrder);
this.setState({ orders: newOrders });
this.props.updateRows(sortedItems);
};
render() {
const sortBtns = this.props.sortKeys.map(
(displayName: string, i: number): React.ReactNode => {
return (
<button
key={`rows-${i}`}
className="float"
onClick={() => {
this.sortRows(i);
}}
>
{displayName}
</button>
);
}
);
const bodyRows = this.props.rows.map(
(row: Row, i: number): React.ReactNode => {
return <div key={`rows-r-${i}`}>{row.elem}</div>;
}
);
return (
<div
id={this.props.id}
style={this.props.style}
className={this.props.className}
>
<div className="margin-b-l">
<Flexbox
children={List([
<RiArrowUpDownFill
size="3rem"
className="black-font margin-r-m"
/>,
<span>{sortBtns}</span>,
])}
childrenStyles={List([{ flex: "0 0 auto" }, { flex: "0 0 auto" }])}
/>
</div>
{bodyRows}
</div>
);
}
}

View file

@ -7,6 +7,11 @@ import { RiFolder2Fill } from "@react-icons/all-files/ri/RiFolder2Fill";
import { RiArchiveDrawerFill } from "@react-icons/all-files/ri/RiArchiveDrawerFill";
import { RiFile2Fill } from "@react-icons/all-files/ri/RiFile2Fill";
import { RiFileList2Fill } from "@react-icons/all-files/ri/RiFileList2Fill";
import { RiCheckboxFill } from "@react-icons/all-files/ri/RiCheckboxFill";
import { RiCheckboxBlankFill } from "@react-icons/all-files/ri/RiCheckboxBlankFill";
import { RiInformationFill } from "@react-icons/all-files/ri/RiInformationFill";
import { BiTable } from "@react-icons/all-files/bi/BiTable";
import { BiListUl } from "@react-icons/all-files/bi/BiListUl";
import { alertMsg, confirmMsg } from "../common/env";
import { getErrMsg } from "../common/utils";
@ -17,10 +22,13 @@ import { MetadataResp, roleVisitor, roleAdmin } from "../client";
import { Flexbox } from "./layout/flexbox";
import { Container } from "./layout/container";
import { Table, Cell, Head } from "./layout/table";
import { Rows, Row } from "./layout/rows";
import { Up } from "../worker/upload_mgr";
import { UploadEntry, UploadState } from "../worker/interface";
import { getIcon } from "./visual/icons";
export const filesViewCtrl = "filesView";
export interface Item {
name: string;
size: number;
@ -396,74 +404,10 @@ export class FilesPanel extends React.Component<Props, State, {}> {
this.props.update(updater().updateFilesInfo);
};
render() {
const showOp = this.props.login.userRole === roleVisitor ? "hidden" : "";
const breadcrumb = this.props.filesInfo.dirPath.map(
(pathPart: string, key: number) => {
return (
<button
key={pathPart}
onClick={() =>
this.chdir(this.props.filesInfo.dirPath.slice(0, key + 1))
}
className="item"
>
<span className="content">{pathPart}</span>
</button>
);
}
);
const ops = (
<div id="upload-op">
<Flexbox
children={List([
<div>
<button onClick={this.onMkDir} className="float cyan-btn">
{this.props.msg.pkg.get("browser.folder.add")}
</button>
<input
type="text"
onChange={this.onNewFolderNameChange}
value={this.state.newFolderName}
placeholder={this.props.msg.pkg.get("browser.folder.name")}
className="float"
/>
</div>,
<div>
<button onClick={this.onClickUpload} className="cyan-btn">
{this.props.msg.pkg.get("browser.upload")}
</button>
<input
type="file"
onChange={this.addUploads}
multiple={true}
value={this.state.uploadFiles}
ref={this.assignInput}
className="hidden"
/>
</div>,
])}
childrenStyles={List([
{ flex: "0 0 70%" },
{ flex: "0 0 30%", justifyContent: "flex-end" },
])}
/>
</div>
);
const sortedItems = this.props.filesInfo.items.sort(
(item1: MetadataResp, item2: MetadataResp) => {
if (item1.isDir && !item2.isDir) {
return -1;
} else if (!item1.isDir && item2.isDir) {
return 1;
}
return 0;
}
);
prepareTable = (
sortedItems: List<MetadataResp>,
showOp: string
): React.ReactNode => {
const items = sortedItems.map((item: MetadataResp) => {
const isSelected = this.state.selectedItems.has(item.name);
const dirPath = this.props.filesInfo.dirPath.join("/");
@ -590,6 +534,243 @@ export class FilesPanel extends React.Component<Props, State, {}> {
},
]);
return (
<Table
colStyles={List([
{ width: "3rem", paddingRight: "1rem" },
{ width: "calc(100% - 12rem)", textAlign: "left" },
{ width: "8rem", textAlign: "right" },
])}
id="item-table"
head={tableTitles}
foot={List()}
rows={items}
updateRows={this.updateItems}
/>
);
};
prepareRows = (
sortedItems: List<MetadataResp>,
showOp: string
): React.ReactNode => {
const sortKeys = List<string>([
this.props.msg.pkg.get("item.type"),
this.props.msg.pkg.get("item.name"),
]);
const rows = sortedItems.map((item: MetadataResp): Row => {
const isSelected = this.state.selectedItems.has(item.name);
const dirPath = this.props.filesInfo.dirPath.join("/");
const itemPath = dirPath.endsWith("/")
? `${dirPath}${item.name}`
: `${dirPath}/${item.name}`;
const selectedIconColor = isSelected ? "cyan0-font" : "grey0-font";
const descIconColor = this.state.showDetail.has(item.name)
? "cyan0-font"
: "grey0-font";
const icon = item.isDir ? (
<RiFolder2Fill size="1.8rem" className="yellow0-font" />
) : (
<RiFile2Fill size="1.8rem" className="cyan0-font" />
);
const fileType = item.isDir
? this.props.msg.pkg.get("item.type.folder")
: this.props.msg.pkg.get("item.type.file");
const name = item.isDir ? (
<span className="clickable" onClick={() => this.gotoChild(item.name)}>
{item.name}
</span>
) : (
<a
className="title-m clickable"
href={`/v1/fs/files?fp=${itemPath}`}
target="_blank"
>
{item.name}
</a>
);
const op = item.isDir ? (
<div className={`v-mid item-op ${showOp}`}>
<RiCheckboxFill
size="1.8rem"
className={selectedIconColor}
onClick={() => this.select(item.name)}
/>
</div>
) : (
<div className={`v-mid item-op ${showOp}`}>
<RiInformationFill
size="1.8rem"
className={`${descIconColor} margin-r-m`}
onClick={() => this.toggleDetail(item.name)}
/>
<RiCheckboxFill
size="1.8rem"
className={selectedIconColor}
onClick={() => this.select(item.name)}
/>
</div>
);
const pathTitle = this.props.msg.pkg.get("item.path");
const modTimeTitle = this.props.msg.pkg.get("item.modTime");
const sizeTitle = this.props.msg.pkg.get("item.size");
const fileTypeTitle = this.props.msg.pkg.get("item.type");
const itemSize = FileSize(item.size, { round: 0 });
const compact = item.isDir
? `${pathTitle}: ${itemPath} | ${modTimeTitle}: ${item.modTime}`
: `${pathTitle}: ${itemPath} | ${modTimeTitle}: ${item.modTime} | ${sizeTitle}: ${itemSize} | sha1: ${item.sha1}`;
const details = (
<div>
<div className="card">
<span className="title-m black-font">{pathTitle}</span>
<span>{itemPath}</span>
</div>
<div className="card">
<span className="title-m black-font">{modTimeTitle}</span>
<span>{item.modTime}</span>
</div>
<div className="card">
<span className="title-m black-font">{sizeTitle}</span>
<span>{itemSize}</span>
</div>
<div className="card">
<span className="title-m black-font">SHA1</span>
<Flexbox
children={List([
<input type="text" readOnly={true} value={`${item.sha1}`} />,
<button onClick={() => this.generateHash(itemPath)}>
{this.props.msg.pkg.get("refresh")}
</button>,
])}
childrenStyles={List([{}, { justifyContent: "flex-end" }])}
/>
</div>
</div>
);
const desc = this.state.showDetail.has(item.name) ? details : compact;
const elem = (
<div>
<Flexbox
children={List([
<div>
<div className="v-mid">
{icon}
<span className="margin-l-m desc-l">{`${fileTypeTitle}`}</span>
&nbsp;
<span className="desc-l grey0-font">{`- ${fileType}`}</span>
</div>
</div>,
<div>{op}</div>,
])}
childrenStyles={List([{}, { justifyContent: "flex-end" }])}
/>
<div className="name">{name}</div>
<div className="desc">{desc}</div>
<div className="hr"></div>
</div>
);
const sortVals = List<string>([item.isDir ? "d" : "f", itemPath]);
return {
elem,
sortVals,
val: item,
};
});
return (
<Rows
sortKeys={sortKeys}
rows={List(rows)}
updateRows={this.updateItems}
/>
);
};
setView = (opt: string) => {
if (opt === "rows" || opt === "table") {
updater().setControlOption(filesViewCtrl, opt);
this.props.update(updater().updateUI);
return;
}
// TODO: log error
};
render() {
const showOp = this.props.login.userRole === roleVisitor ? "hidden" : "";
const breadcrumb = this.props.filesInfo.dirPath.map(
(pathPart: string, key: number) => {
return (
<button
key={pathPart}
onClick={() =>
this.chdir(this.props.filesInfo.dirPath.slice(0, key + 1))
}
className="item"
>
<span className="content">{pathPart}</span>
</button>
);
}
);
const ops = (
<div id="upload-op">
<Flexbox
children={List([
<div>
<button onClick={this.onMkDir} className="float cyan-btn">
{this.props.msg.pkg.get("browser.folder.add")}
</button>
<input
type="text"
onChange={this.onNewFolderNameChange}
value={this.state.newFolderName}
placeholder={this.props.msg.pkg.get("browser.folder.name")}
className="float"
/>
</div>,
<div>
<button onClick={this.onClickUpload} className="cyan-btn">
{this.props.msg.pkg.get("browser.upload")}
</button>
<input
type="file"
onChange={this.addUploads}
multiple={true}
value={this.state.uploadFiles}
ref={this.assignInput}
className="hidden"
/>
</div>,
])}
childrenStyles={List([
{ flex: "0 0 70%" },
{ flex: "0 0 30%", justifyContent: "flex-end" },
])}
/>
</div>
);
const viewType = this.props.ui.control.controls.get(filesViewCtrl);
const view =
viewType === "rows" ? (
<div id="item-rows">
{this.prepareRows(this.props.filesInfo.items, showOp)}
</div>
) : (
this.prepareTable(this.props.filesInfo.items, showOp)
);
const usedSpace = FileSize(parseInt(this.props.login.usedSpace, 10), {
round: 0,
});
@ -619,7 +800,7 @@ export class FilesPanel extends React.Component<Props, State, {}> {
this.props.filesInfo.dirPath.join("/")
);
}}
className="red-btn"
className="red-btn left"
>
{this.props.msg.pkg.get("browser.share.del")}
</button>
@ -627,7 +808,7 @@ export class FilesPanel extends React.Component<Props, State, {}> {
<button
type="button"
onClick={this.addSharing}
className="cyan-btn"
className="cyan-btn left"
>
{this.props.msg.pkg.get("browser.share.add")}
</button>
@ -640,7 +821,7 @@ export class FilesPanel extends React.Component<Props, State, {}> {
<button
type="button"
onClick={() => this.delete()}
className="red-btn"
className="red-btn left"
>
{this.props.msg.pkg.get("browser.delete")}
</button>
@ -652,14 +833,33 @@ export class FilesPanel extends React.Component<Props, State, {}> {
) : null}
</span>,
<span>
<span
id="space-used"
className="desc-m grey0-font"
>{`${this.props.msg.pkg.get(
"browser.used"
)} ${usedSpace} / ${spaceLimit}`}</span>
</span>,
<Flexbox
children={List([
<BiListUl
size="1.4rem"
className="black-font margin-r-s"
onClick={() => {
this.setView("rows");
}}
/>,
<BiTable
size="1.4rem"
className="black-font margin-r-s"
onClick={() => {
this.setView("table");
}}
/>,
<span className={`${showOp}`}>
<button
onClick={() => this.selectAll()}
className="select-btn"
>
{this.props.msg.pkg.get("browser.selectAll")}
</button>
</span>,
])}
/>,
])}
childrenStyles={List([
{ flex: "0 0 auto" },
@ -689,27 +889,19 @@ export class FilesPanel extends React.Component<Props, State, {}> {
/>
</span>,
<span className={`${showOp}`}>
<button onClick={() => this.selectAll()} className="select-btn">
{this.props.msg.pkg.get("browser.selectAll")}
</button>
<span>
<span
id="space-used"
className="desc-m grey0-font"
>{`${this.props.msg.pkg.get(
"browser.used"
)} ${usedSpace} / ${spaceLimit}`}</span>
</span>,
])}
childrenStyles={List([{}, { justifyContent: "flex-end" }])}
/>
<Table
colStyles={List([
{ width: "3rem", paddingRight: "1rem" },
{ width: "calc(100% - 12rem)", textAlign: "left" },
{ width: "8rem", textAlign: "right" },
])}
id="item-table"
head={tableTitles}
foot={List()}
rows={items}
updateRows={this.updateItems}
/>
{view}
</Container>
</div>
);

View file

@ -313,15 +313,37 @@ export class Updater {
const isAuthed = this.props.login.authed;
const isSharing =
this.props.ui.control.controls.get(sharingCtrl) === ctrlOn;
const leftControls = this.props.ui.control.controls.filter(
(_: string, key: string): boolean => {
return (
key !== panelTabs &&
key !== settingsDialogCtrl &&
key !== settingsTabsCtrl &&
key !== sharingCtrl
);
}
);
const leftOpts = this.props.ui.control.options.filter(
(_: Set<string>, key: string): boolean => {
return (
key !== panelTabs &&
key !== settingsDialogCtrl &&
key !== settingsTabsCtrl &&
key !== sharingCtrl
);
}
);
let newControls: Map<string, string> = undefined;
let newOptions: Map<string, Set<string>> = undefined;
if (isAuthed) {
this.props.ui.control.controls = Map<string, string>({
newControls = Map<string, string>({
[panelTabs]: "filesPanel",
[settingsDialogCtrl]: ctrlOff,
[settingsTabsCtrl]: "preferencePane",
[sharingCtrl]: isSharing ? ctrlOn : ctrlOff,
});
this.props.ui.control.options = Map<string, Set<string>>({
newOptions = Map<string, Set<string>>({
[panelTabs]: Set<string>([
"filesPanel",
"uploadingsPanel",
@ -333,33 +355,33 @@ export class Updater {
});
if (this.props.login.userRole == roleAdmin) {
this.props.ui.control.options = this.props.ui.control.options.set(
newOptions = newOptions.set(
settingsTabsCtrl,
Set<string>(["preferencePane", "managementPane"])
);
}
} else {
if (isSharing) {
this.props.ui.control.controls = Map<string, string>({
newControls = Map<string, string>({
[panelTabs]: "filesPanel",
[settingsDialogCtrl]: ctrlHidden,
[settingsTabsCtrl]: ctrlHidden,
[sharingCtrl]: ctrlOn,
});
this.props.ui.control.options = Map<string, Set<string>>({
newOptions = Map<string, Set<string>>({
[panelTabs]: Set<string>(["filesPanel"]),
[settingsDialogCtrl]: Set<string>([ctrlHidden]),
[settingsTabsCtrl]: Set<string>([ctrlHidden]),
[sharingCtrl]: Set<string>([ctrlOn]),
});
} else {
this.props.ui.control.controls = Map<string, string>({
newControls = Map<string, string>({
[panelTabs]: ctrlHidden,
[settingsDialogCtrl]: ctrlHidden,
[settingsTabsCtrl]: ctrlHidden,
[sharingCtrl]: ctrlOff,
});
this.props.ui.control.options = Map<string, Set<string>>({
newOptions = Map<string, Set<string>>({
[panelTabs]: Set<string>([ctrlHidden]),
[settingsDialogCtrl]: Set<string>([ctrlHidden]),
[settingsTabsCtrl]: Set<string>([ctrlHidden]),
@ -367,6 +389,9 @@ export class Updater {
});
}
}
this.props.ui.control.controls = newControls.merge(leftControls);
this.props.ui.control.options = newOptions.merge(leftOpts);
};
initStateForVisitor = async (): Promise<any> => {

View file

@ -14,8 +14,9 @@ import { RiInformationFill } from "@react-icons/all-files/ri/RiInformationFill";
import { RiDeleteBin2Fill } from "@react-icons/all-files/ri/RiDeleteBin2Fill";
import { RiArchiveDrawerFill } from "@react-icons/all-files/ri/RiArchiveDrawerFill";
import { RiFileList2Fill } from "@react-icons/all-files/ri/RiFileList2Fill";
import { RiArrowUpDownFill } from "@react-icons/all-files/ri/RiArrowUpDownFill";
import { BiTable } from "@react-icons/all-files/bi/BiTable";
import { BiListUl } from "@react-icons/all-files/bi/BiListUl";
import { colorClass } from "./colors";
@ -38,6 +39,9 @@ const icons = Map<string, IconType>({
RiInformationFill: RiInformationFill,
RiDeleteBin2Fill: RiDeleteBin2Fill,
RiArchiveDrawerFill: RiArchiveDrawerFill,
RiArrowUpDownFill: RiArrowUpDownFill,
BiTable: BiTable,
BiListUl: BiListUl,
});
export function getIconWithProps(

View file

@ -118,4 +118,11 @@ export const msgs: Map<string, string> = Map({
"err.server": "The operation failed in the server",
"err.script.cors": "script error with CORS",
"err.unknown": "unknown error",
"item.type": "Item Type",
"item.type.folder": "Folder",
"item.type.file": "File",
"item.name": "Item Name",
"item.path": "Path",
"item.modTime": "Mod Time",
"item.size": "Size",
});

View file

@ -115,4 +115,11 @@ export const msgs: Map<string, string> = Map({
"err.server": "服务器端操作失败",
"err.script.cors": "跨域脚本错误",
"err.unknown": "未知错误",
"item.type": "类型",
"item.type.folder": "文件夹",
"item.type.file": "文件",
"item.name": "名称",
"item.path": "路径",
"item.modTime": "修改时间",
"item.size": "大小",
});