!1 Merge back to master

Merge pull request !1 from dev branch
This commit is contained in:
hekk 2018-05-27 21:32:55 +08:00
parent 30c963a5f0
commit 61a1c93f0f
89 changed files with 15859 additions and 2 deletions

View file

@ -0,0 +1,145 @@
import React from "react";
import { Button } from "../control/button";
import { Input } from "../control/input";
import { config } from "../../config";
import { getIcon } from "../display/icon";
import { makePostBody } from "../../libs/utils";
import { styleButtonLabel } from "./info_bar";
export const classLogin = "auth-pane-login";
export const classLogout = "auth-pane-logout";
const IconSignIn = getIcon("signIn");
const IconSignOut = getIcon("signOut");
const IconAngRight = getIcon("angRight");
export class AuthPane extends React.PureComponent {
constructor(props) {
super(props);
this.state = {
adminId: "",
adminPwd: ""
};
}
onLogin = e => {
e.preventDefault();
this.props.onLogin(
this.props.serverAddr,
this.state.adminId,
this.state.adminPwd
);
};
onLogout = () => {
this.props.onLogout(this.props.serverAddr);
};
onChangeAdminId = adminId => {
this.setState({ adminId });
};
onChangeAdminPwd = adminPwd => {
this.setState({ adminPwd });
};
render() {
if (this.props.isLogin) {
return (
<span className={classLogout} style={this.props.styleContainer}>
<Button
onClick={this.onLogout}
icon={<IconSignOut size={config.rootSize} />}
label={"Logout"}
styleLabel={styleButtonLabel}
styleDefault={{ color: "#666" }}
styleContainer={{ backgroundColor: "#ccc" }}
/>
</span>
);
} else {
if (this.props.compact) {
return (
<form
onSubmit={this.onLogin}
className={classLogin}
style={this.props.styleContainer}
>
<Input
placeholder="user name"
type="text"
onChange={this.onChangeAdminId}
value={this.state.adminId}
styleContainer={{ margin: "0.5rem" }}
icon={<IconAngRight size={config.rootSize} />}
/>
<Input
placeholder="password"
type="password"
onChange={this.onChangeAdminPwd}
value={this.state.adminPwd}
styleContainer={{ margin: "0.5rem" }}
icon={<IconAngRight size={config.rootSize} />}
/>
<Button
type="submit"
icon={<IconSignIn size={config.rootSize} />}
label={"login"}
styleLabel={styleButtonLabel}
styleDefault={{ color: "#fff" }}
styleContainer={{
backgroundColor: "#2c3e50",
marginLeft: "0.5rem"
}}
/>
</form>
);
} else {
return (
<form
onSubmit={this.onLogin}
className={classLogin}
style={this.props.styleContainer}
>
<Input
placeholder="user name"
type="text"
onChange={this.onChangeAdminId}
value={this.state.adminId}
icon={<IconAngRight size={config.rootSize} />}
/>
<Input
placeholder="password"
type="password"
onChange={this.onChangeAdminPwd}
value={this.state.adminPwd}
styleContainer={{ marginLeft: "0.5rem" }}
icon={<IconAngRight size={config.rootSize} />}
/>
<Button
type="submit"
icon={<IconSignIn size={config.rootSize} />}
label={"login"}
styleLabel={styleButtonLabel}
styleDefault={{ color: "#fff" }}
styleContainer={{
backgroundColor: "#2c3e50",
marginLeft: "0.5rem"
}}
/>
</form>
);
}
}
}
}
AuthPane.defaultProps = {
onLogin: () => console.error("undefined"),
onLogout: () => console.error("undefined"),
compact: false,
isLogin: false,
serverAddr: "",
styleContainer: {},
styleStr: ""
};

View file

@ -0,0 +1,249 @@
import React from "react";
import { FileBoxDetail } from "./file_box_detail";
import { Button } from "../control/button";
import { config } from "../../config";
import { getIcon, getIconColor } from "../display/icon";
import { getFileExt } from "../../libs/file_type";
import { del, publishId, shadowId, setDownLimit } from "../../libs/api_share";
const msgUploadOk = "Uploading is stopped and file is deleted";
const msgUploadNok = "Fail to delete file";
const styleLeft = {
float: "left",
padding: "1rem 0 1rem 1rem"
};
const styleRight = {
float: "right",
textAlign: "right",
padding: "0rem"
};
const clear = <div style={{ clear: "both" }} />;
const iconDesStyle = {
display: "inline-block",
fontSize: "0.875rem",
lineHeight: "1rem",
marginBottom: "0.25rem",
maxWidth: "12rem",
overflow: "hidden",
textOverflow: "ellipsis",
textDecoration: "none",
verticalAlign: "middle",
whiteSpace: "nowrap"
};
const descStyle = {
fontSize: "0.75rem",
padding: "0.75rem"
};
const otherStyle = `
.main-pane {
background-color: rgba(255, 255, 255, 1);
transition: background-color 0.1s;
}
.main-pane:hover {
background-color: rgba(255, 255, 255, 0.85);
transition: background-color 0.1s;
}
.show-detail {
opacity: 1;
height: auto;
transition: opacity 0.15s, height 0.5s;
}
.hide-detail {
opacity: 0;
height: 0;
overflow: hidden;
transition: opacity 0.15s, height 0.5s;
}
.main-pane a {
color: #333;
transition: color 1s;
}
.main-pane a:hover {
color: #3498db;
transition: color 1s;
}
`;
const IconMore = getIcon("bars");
const IconTimesCir = getIcon("timesCir");
const styleIconTimesCir = {
color: getIconColor("timesCir")
};
const iconMoreStyleStr = `
.file-box-more {
color: #333;
background-color: #fff;
transition: color 0.4s, background-color 0.4s;
}
.file-box-more:hover {
color: #000;
background-color: #ccc;
transition: color 0.4s, background-color 0.4s;
}
`;
let styleFileBox = {
textAlign: "left",
margin: "1px 0px",
fontSize: "0.75rem"
};
const styleButtonContainer = {
width: "1rem",
height: "1rem",
padding: "1.5rem 1rem"
};
const styleButtonIcon = {
lineHeight: "1rem",
height: "1rem",
margin: "0"
};
export class FileBox extends React.PureComponent {
constructor(props) {
super(props);
}
onToggleDetail = () => {
this.props.onToggleDetail(this.props.id);
};
onDelete = () => {
del(this.props.id).then(ok => {
if (ok) {
this.props.onOk(msgUploadOk);
this.props.onRefresh();
} else {
this.props.onError(msgUploadNok);
}
});
};
render() {
const ext = getFileExt(this.props.name);
const IconFile = getIcon(ext);
const IconSpinner = getIcon("spinner");
const styleIcon = {
color: this.props.isLoading ? "#34495e" : getIconColor(ext)
};
styleFileBox = {
...styleFileBox,
width: this.props.width
};
const fileIcon = this.props.isLoading ? (
<IconSpinner
size={config.rootSize * 2}
style={styleIcon}
className="anm-rotate"
/>
) : (
<IconFile size={config.rootSize * 2} style={styleIcon} />
);
const opIcon = this.props.isLoading ? (
<Button
icon={<IconTimesCir size={config.rootSize} style={styleIconTimesCir} />}
label=""
styleContainer={styleButtonContainer}
styleIcon={styleButtonIcon}
onClick={this.onDelete}
/>
) : (
<Button
icon={<IconMore size={config.rootSize} />}
className={"file-box-more"}
label=""
styleContainer={styleButtonContainer}
styleIcon={styleButtonIcon}
styleStr={iconMoreStyleStr}
onClick={this.onToggleDetail}
/>
);
const downloadLink = (
<a href={this.props.href} style={iconDesStyle} target="_blank">
{this.props.name}
</a>
);
const classDetailPane =
this.props.showDetailId === this.props.id &&
this.props.uploadState === "done"
? "show-detail"
: "hide-detail";
return (
<div key={this.props.id} style={styleFileBox} className="file-box">
<div className="main-pane">
<div style={styleLeft}>{fileIcon}</div>
<div style={styleLeft}>
{downloadLink}
<div
style={{
color: "#999",
lineHeight: "0.75rem",
height: "0.75rem"
}}
>{`${this.props.size} ${this.props.modTime}`}</div>
</div>
<div style={styleRight}>{opIcon}</div>
{clear}
<style>{otherStyle}</style>
</div>
<div style={{ position: "relative" }}>
<FileBoxDetail
id={this.props.id}
name={this.props.name}
size={this.props.size}
modTime={this.props.modTime}
href={this.props.href}
downLimit={this.props.downLimit}
width={this.props.width}
className={classDetailPane}
onRefresh={this.props.onRefresh}
onError={this.props.onError}
onOk={this.props.onOk}
onDel={del}
onPublishId={publishId}
onShadowId={shadowId}
onSetDownLimit={setDownLimit}
/>
</div>
</div>
);
}
}
FileBox.defaultProps = {
id: "",
name: "",
isLoading: false,
modTime: "unknown",
uploadState: "",
href: "",
width: "320px",
showDetailId: "",
downLimit: -3,
size: "unknown",
onToggleDetail: () => console.error("undefined"),
onRefresh: () => console.error("undefined"),
onError: () => console.error("undefined"),
onOk: () => console.error("undefined")
};

View file

@ -0,0 +1,230 @@
import React from "react";
import { Button } from "../control/button";
import { Input } from "../control/input";
export const classDelBtn = "file-box-pane-btn-del";
export const classDelYes = "del-no";
export const classDelNo = "del-yes";
let styleDetailPane = {
color: "#666",
backgroundColor: "#fff",
position: "absolute",
marginBottom: "5rem",
zIndex: "10"
};
const styleDetailContainer = {
padding: "1em",
borderBottom: "solid 1rem #ccc"
};
const styleDetailHeader = {
color: "#999",
fontSize: "0.75rem",
fontWeight: "bold",
margin: "1.5rem 0 0.5rem 0",
padding: 0,
textTransform: "uppercase"
};
const styleDesc = {
overflow: "hidden",
whiteSpace: "nowrap",
textOverflow: "ellipsis",
lineHeight: "1.5rem",
fontSize: "0.875rem"
};
export class FileBoxDetail extends React.PureComponent {
constructor(props) {
super(props);
this.state = {
downLimit: this.props.downLimit,
showDelComfirm: false
};
styleDetailPane = {
...styleDetailPane,
width: this.props.width
};
}
onResetLink = () => {
return this.props.onPublishId(this.props.id).then(resettedId => {
if (resettedId == null) {
this.props.onError("Resetting link failed");
} else {
this.props.onOk("Link is reset");
this.props.onRefresh();
}
});
};
onShadowLink = () => {
return this.props.onShadowId(this.props.id).then(shadowId => {
if (shadowId == null) {
this.props.onError("Shadowing link failed");
} else {
this.props.onOk("Link is shadowed");
this.props.onRefresh();
}
});
};
onSetDownLimit = newValue => {
this.setState({ downLimit: newValue });
};
onComfirmDel = () => {
this.setState({ showDelComfirm: true });
};
onCancelDel = () => {
this.setState({ showDelComfirm: false });
};
onUpdateDownLimit = () => {
return this.props
.onSetDownLimit(this.props.id, this.state.downLimit)
.then(ok => {
if (ok) {
this.props.onOk("Download limit updated");
this.props.onRefresh();
} else {
this.props.onError("Setting download limit failed");
}
});
};
onDelete = () => {
return this.props.onDel(this.props.id).then(ok => {
if (ok) {
this.props.onOk("File deleted");
this.props.onRefresh();
} else {
this.props.onError("Fail to delete file");
}
});
};
render() {
const delComfirmButtons = (
<div>
<Button
className={classDelYes}
label={"DON'T delete"}
styleContainer={{ backgroundColor: "#2c3e50", marginTop: "0.25rem" }}
styleDefault={{ color: "#fff" }}
onClick={this.onCancelDel}
/>
<Button
className={classDelNo}
label={"DELETE it"}
styleContainer={{ backgroundColor: "#e74c3c", marginTop: "0.25rem" }}
styleDefault={{ color: "#fff" }}
onClick={this.onDelete}
/>
</div>
);
return (
<div style={styleDetailPane} className={this.props.className}>
<div style={styleDetailContainer}>
<div>
<h4 style={styleDetailHeader}>File Information</h4>
<div>
<div style={styleDesc}>
<b>Name</b> {this.props.name}
</div>
<div style={styleDesc}>
<b>Size</b> {this.props.size}
</div>
<div style={styleDesc}>
<b>Time</b> {this.props.modTime}
</div>
</div>
</div>
<div>
<h4 style={styleDetailHeader}>Download Link</h4>
<Input
type="text"
value={`${window.location.protocol}//${window.location.host}${
this.props.href
}`}
style={{ marginBottom: "0.5rem" }}
/>
{/* <Button label={"Copy"} onClick={this.onCopyLink} /> */}
<br />
<Button
label={"Reset"}
onClick={this.onResetLink}
styleContainer={{
backgroundColor: "#ccc",
marginRight: "0.5rem",
marginTop: "0.25rem"
}}
/>
<Button
label={"Regenerate"}
onClick={this.onShadowLink}
styleContainer={{ backgroundColor: "#ccc", marginTop: "0.25rem" }}
/>
</div>
<div>
<h4 style={styleDetailHeader}>
Download Limit (-1 means unlimited)
</h4>
<Input
type="text"
value={this.state.downLimit}
onChange={this.onSetDownLimit}
style={{ marginBottom: "0.5rem" }}
/>
<br />
<Button
label={"Update"}
styleContainer={{ backgroundColor: "#ccc", marginTop: "0.25rem" }}
onClick={this.onUpdateDownLimit}
/>
</div>
<div>
<h4 style={styleDetailHeader}>Delete</h4>
{this.state.showDelComfirm ? (
delComfirmButtons
) : (
<Button
className={classDelBtn}
label={"Delete"}
styleContainer={{
backgroundColor: "#e74c3c",
marginTop: "0.25rem"
}}
styleDefault={{ color: "#fff" }}
onClick={this.onComfirmDel}
/>
)}
</div>
</div>
</div>
);
}
}
FileBoxDetail.defaultProps = {
id: "n/a",
name: "n/a",
size: "n/a",
modTime: 0,
href: "n/a",
downLimit: -3,
width: -1,
className: "",
onRefresh: () => console.error("undefined"),
onError: () => console.error("undefined"),
onOk: () => console.error("undefined"),
onDel: () => console.error("undefined"),
onPublishId: () => console.error("undefined"),
onShadowId: () => console.error("undefined"),
onSetDownLimit: () => console.error("undefined")
};

View file

@ -0,0 +1,154 @@
import axios from "axios";
import byteSize from "byte-size";
import React from "react";
import ReactDOM from "react-dom";
import throttle from "lodash.throttle";
import { Grids } from "../../components/layout/grids";
import { Uploader } from "../../components/composite/uploader";
import { FileBox } from "./file_box";
import { TimeGrids } from "./time_grids";
import { config } from "../../config";
const msgSynced = "Synced";
const msgSyncFailed = "Syncing failed";
const interval = 250;
export class FilePane extends React.PureComponent {
constructor(props) {
super(props);
this.state = {
infos: [],
showDetailId: -1
};
this.onRefresh = throttle(this.onRefreshImp, interval);
this.onUpdateProgress = throttle(this.onUpdateProgressImp, interval);
}
componentWillMount() {
return this.onRefreshImp();
}
onRefreshImp = () => {
return this.props
.onList()
.then(infos => {
if (infos != null) {
this.setState({ infos });
this.props.onOk(msgSynced);
} else {
this.props.onError(msgSyncFailed);
}
})
.catch(err => {
console.error(err);
this.props.onError(msgSyncFailed);
});
};
onUpdateProgressImp = (shareId, progress) => {
const updatedInfos = this.state.infos.map(shareInfo => {
return shareInfo.Id === shareId ? { ...shareInfo, progress } : shareInfo;
});
this.setState({ infos: updatedInfos });
};
onToggleDetail = id => {
this.setState({
showDetailId: this.state.showDetailId === id ? -1 : id
});
};
getByteSize = size => {
const sizeObj = byteSize(size);
return `${sizeObj.value} ${sizeObj.unit}`;
};
getInfos = filterName => {
const filteredInfos = this.state.infos.filter(shareInfo => {
return shareInfo.PathLocal.includes(filterName);
});
return filteredInfos.map(shareInfo => {
const isLoading = shareInfo.State === "uploading";
const timestamp = shareInfo.ModTime / 1000000;
const modTime = new Date(timestamp).toLocaleString();
const href = `${config.serverAddr}/download?shareid=${shareInfo.Id}`;
const progress = isNaN(shareInfo.progress) ? 0 : shareInfo.progress;
const name = isLoading
? `${Math.floor(progress * 100)}% ${shareInfo.PathLocal}`
: shareInfo.PathLocal;
return {
key: shareInfo.Id,
timestamp,
component: (
<FileBox
key={shareInfo.Id}
id={shareInfo.Id}
name={name}
size={this.getByteSize(shareInfo.Uploaded)}
uploadState={shareInfo.State}
isLoading={isLoading}
modTime={modTime}
href={href}
downLimit={shareInfo.DownLimit}
width={`${this.props.colWidth}rem`}
onRefresh={this.onRefresh}
onOk={this.props.onOk}
onError={this.props.onError}
showDetailId={this.state.showDetailId}
onToggleDetail={this.onToggleDetail}
/>
)
};
});
};
render() {
const styleUploaderContainer = {
width: `${this.props.colWidth}rem`,
margin: "auto"
};
const containerStyle = {
width: this.props.width,
margin: "auto",
marginTop: "0",
marginBottom: "10rem"
};
return (
<div className="file-pane" style={containerStyle}>
<TimeGrids
items={this.getInfos(this.props.filterName)}
styleContainer={{
width:
this.props.width === "auto"
? config.rootSize * config.colWidth
: this.props.width,
margin: "auto"
}}
/>
<div style={styleUploaderContainer}>
<Uploader
onRefresh={this.onRefresh}
onUpdateProgress={this.onUpdateProgress}
onOk={this.props.onOk}
onError={this.props.onError}
/>
</div>
</div>
);
}
}
FilePane.defaultProps = {
width: "100%",
colWidth: 20,
filterName: "",
onList: () => console.error("undefined"),
onOk: () => console.error("undefined"),
onError: () => console.error("undefined")
};

View file

@ -0,0 +1,250 @@
import React from "react";
import { Button } from "../control/button";
import { Input } from "../control/input";
import { getIcon, getIconColor } from "../display/icon";
import { AuthPane } from "./auth_pane";
import { rootSize } from "../../config";
let styleInfoBar = {
textAlign: "left",
color: "#999",
marginBottom: "1rem",
margin: "auto"
};
const styleContainer = {
padding: "0.5rem",
backgroundColor: "rgba(255, 255, 255, 0.5)"
};
const styleLeft = {
float: "left",
width: "50%",
heigth: "2rem"
};
const styleRight = {
float: "right",
width: "50%",
textAlign: "right",
heigth: "2rem"
};
const styleButtonLabel = {
verticalAlign: "middle"
};
const IconPlusCir = getIcon("pluscir");
const IconSearch = getIcon("search");
const clear = <div style={{ clear: "both" }} />;
export class InfoBar extends React.PureComponent {
constructor(props) {
super(props);
this.state = {
filterFileName: "",
fold: this.props.compact
};
}
onLogin = (serverAddr, adminId, adminPwd) => {
this.props.onLogin(serverAddr, adminId, adminPwd);
};
onLogout = serverAddr => {
this.props.onLogout(serverAddr);
};
onSearch = value => {
// TODO: need debounce
this.props.onSearch(value);
this.setState({ filterFileName: value });
};
onAddLocalFiles = () => {
return this.props.onAddLocalFiles().then(ok => {
if (ok) {
// TODO: need to add refresh
this.props.onOk("Local files are added, please refresh.");
} else {
this.props.onError("Fail to add local files");
}
});
};
onToggle = () => {
this.setState({ fold: !this.state.fold });
};
render() {
styleInfoBar = { ...styleInfoBar, width: this.props.width };
if (this.props.compact) {
const IconMore = getIcon("bars");
const menuIcon = (
<div style={{ backgroundColor: "rgba(255, 255, 255, 0.5)" }}>
<div>
<div style={{ float: "right" }}>
<Button
onClick={this.onToggle}
label={""}
// styleLabel={styleButtonLabel}
styleContainer={{
height: "2.5rem",
width: "2.5rem",
backgroundColor: "rgba(255, 255, 255, 0.2)",
margin: "0.5rem"
}}
styleDefault={{ height: "auto" }}
styleIcon={{
lineHeight: "1rem",
height: "1rem",
margin: "0.75rem",
display: "inline-block"
}}
icon={<IconMore size={rootSize} style={{ color: "#fff" }} />}
/>
</div>
<div style={{ float: "right" }}>
<Input
onChange={this.onSearch}
placeholder="Search..."
type="text"
value={this.state.filterFileName}
styleContainer={{
backgroundColor: "rgba(255, 255, 255, 0.5)",
margin: "0.5rem",
textAlign: "left"
}}
icon={<IconSearch size={16} />}
/>
</div>
<div style={{ clear: "both" }} />
</div>
</div>
);
const menuList = !this.state.fold ? (
<div style={styleContainer}>
<div>
<AuthPane
onLogin={this.onLogin}
onLogout={this.onLogout}
isLogin={this.props.isLogin}
serverAddr={this.props.serverAddr}
compact={this.props.compact}
/>
<Button
onClick={this.onAddLocalFiles}
label={"Scan Files"}
styleLabel={styleButtonLabel}
styleContainer={{
backgroundColor: "#2ecc71",
marginLeft: "0.5rem"
}}
styleDefault={{ color: "#fff" }}
icon={<IconPlusCir size={16} style={{ color: "#fff" }} />}
/>
</div>
</div>
) : (
<span />
);
const menu = (
<div>
{menuIcon}
{menuList}
</div>
);
return (
<div
className="info-bar"
style={{ ...styleInfoBar, textAlign: "right" }}
>
{this.props.isLogin ? (
menu
) : (
<AuthPane
onLogin={this.onLogin}
onLogout={this.onLogout}
isLogin={this.props.isLogin}
serverAddr={this.props.serverAddr}
styleContainer={{ textAlign: "left" }}
compact={this.props.compact}
/>
)}
<div>{this.props.children}</div>
</div>
);
}
const visitorPane = (
<AuthPane
onLogin={this.onLogin}
onLogout={this.onLogout}
isLogin={this.props.isLogin}
serverAddr={this.props.serverAddr}
compact={this.props.compact}
/>
);
const memberPane = (
<div>
<div style={styleLeft}>
<AuthPane
onLogin={this.onLogin}
onLogout={this.onLogout}
isLogin={this.props.isLogin}
serverAddr={this.props.serverAddr}
/>
<Button
onClick={this.onAddLocalFiles}
label={"Scan Files"}
styleLabel={styleButtonLabel}
styleContainer={{
backgroundColor: "#2ecc71",
marginLeft: "0.5rem"
}}
styleDefault={{ color: "#fff" }}
icon={<IconPlusCir size={16} style={{ color: "#fff" }} />}
/>
</div>
<div style={styleRight}>
<Input
onChange={this.onSearch}
placeholder="Search..."
type="text"
value={this.state.filterFileName}
styleContainer={{ backgroundColor: "rgba(255, 255, 255, 0.5)" }}
icon={<IconSearch size={16} />}
/>
</div>
{clear}
</div>
);
return (
<div className="info-bar" style={styleInfoBar}>
<div style={styleContainer}>
{this.props.isLogin ? memberPane : visitorPane}
</div>
<div>{this.props.children}</div>
</div>
);
}
}
InfoBar.defaultProps = {
compact: false,
width: "-1",
isLogin: false,
serverAddr: "",
onLogin: () => console.error("undefined"),
onLogout: () => console.error("undefined"),
onAddLocalFiles: () => console.error("undefined"),
onSearch: () => console.error("undefined"),
onOk: () => console.error("undefined"),
onError: () => console.error("undefined")
};

View file

@ -0,0 +1,214 @@
import React from "react";
import { getIcon, getIconColor } from "../display/icon";
const statusNull = "null";
const statusInfo = "info";
const statusWarn = "warn";
const statusError = "error";
const statusOk = "ok";
const statusStart = "start";
const statusEnd = "end";
const IconInfo = getIcon("infoCir");
const IconWarn = getIcon("exTri");
const IconError = getIcon("timesCir");
const IconOk = getIcon("checkCir");
const IconStart = getIcon("refresh");
const colorInfo = getIconColor("infoCir");
const colorWarn = getIconColor("exTri");
const colorError = getIconColor("timesCir");
const colorOk = getIconColor("checkCir");
const colorStart = getIconColor("refresh");
const classFadeIn = "log-fade-in";
const classHidden = "log-hidden";
const styleStr = `
.log .${classFadeIn} {
opacity: 1;
margin-left: 0.5rem;
padding: 0.25rem 0.5rem;
transition: opacity 0.3s, margin-left 0.3s, padding 0.3s;
}
.log .${classHidden} {
opacity: 0;
margin-left: 0rem;
padding: 0;
transition: opacity 0.3s, margin-left 0.3s, padding 0.3s;
}
.log a {
color: #2980b9;
transition: color 0.3s;
text-decoration: none;
}
.log a:hover {
color: #3498db;
transition: color 0.3s;
text-decoration: none;
}
`;
const wait = 5000;
const logSlotLen = 2;
const getEmptyLog = () => ({
className: classHidden,
msg: "",
status: statusNull
});
const getLogIcon = status => {
switch (status) {
case statusInfo:
return (
<IconInfo
size={16}
style={{ marginRight: "0.25rem", color: colorInfo }}
/>
);
case statusWarn:
return (
<IconWarn
size={16}
style={{ marginRight: "0.25rem", color: colorWarn }}
/>
);
case statusError:
return (
<IconError
size={16}
style={{ marginRight: "0.25rem", color: colorError }}
/>
);
case statusOk:
return (
<IconOk size={16} style={{ marginRight: "0.25rem", color: colorOk }} />
);
case statusStart:
return (
<IconStart
size={16}
className={"anm-rotate"}
style={{ marginRight: "0.25rem", color: colorStart }}
/>
);
case statusEnd:
return (
<IconOk size={16} style={{ marginRight: "0.25rem", color: colorOk }} />
);
default:
return <span />;
}
};
export class Log extends React.PureComponent {
constructor(props) {
super(props);
this.state = {
logs: Array(logSlotLen).fill(getEmptyLog())
};
this.id = 0;
}
genId = () => {
return this.id++ % logSlotLen;
};
addLog = (status, msg) => {
const id = this.genId();
const nextLogs = [
...this.state.logs.slice(0, id),
{
className: classFadeIn,
msg,
status
},
...this.state.logs.slice(id + 1)
];
this.setState({ logs: nextLogs });
this.delayClearLog(id);
return id;
};
delayClearLog = idToDel => {
setTimeout(this.clearLog, wait, idToDel);
};
clearLog = idToDel => {
// TODO: there may be race condition here
const nextLogs = [
...this.state.logs.slice(0, idToDel),
getEmptyLog(),
...this.state.logs.slice(idToDel + 1)
];
this.setState({ logs: nextLogs });
};
info = msg => {
this.addLog(statusInfo, msg);
};
warn = msg => {
this.addLog(statusWarn, msg);
};
error = msg => {
this.addLog(statusError, msg);
};
ok = msg => {
this.addLog(statusOk, msg);
};
start = msg => {
const id = this.genId();
const nextLogs = [
...this.state.logs.slice(0, id),
{
className: classFadeIn,
msg,
status: statusStart
},
...this.state.logs.slice(id + 1)
];
this.setState({ logs: nextLogs });
return id;
};
end = (startId, msg) => {
// remove start log
this.clearLog(startId);
this.addLog(statusEnd, msg);
};
render() {
const logList = Object.keys(this.state.logs).map(logId => {
return (
<span
key={logId}
style={this.props.styleLog}
className={this.state.logs[logId].className}
>
{getLogIcon(this.state.logs[logId].status)}
{this.state.logs[logId].msg}
</span>
);
});
return (
<span className={"log"} style={this.props.style}>
{logList}
<style>{styleStr}</style>
</span>
);
}
}
Log.defaultProps = {
style: {},
styleLog: {}
};

View file

@ -0,0 +1,32 @@
import React from "react";
import { AuthPane, classLogin, classLogout } from "../auth_pane";
describe("AuthPane", () => {
test("AuthPane should show login pane if isLogin === true, or show logout pane", () => {
const tests = [
{
input: {
onLogin: jest.fn,
onLogout: jest.fn,
isLogin: false,
serverAddr: ""
},
output: classLogin
},
{
input: {
onLogin: jest.fn,
onLogout: jest.fn,
isLogin: true,
serverAddr: ""
},
output: classLogout
}
];
tests.forEach(testCase => {
const pane = new AuthPane(testCase.input);
expect(pane.render().props.className).toBe(testCase.output);
});
});
});

View file

@ -0,0 +1,297 @@
jest.mock("../../../libs/api_share");
import React from "react";
import { mount } from "enzyme";
import { FileBoxDetail, classDelYes, classDelNo } from "../file_box_detail";
import { execFuncs, getDesc, verifyCalls } from "../../../tests/test_helper";
import valueEqual from "value-equal";
import {
del,
publishId,
shadowId,
setDownLimit
} from "../../../libs/api_share";
describe("FileBoxDetail", () => {
test("FileBoxDetail should show delete button by default, toggle using onComfirmDel and onCancelDel", () => {
const box = mount(<FileBoxDetail />);
expect(box.instance().state.showDelComfirm).toBe(false);
box.instance().onComfirmDel();
expect(box.instance().state.showDelComfirm).toBe(true);
box.instance().onCancelDel();
expect(box.instance().state.showDelComfirm).toBe(false);
});
});
describe("FileBoxDetail", () => {
const tests = [
{
init: {
id: "0",
name: "filename",
size: "1B",
modTime: 0,
href: "href",
downLimit: -1
},
execs: [
{
func: "onSetDownLimit",
args: [3]
}
],
state: {
downLimit: 3,
showDelComfirm: false
}
},
{
init: {
id: "0",
name: "filename",
size: "1B",
modTime: 0,
href: "href",
downLimit: -1
},
execs: [
{
func: "onComfirmDel",
args: []
}
],
state: {
downLimit: -1,
showDelComfirm: true
}
},
{
init: {
id: "0",
name: "filename",
size: "1B",
modTime: 0,
href: "href",
downLimit: -1
},
execs: [
{
func: "onComfirmDel",
args: []
},
{
func: "onCancelDel",
args: []
}
],
state: {
downLimit: -1,
showDelComfirm: false
}
},
{
init: {
id: "0",
name: "filename",
size: "1B",
modTime: 0,
href: "href",
downLimit: -1
},
execs: [
{
func: "onResetLink",
args: []
}
],
state: {
downLimit: -1,
showDelComfirm: false
},
calls: [
{
func: "onPublishId",
count: 1
},
{
func: "onOk",
count: 1
},
{
func: "onRefresh",
count: 1
}
]
},
{
init: {
id: "0",
name: "filename",
size: "1B",
modTime: 0,
href: "href",
downLimit: -1
},
execs: [
{
func: "onShadowLink",
args: []
}
],
state: {
downLimit: -1,
showDelComfirm: false
},
calls: [
{
func: "onShadowId",
count: 1
},
{
func: "onOk",
count: 1
},
{
func: "onRefresh",
count: 1
}
]
},
{
init: {
id: "0",
name: "filename",
size: "1B",
modTime: 0,
href: "href",
downLimit: -1
},
execs: [
{
func: "onUpdateDownLimit",
args: []
}
],
state: {
downLimit: -1,
showDelComfirm: false
},
calls: [
{
func: "onSetDownLimit",
count: 1
},
{
func: "onOk",
count: 1
},
{
func: "onRefresh",
count: 1
}
]
},
{
init: {
id: "0",
name: "filename",
size: "1B",
modTime: 0,
href: "href",
downLimit: -1
},
execs: [
{
func: "onDelete",
args: []
}
],
state: {
downLimit: -1,
showDelComfirm: false
},
calls: [
{
func: "onDel",
count: 1
},
{
func: "onOk",
count: 1
},
{
func: "onRefresh",
count: 1
}
]
}
];
tests.forEach(testCase => {
test(getDesc("FileBoxDetail", testCase), () => {
const stubs = {
onOk: jest.fn(),
onError: jest.fn(),
onRefresh: jest.fn(),
onDel: jest.fn(),
onPublishId: jest.fn(),
onShadowId: jest.fn(),
onSetDownLimit: jest.fn()
};
const stubWraps = {
onDel: () => {
stubs.onDel();
return del();
},
onPublishId: () => {
stubs.onPublishId();
return publishId();
},
onShadowId: () => {
stubs.onShadowId();
return shadowId();
},
onSetDownLimit: () => {
stubs.onSetDownLimit();
return setDownLimit();
}
};
return new Promise((resolve, reject) => {
const pane = mount(
<FileBoxDetail
id={testCase.init.id}
name={testCase.init.name}
size={testCase.init.size}
modTime={testCase.init.modTime}
href={testCase.init.href}
downLimit={testCase.init.downLimit}
onRefresh={stubs.onRefresh}
onOk={stubs.onOk}
onError={stubs.onError}
onDel={stubWraps.onDel}
onPublishId={stubWraps.onPublishId}
onShadowId={stubWraps.onShadowId}
onSetDownLimit={stubWraps.onSetDownLimit}
/>
);
execFuncs(pane.instance(), testCase.execs).then(() => {
pane.update();
if (!valueEqual(pane.instance().state, testCase.state)) {
return reject("FileBoxDetail: state not identical");
}
if (testCase.calls != null) {
const err = verifyCalls(testCase.calls, stubs);
if (err != null) {
return reject("FileBoxDetail: state not identical");
}
}
resolve();
});
});
});
});
});

View file

@ -0,0 +1,144 @@
jest.mock("../../../libs/api_share");
import React from "react";
import { FilePane } from "../file_pane";
import { mount } from "enzyme";
import * as mockApiShare from "../../../libs/api_share";
import { execFuncs, getDesc, verifyCalls } from "../../../tests/test_helper";
import valueEqual from "value-equal";
describe("FilePane", () => {
const tests = [
{
init: {
list: [{ Id: 0, PathLocal: "" }]
},
execs: [
{
func: "componentWillMount",
args: []
}
],
state: {
infos: [{ Id: 0, PathLocal: "" }],
showDetailId: -1
},
calls: [
{
func: "onList",
count: 2 // because componentWillMount will be callled twice
},
{
func: "onOk",
count: 2 // because componentWillMount will be callled twice
}
]
},
{
init: {
list: [{ Id: 0, PathLocal: "" }, { Id: 1, PathLocal: "" }]
},
execs: [
{
func: "componentWillMount",
args: []
},
{
func: "onUpdateProgressImp",
args: [0, "100%"]
}
],
state: {
infos: [
{ Id: 0, PathLocal: "", progress: "100%" },
{ Id: 1, PathLocal: "" }
],
showDetailId: -1
}
},
{
init: {
list: []
},
execs: [
{
func: "componentWillMount",
args: []
},
{
func: "onToggleDetail",
args: [0]
}
],
state: {
infos: [],
showDetailId: 0
}
},
{
init: {
list: []
},
execs: [
{
func: "onToggleDetail",
args: [0]
},
{
func: "onToggleDetail",
args: [0]
}
],
state: {
infos: [],
showDetailId: -1
}
}
];
tests.forEach(testCase => {
test(getDesc("FilePane", testCase), () => {
// mock list()
mockApiShare.__truncInfos();
mockApiShare.__addInfos(testCase.init.list);
const stubs = {
onList: jest.fn(),
onOk: jest.fn(),
onError: jest.fn()
};
const stubWraps = {
onListWrap: () => {
stubs.onList();
return mockApiShare.list();
}
};
return new Promise((resolve, reject) => {
const pane = mount(
<FilePane
onList={stubWraps.onListWrap}
onOk={stubs.onOk}
onError={stubs.onError}
/>
);
execFuncs(pane.instance(), testCase.execs).then(() => {
pane.update();
if (!valueEqual(pane.instance().state, testCase.state)) {
return reject("FilePane: state not identical");
}
if (testCase.calls != null) {
const err = verifyCalls(testCase.calls, stubs);
if (err != null) {
return reject(err);
}
}
resolve();
});
});
});
});
});

View file

@ -0,0 +1,136 @@
jest.mock("../../../libs/api_share");
jest.mock("../../../libs/api_auth");
import React from "react";
import { InfoBar } from "../info_bar";
import { mount } from "enzyme";
import * as mockApiShare from "../../../libs/api_share";
import { execFuncs, getDesc, verifyCalls } from "../../../tests/test_helper";
import valueEqual from "value-equal";
describe("InfoBar", () => {
const tests = [
{
execs: [
{
func: "onSearch",
args: ["searchFileName"]
}
],
state: {
filterFileName: "searchFileName",
fold: false
},
calls: [
{
func: "onSearch",
count: 1
}
]
},
{
execs: [
{
func: "onLogin",
args: ["serverAddr", "adminId", "adminPwd"]
}
],
state: {
filterFileName: "",
fold: false
},
calls: [
{
func: "onLogin",
count: 1
}
]
},
{
execs: [
{
func: "onLogout",
args: ["serverAddr"]
}
],
state: {
filterFileName: "",
fold: false
},
calls: [
{
func: "onLogout",
count: 1
}
]
},
{
execs: [
{
func: "onAddLocalFiles",
args: []
}
],
state: {
filterFileName: "",
fold: false
},
calls: [
{
func: "onAddLocalFiles",
count: 1
}
]
}
];
tests.forEach(testCase => {
test(getDesc("InfoBar", testCase), () => {
const stubs = {
onLogin: jest.fn(),
onLogout: jest.fn(),
onAddLocalFiles: jest.fn(),
onSearch: jest.fn(),
onOk: jest.fn(),
onError: jest.fn()
};
const onAddLocalFilesWrap = () => {
stubs.onAddLocalFiles();
return Promise.resolve(true);
};
return new Promise((resolve, reject) => {
const infoBar = mount(
<InfoBar
width="100%"
isLogin={false}
serverAddr=""
onLogin={stubs.onLogin}
onLogout={stubs.onLogout}
onAddLocalFiles={onAddLocalFilesWrap}
onSearch={stubs.onSearch}
onOk={stubs.onOk}
onError={stubs.onError}
/>
);
execFuncs(infoBar.instance(), testCase.execs)
.then(() => {
infoBar.update();
if (!valueEqual(infoBar.instance().state, testCase.state)) {
return reject("state not identical");
}
if (testCase.calls != null) {
const err = verifyCalls(testCase.calls, stubs);
if (err !== null) {
return reject(err);
}
}
resolve();
})
.catch(err => console.error(err));
});
});
});
});

View file

@ -0,0 +1,51 @@
import React from "react";
import { mount } from "enzyme";
import { checkQueueCycle, Uploader } from "../uploader";
const testTimeout = 4000;
describe("Uploader", () => {
test(
"Uploader will upload files in uploadQueue by interval",
() => {
// TODO: could be refactored using timer mocks
// https://facebook.github.io/jest/docs/en/timer-mocks.html
const tests = [
{
input: { target: { files: ["task1", "task2", "task3"] } },
uploadCalled: 3
}
];
let promises = [];
const uploader = mount(<Uploader />);
tests.forEach(testCase => {
// mock
const uploadSpy = jest.fn();
const uploadStub = () => {
uploadSpy();
return Promise.resolve();
};
uploader.instance().upload = uploadStub;
uploader.update();
// upload and verify
uploader.instance().onUpload(testCase.input);
const wait = testCase.input.target.files.length * 1000 + 100;
const promise = new Promise(resolve => {
setTimeout(() => {
expect(uploader.instance().state.uploadQueue.length).toBe(0);
expect(uploadSpy.mock.calls.length).toBe(testCase.uploadCalled);
resolve();
}, wait);
});
promises = [...promises, promise];
});
return Promise.all(promises);
},
testTimeout
);
});

View file

@ -0,0 +1,69 @@
import React from "react";
import { config } from "../../config";
import { Grids } from "../layout/grids";
const styleTitle = {
color: "#fff",
backgroundColor: "rgba(0, 0, 0, 0.4)",
display: "inline-block",
padding: "0.5rem 1rem",
fontSize: "1rem",
margin: "2rem 0 0.5rem 0",
lineHeight: "1rem",
height: "1rem"
};
export class TimeGrids extends React.PureComponent {
render() {
const groups = new Map();
this.props.items.forEach(item => {
const date = new Date(item.timestamp);
const key = `${date.getFullYear()}-${date.getMonth() +
1}-${date.getDate()}`;
if (groups.has(key)) {
groups.set(key, [...groups.get(key), item]);
} else {
groups.set(key, [item]);
}
});
var timeGrids = [];
groups.forEach((gridGroup, groupKey) => {
const year = parseInt(groupKey.split("-")[0]);
const month = parseInt(groupKey.split("-")[1]);
const date = parseInt(groupKey.split("-")[2]);
const sortedGroup = gridGroup.sort((item1, item2) => {
return item2.timestamp - item1.timestamp;
});
timeGrids = [
...timeGrids,
<div key={year * 365 + month * 30 + date}>
<div style={styleTitle}>
<span>{groupKey}</span>
</div>
<Grids nodes={sortedGroup} />
</div>
];
});
const sortedGroups = timeGrids.sort((group1, group2) => {
return group2.key - group1.key;
});
return <div style={this.props.styleContainer}>{sortedGroups}</div>;
}
}
TimeGrids.defaultProps = {
items: [
{
key: "",
timestamp: -1,
component: <span>no grid found</span>
}
],
styleContainer: {}
};

View file

@ -0,0 +1,215 @@
import React from "react";
import ReactDOM from "react-dom";
import { config } from "../../config";
import { Button } from "../control/button";
import { getIcon } from "../display/icon";
import { FileUploader } from "../../libs/api_upload";
const msgFileNotFound = "File not found";
const msgFileUploadOk = "is uploaded";
const msgChromeLink = "https://www.google.com/chrome/";
const msgFirefoxLink = "https://www.mozilla.org/";
export const checkQueueCycle = 1000;
const IconPlus = getIcon("cirUp");
const IconThiList = getIcon("thList");
const styleContainer = {
position: "fixed",
bottom: "0.5rem",
margin: "auto",
zIndex: 1
};
const styleButtonContainer = {
backgroundColor: "#2ecc71",
width: "20rem",
height: "auto",
textAlign: "center"
};
const styleDefault = {
color: "#fff"
};
const styleLabel = {
display: "inline-block",
verticalAlign: "middle",
marginLeft: "0.5rem"
};
const styleUploadQueue = {
backgroundColor: "#000",
opacity: 0.85,
color: "#fff",
fontSize: "0.75rem",
lineHeight: "1.25rem"
};
const styleUploadItem = {
width: "18rem",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
padding: "0.5rem 1rem"
};
const styleUnsupported = {
backgroundColor: "#e74c3c",
color: "#fff",
overflow: "hidden",
padding: "0.5rem 1rem",
width: "18rem",
textAlign: "center"
};
const styleStr = `
a {
color: white;
margin: auto 0.5rem auto 0.5rem;
}
`;
export class Uploader extends React.PureComponent {
constructor(props) {
super(props);
this.state = {
uploadQueue: [],
uploadValue: ""
};
this.input = undefined;
this.assignInput = input => {
this.input = ReactDOM.findDOMNode(input);
};
}
componentDidMount() {
// will polling uploadQueue like a worker
this.checkQueue();
}
checkQueue = () => {
// TODO: using web worker to avoid lagging UI
if (this.state.uploadQueue.length > 0) {
this.upload(this.state.uploadQueue[0]).then(() => {
this.setState({ uploadQueue: this.state.uploadQueue.slice(1) });
setTimeout(this.checkQueue, checkQueueCycle);
});
} else {
setTimeout(this.checkQueue, checkQueueCycle);
}
};
upload = file => {
const fileUploader = new FileUploader(
this.onStart,
this.onProgress,
this.onFinish,
this.onError
);
return fileUploader.uploadFile(file);
};
onStart = () => {
this.props.onRefresh();
};
onProgress = (shareId, progress) => {
this.props.onUpdateProgress(shareId, progress);
};
onFinish = () => {
this.props.onRefresh();
};
onError = err => {
this.props.onError(err);
};
onUpload = event => {
if (event.target.files == null || event.target.files.length === 0) {
this.props.onError(msgFileNotFound);
this.setState({ uploadValue: "" });
} else {
this.setState({
uploadQueue: [...this.state.uploadQueue, ...event.target.files],
uploadValue: ""
});
}
};
onChooseFile = () => {
this.input.click();
};
render() {
if (
window.FormData == null ||
window.FileReader == null ||
window.Blob == null
) {
return (
<div style={{ ...styleUnsupported, ...styleContainer }}>
Unsupported Browser. Try
<a href={msgFirefoxLink} target="_blank">
Firefox
</a>
or
<a href={msgChromeLink} target="_blank">
Chrome
</a>
<style>{styleStr}</style>
</div>
);
}
const hiddenInput = (
<input
type="file"
onChange={this.onUpload}
style={{ display: "none" }}
ref={this.assignInput}
multiple={true}
value={this.state.uploadValue}
/>
);
const uploadQueue = this.state.uploadQueue.map(file => {
return (
<div key={file.name} style={styleUploadItem}>
<IconThiList
size={config.rootSize * 0.75}
style={{ marginRight: "0.5rem" }}
/>
{file.name}
</div>
);
});
return (
<div className="uploader" style={styleContainer}>
<div style={styleUploadQueue}>{uploadQueue}</div>
<Button
onClick={this.onChooseFile}
label="UPLOAD"
icon={<IconPlus size={config.rootSize} style={styleDefault} />}
styleDefault={styleDefault}
styleContainer={styleButtonContainer}
styleLabel={styleLabel}
/>
{hiddenInput}
</div>
);
}
}
Uploader.defaultProps = {
onRefresh: () => console.error("undefined"),
onUpdateProgress: () => console.error("undefined"),
onOk: () => console.error("undefined"),
onError: () => console.error("undefined")
};