!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

3
client/.babelrc Normal file
View file

@ -0,0 +1,3 @@
{
"presets": ["env", "react", "stage-0", "stage-2"]
}

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

View file

@ -0,0 +1,102 @@
import React from "react";
const buttonClassName = "btn";
const styleContainer = {
display: "inline-block",
height: "2.5rem"
};
const styleIcon = {
lineHeight: "2.5rem",
height: "2.5rem",
margin: "0 -0.25rem 0 0.5rem"
};
const styleBase = {
background: "transparent",
lineHeight: "2.5rem",
fontSize: "0.875rem",
border: "none",
outline: "none",
padding: "0 0.75rem",
textAlign: "center"
};
const styleDefault = {
...styleBase
};
const styleStr = `
.${buttonClassName}:hover {
opacity: 0.7;
transition: opacity 0.25s;
}
.${buttonClassName}:active {
opacity: 0.7;
transition: opacity 0.25s;
}
.${buttonClassName}:disabled {
opacity: 0.2;
transition: opacity 0.25s;
}
button::-moz-focus-inner {
border: 0;
}
`;
export class Button extends React.PureComponent {
constructor(props) {
super(props);
this.styleDefault = { ...styleDefault, ...this.props.styleDefault };
this.styleStr = this.props.styleStr ? this.props.styleStr : styleStr;
}
onClick = e => {
if (this.props.onClick && this.props.isEnabled) {
this.props.onClick(e);
}
};
render() {
const style = this.props.isEnabled ? this.styleDefault : this.styleDisabled;
const icon =
this.props.icon != null ? (
<span style={{ ...styleIcon, ...this.props.styleIcon }}>
{this.props.icon}
</span>
) : (
<span />
);
return (
<div
style={{ ...styleContainer, ...this.props.styleContainer }}
className={`${buttonClassName} ${this.props.className}`}
onClick={this.onClick}
>
{icon}
<button style={style}>
<span style={this.props.styleLabel}>{this.props.label}</span>
<style>{this.styleStr}</style>
</button>
</div>
);
}
}
Button.defaultProps = {
className: "btn",
isEnabled: true,
icon: null,
onClick: () => true,
styleContainer: {},
styleDefault: {},
styleDisabled: {},
styleLabel: {},
styleIcon: {},
styleStr: undefined
};

View file

@ -0,0 +1,128 @@
import React from "react";
const styleContainer = {
backgroundColor: "#ccc",
display: "inline-block",
height: "2.5rem"
};
const styleIcon = {
lineHeight: "2.5rem",
height: "2.5rem",
margin: "0 0.25rem 0 0.5rem"
};
const styleInputBase = {
backgroundColor: "transparent",
border: "none",
display: "inline-block",
fontSize: "0.875rem",
height: "2.5rem",
lineHeight: "2.5rem",
outline: "none",
overflowY: "hidden",
padding: "0 0.75rem",
verticalAlign: "middle"
};
const styleDefault = {
...styleInputBase,
color: "#333"
};
const styleInvalid = {
...styleInputBase,
color: "#e74c3c"
};
const inputClassName = "qs-input";
const styleStr = `
.${inputClassName}:hover {
// box-shadow: 0px 0px -5px rgba(0, 0, 0, 1);
opacity: 0.7;
transition: opacity 0.25s;
}
.${inputClassName}:active {
// box-shadow: 0px 0px -5px rgba(0, 0, 0, 1);
opacity: 0.7;
transition: opacity 0.25s;
}
.${inputClassName}:disabled {
color: #ccc;
}
`;
export class Input extends React.PureComponent {
constructor(props) {
super(props);
this.state = { isValid: true };
this.inputRef = undefined;
}
onChange = e => {
this.props.onChange(e.target.value);
this.props.onChangeEvent(e);
this.props.onChangeTarget(e.target);
this.setState({ isValid: this.props.validate(e.target.value) });
};
getRef = input => {
this.inputRef = input;
this.props.inputRef(this.inputRef);
};
render() {
const style = this.state.isValid ? styleDefault : styleInvalid;
const icon =
this.props.icon != null ? (
<span style={styleIcon}>{this.props.icon}</span>
) : (
<span />
);
return (
<div style={{ ...styleContainer, ...this.props.styleContainer }}>
{icon}
<input
style={{
...styleDefault,
...this.props.style,
width: this.props.width
}}
className={`${inputClassName} ${this.props.className}`}
disabled={this.props.disabled}
readOnly={this.props.readOnly}
maxLength={this.props.maxLength}
placeholder={this.props.placeholder}
type={this.props.type}
onChange={this.onChange}
value={this.props.value}
ref={this.getRef}
/>
<style>{styleStr}</style>
</div>
);
}
}
Input.defaultProps = {
className: "input",
maxLength: "32",
placeholder: "placeholder",
readOnly: false,
style: {},
styleContainer: {},
styleInvalid: {},
type: "text",
disabled: false,
width: "auto",
value: "",
icon: null,
onChange: () => true,
onChangeEvent: () => true,
onChangeTarget: () => true,
validate: () => true,
inputRef: () => true
};

View file

@ -0,0 +1,177 @@
const IconFile = require("react-icons/lib/fa/file-o");
const IconImg = require("react-icons/lib/md/image");
const IconZip = require("react-icons/lib/md/archive");
const IconVideo = require("react-icons/lib/md/ondemand-video");
const IconAudio = require("react-icons/lib/md/music-video");
const IconText = require("react-icons/lib/md/description");
const IconExcel = require("react-icons/lib/fa/file-excel-o");
const IconPPT = require("react-icons/lib/fa/file-powerpoint-o");
const IconPdf = require("react-icons/lib/md/picture-as-pdf");
const IconWord = require("react-icons/lib/fa/file-word-o");
const IconCode = require("react-icons/lib/md/code");
const IconApk = require("react-icons/lib/md/android");
const IconExe = require("react-icons/lib/fa/cog");
const IconBars = require("react-icons/lib/fa/bars");
const IconSpinner = require("react-icons/lib/md/autorenew");
const IconCirUp = require("react-icons/lib/fa/arrow-circle-up");
const IconSignIn = require("react-icons/lib/fa/sign-in");
const IconSignOut = require("react-icons/lib/fa/sign-out");
const IconAngUp = require("react-icons/lib/fa/angle-up");
const IconAngRight = require("react-icons/lib/fa/angle-right");
const IconAngDown = require("react-icons/lib/fa/angle-down");
const IconAngLeft = require("react-icons/lib/fa/angle-left");
const IconTimesCir = require("react-icons/lib/md/cancel");
const IconPlusSqu = require("react-icons/lib/md/add-box");
const IconPlusCir = require("react-icons/lib/fa/plus-circle");
const IconPlus = require("react-icons/lib/md/add");
const IconSearch = require("react-icons/lib/fa/search");
const IconThList = require("react-icons/lib/fa/th-list");
const IconCalendar = require("react-icons/lib/fa/calendar-o");
const IconCheckCir = require("react-icons/lib/fa/check-circle");
const IconExTri = require("react-icons/lib/fa/exclamation-triangle");
const IconInfoCir = require("react-icons/lib/fa/info-circle");
const IconRefresh = require("react-icons/lib/fa/refresh");
const fileTypeIconMap = {
// text
txt: { icon: IconText, color: "#333" },
rtf: { icon: IconText, color: "#333" },
htm: { icon: IconText, color: "#333" },
html: { icon: IconText, color: "#333" },
xml: { icon: IconText, color: "#333" },
yml: { icon: IconText, color: "#333" },
json: { icon: IconText, color: "#333" },
toml: { icon: IconText, color: "#333" },
md: { icon: IconText, color: "#333" },
// office
ppt: { icon: IconPPT, color: "#e67e22" },
pptx: { icon: IconPPT, color: "#e67e22" },
xls: { icon: IconExcel, color: "#16a085" },
xlsx: { icon: IconExcel, color: "#16a085" },
xlsm: { icon: IconExcel, color: "#16a085" },
doc: { icon: IconWord, color: "#2980b9" },
docx: { icon: IconWord, color: "#2980b9" },
docx: { icon: IconWord, color: "#2980b9" },
pdf: { icon: IconPdf, color: "#c0392b" },
// code
c: { icon: IconCode, color: "#666" },
cpp: { icon: IconCode, color: "#666" },
java: { icon: IconCode, color: "#666" },
js: { icon: IconCode, color: "#666" },
py: { icon: IconCode, color: "#666" },
pyc: { icon: IconCode, color: "#666" },
rb: { icon: IconCode, color: "#666" },
php: { icon: IconCode, color: "#666" },
go: { icon: IconCode, color: "#666" },
sh: { icon: IconCode, color: "#666" },
vb: { icon: IconCode, color: "#666" },
sql: { icon: IconCode, color: "#666" },
r: { icon: IconCode, color: "#666" },
swift: { icon: IconCode, color: "#666" },
oc: { icon: IconCode, color: "#666" },
// misc
apk: { icon: IconApk, color: "#2ecc71" },
exe: { icon: IconExe, color: "#333" },
deb: { icon: IconExe, color: "#333" },
rpm: { icon: IconExe, color: "#333" },
// img
bmp: { icon: IconImg, color: "#1abc9c" },
gif: { icon: IconImg, color: "#1abc9c" },
jpg: { icon: IconImg, color: "#1abc9c" },
jpeg: { icon: IconImg, color: "#1abc9c" },
tiff: { icon: IconImg, color: "#1abc9c" },
psd: { icon: IconImg, color: "#1abc9c" },
png: { icon: IconImg, color: "#1abc9c" },
svg: { icon: IconImg, color: "#1abc9c" },
pcx: { icon: IconImg, color: "#1abc9c" },
dxf: { icon: IconImg, color: "#1abc9c" },
wmf: { icon: IconImg, color: "#1abc9c" },
emf: { icon: IconImg, color: "#1abc9c" },
eps: { icon: IconImg, color: "#1abc9c" },
tga: { icon: IconImg, color: "#1abc9c" },
// compress
gz: { icon: IconZip, color: "#34495e" },
zip: { icon: IconZip, color: "#34495e" },
"7z": { icon: IconZip, color: "#34495e" },
rar: { icon: IconZip, color: "#34495e" },
tar: { icon: IconZip, color: "#34495e" },
gzip: { icon: IconZip, color: "#34495e" },
cab: { icon: IconZip, color: "#34495e" },
uue: { icon: IconZip, color: "#34495e" },
arj: { icon: IconZip, color: "#34495e" },
bz2: { icon: IconZip, color: "#34495e" },
lzh: { icon: IconZip, color: "#34495e" },
jar: { icon: IconZip, color: "#34495e" },
ace: { icon: IconZip, color: "#34495e" },
iso: { icon: IconZip, color: "#34495e" },
z: { icon: IconZip, color: "#34495e" },
// video
asf: { icon: IconVideo, color: "#f39c12" },
avi: { icon: IconVideo, color: "#f39c12" },
flv: { icon: IconVideo, color: "#f39c12" },
mkv: { icon: IconVideo, color: "#f39c12" },
mov: { icon: IconVideo, color: "#f39c12" },
mp4: { icon: IconVideo, color: "#f39c12" },
mpeg: { icon: IconVideo, color: "#f39c12" },
mpg: { icon: IconVideo, color: "#f39c12" },
ram: { icon: IconVideo, color: "#f39c12" },
rmvb: { icon: IconVideo, color: "#f39c12" },
qt: { icon: IconVideo, color: "#f39c12" },
wmv: { icon: IconVideo, color: "#f39c12" },
// audio
cda: { icon: IconAudio, color: "#d35400" },
cmf: { icon: IconAudio, color: "#d35400" },
mid: { icon: IconAudio, color: "#d35400" },
mp1: { icon: IconAudio, color: "#d35400" },
mp2: { icon: IconAudio, color: "#d35400" },
mp3: { icon: IconAudio, color: "#d35400" },
rm: { icon: IconAudio, color: "#d35400" },
rmi: { icon: IconAudio, color: "#d35400" },
vqf: { icon: IconAudio, color: "#d35400" },
wav: { icon: IconAudio, color: "#d35400" }
};
const fileIconMap = {
...fileTypeIconMap,
// other
spinner: { icon: IconSpinner, color: "#1abc9c" },
cirup: { icon: IconCirUp, color: "#fff" },
signin: { icon: IconSignIn, color: "#fff" },
signout: { icon: IconSignOut, color: "#fff" },
angup: { icon: IconAngUp, color: "#2c3e50" },
angright: { icon: IconAngRight, color: "#2c3e50" },
angdown: { icon: IconAngDown, color: "#2c3e50" },
angleft: { icon: IconAngLeft, color: "#2c3e50" },
timescir: { icon: IconTimesCir, color: "#c0392b" },
plussqu: { icon: IconPlusSqu, color: "#2ecc71" },
pluscir: { icon: IconPlusCir, color: "#2ecc71" },
plus: { icon: IconPlus, color: "#2ecc71" },
search: { icon: IconSearch, color: "#ccc" },
checkcir: { icon: IconCheckCir, color: "#27ae60" },
extri: { icon: IconExTri, color: "#f39c12" },
infocir: { icon: IconInfoCir, color: "#2c3e50" },
refresh: { icon: IconRefresh, color: "#8e44ad" },
thlist: { icon: IconThList, color: "#fff" },
bars: { icon: IconBars, color: "#666" },
calendar: { icon: IconCalendar, color: "#333" }
};
export const getIcon = extend => {
if (fileIconMap[extend.toUpperCase()]) {
return fileIconMap[extend.toUpperCase()].icon;
} else if (fileIconMap[extend.toLowerCase()]) {
return fileIconMap[extend.toLowerCase()].icon;
}
return IconFile;
};
export const getIconColor = extend => {
if (fileIconMap[extend.toUpperCase()]) {
return fileIconMap[extend.toUpperCase()].color;
} else if (fileIconMap[extend.toLowerCase()]) {
return fileIconMap[extend.toLowerCase()].color;
}
return "#333";
};

View file

@ -0,0 +1,27 @@
import React from "react";
const styleGridBase = {
float: "left",
margin: 0
};
export const Grids = props => (
<div style={props.containerStyle}>
{props.nodes.map(node => (
<div
className="grid"
key={node.key}
style={{ ...props.gridStyle, ...node.style }}
>
{node.component}
</div>
))}
<div style={{ clear: "both" }} />
</div>
);
Grids.defaultProps = {
nodes: [{ key: "key", component: <span />, style: {} }],
gridStyle: styleGridBase,
containerStyle: {}
};

7
client/config.js Normal file
View file

@ -0,0 +1,7 @@
export const config = {
serverAddr: "",
testId: "admin",
testPwd: "quicksh@re",
rootSize: 16,
colWidth: 20
};

View file

@ -0,0 +1,7 @@
export function login(serverAddr, adminId, adminPwd, axiosConfig) {
return Promise.resolve(true);
}
export function logout(serverAddr, axiosConfig) {
return Promise.resolve(true);
}

View file

@ -0,0 +1,41 @@
let _infos = [];
const shadowedId = "shadowedId";
const publicId = "publicId";
export function __addInfos(infos) {
_infos = [..._infos, ...infos];
}
export function __truncInfos(info) {
_infos = [];
}
export const del = shareId => {
_infos = _infos.filter(info => {
return !info.shareId == shareId;
});
return Promise.resolve(true);
};
export const list = () => {
return Promise.resolve(_infos);
};
export const shadowId = shareId => {
return Promise.resolve(shadowedId);
};
export const publishId = shareId => {
return Promise.resolve(publicId);
};
export const setDownLimit = (shareId, downLimit) => {
_infos = _infos.map(info => {
return info.shareId == shareId ? { ...info, downLimit } : info;
});
return Promise.resolve(true);
};
export const addLocalFiles = () => {
return Promise.resolve(true);
};

29
client/libs/api_auth.js Normal file
View file

@ -0,0 +1,29 @@
import axios from "axios";
import { config } from "../config";
import { makePostBody } from "./utils";
export function login(serverAddr, adminId, adminPwd, axiosConfig) {
return axios
.post(
`${serverAddr}/login`,
makePostBody(
{
act: "login",
adminid: adminId,
adminpwd: adminPwd
},
axiosConfig
)
)
.then(response => {
return response.data.Code === 200;
});
}
export function logout(serverAddr, axiosConfig) {
return axios
.post(`${serverAddr}/login`, makePostBody({ act: "logout" }), axiosConfig)
.then(response => {
return response.data.Code === 200;
});
}

51
client/libs/api_share.js Normal file
View file

@ -0,0 +1,51 @@
import axios from "axios";
import { config } from "../config";
export const del = shareId => {
return axios
.delete(`${config.serverAddr}/fileinfo?shareid=${shareId}`)
.then(response => response.data.Code === 200);
};
export const list = () => {
return axios.get(`${config.serverAddr}/fileinfo`).then(response => {
// TODO check status code
return response.data.List;
});
};
export const shadowId = shareId => {
const act = "shadowid";
return axios
.patch(`${config.serverAddr}/fileinfo?act=${act}&shareid=${shareId}`)
.then(response => {
return response.data.ShareId;
});
};
export const publishId = shareId => {
const act = "publishid";
return axios
.patch(`${config.serverAddr}/fileinfo?act=${act}&shareid=${shareId}`)
.then(response => {
return response.data.ShareId;
});
};
export const setDownLimit = (shareId, downLimit) => {
const act = "setdownlimit";
return axios
.patch(
`${
config.serverAddr
}/fileinfo?act=${act}&shareid=${shareId}&downlimit=${downLimit}`
)
.then(response => response.data.Code === 200);
};
export const addLocalFiles = () => {
const act = "addlocalfiles";
return axios
.patch(`${config.serverAddr}/fileinfo?act=${act}`)
.then(response => response.data.Code === 200);
};

202
client/libs/api_upload.js Normal file
View file

@ -0,0 +1,202 @@
import axios from "axios";
import { config } from "../config";
import { makePostBody } from "./utils";
const wait = 5000; // TODO: should tune according to backend
const retryMax = 100000;
const maxUploadLen = 20 * 1024 * 1024;
// TODO: add to react-intl
const msgUploadFailed = "Fail to upload, upload is stopped.";
const msgUploadFailedAndRetry = "Fail to upload, retrying...";
const msgFileExists = "File exists.";
const msgTooBigChunk = "Too big chunk.";
const msgFileNotFound = "File not found, upload stopped.";
function randomWait() {
return Math.random() * wait;
}
function isKnownErr(res) {
return res != null && res.Code != null && res.Msg != null;
}
export class FileUploader {
constructor(onStart, onProgress, onFinish, onError) {
this.onStart = onStart;
this.onProgress = onProgress;
this.onFinish = onFinish;
this.onError = onError;
this.retry = retryMax;
this.reader = new FileReader();
this.uploadFile = file => {
return this.startUpload(file);
};
this.startUpload = file => {
return axios
.post(
`${config.serverAddr}/startupload`,
makePostBody({
fname: file.name
})
)
.then(response => {
if (
response.data.ShareId == null ||
response.data.Start === null ||
response.data.Length === null
) {
throw response;
} else {
this.onStart(response.data.ShareId, file.name);
return this.upload(
{
shareId: response.data.ShareId,
start: response.data.Start,
length: response.data.Length
},
file
);
}
})
.catch(response => {
// TODO: this is not good because error may not be response
if (isKnownErr(response.data) && response.data.Code === 429) {
setTimeout(this.startUpload, randomWait(), file);
} else if (isKnownErr(response.data) && response.data.Code === 412) {
this.onError(msgFileExists);
} else if (isKnownErr(response.data) && response.data.Code === 404) {
this.onError(msgFileNotFound);
} else if (this.retry > 0) {
this.retry--;
this.onError(msgUploadFailedAndRetry);
console.trace(response);
setTimeout(this.startUpload, randomWait(), file);
} else {
this.onError(msgUploadFailed);
console.trace(response);
}
});
};
this.prepareReader = (shareInfo, end, resolve, reject) => {
this.reader.onerror = err => {
reject(err);
};
this.reader.onloadend = event => {
const formData = new FormData();
formData.append("shareid", shareInfo.shareId);
formData.append("start", shareInfo.start);
formData.append("len", end - shareInfo.start);
formData.append("chunk", new Blob([event.target.result]));
const url = `${config.serverAddr}/upload`;
const headers = {
"Content-Type": "multipart/form-data"
};
try {
axios
.post(url, formData, { headers })
.then(response => resolve(response))
.catch(err => {
reject(err);
});
} catch (err) {
reject(err);
}
};
};
this.upload = (shareInfo, file) => {
const uploaded = shareInfo.start + shareInfo.length;
const end = uploaded < file.size ? uploaded : file.size;
return new Promise((resolve, reject) => {
if (
end == null ||
shareInfo.start == null ||
end - shareInfo.start >= maxUploadLen
) {
throw new Error(msgTooBigChunk);
}
const chunk = file.slice(shareInfo.start, end);
this.prepareReader(shareInfo, end, resolve, reject);
this.reader.readAsArrayBuffer(chunk);
})
.then(response => {
if (
response.data.ShareId == null ||
response.data.Start == null ||
response.data.Length == null ||
response.data.Start !== end
) {
throw response;
} else {
if (end < file.size) {
this.onProgress(shareInfo.shareId, end / file.size);
return this.upload(
{
shareId: shareInfo.shareId,
start: shareInfo.start + shareInfo.length,
length: shareInfo.length
},
file
);
} else {
return this.finishUpload(shareInfo);
}
}
})
.catch(response => {
// possible error: response.data.Start == null || response.data.Start !== end
if (isKnownErr(response.data) && response.data.Code === 429) {
setTimeout(this.upload, randomWait(), shareInfo, file);
} else if (isKnownErr(response.data) && response.data.Code === 404) {
this.onError(msgFileNotFound);
} else if (this.retry > 0) {
this.retry--;
setTimeout(this.upload, randomWait(), shareInfo, file);
this.onError(msgUploadFailedAndRetry);
console.trace(response);
} else {
this.onError(msgUploadFailed);
console.trace(response);
}
});
};
this.finishUpload = shareInfo => {
return axios
.post(`${config.serverAddr}/finishupload?shareid=${shareInfo.shareId}`)
.then(response => {
// TODO: should check Code instead of Url
if (response.data.ShareId != null && response.data.Start == null) {
this.onFinish();
return response.data.ShareId;
} else {
throw response;
}
})
.catch(response => {
if (isKnownErr(response.data) && response.data.Code === 429) {
setTimeout(this.finishUpload, randomWait(), shareInfo);
} else if (isKnownErr(response.data) && response.data.Code === 404) {
this.onError(msgFileNotFound);
} else if (this.retry > 0) {
this.retry--;
setTimeout(this.finishUpload, randomWait(), shareInfo);
this.onError(msgUploadFailedAndRetry);
console.trace(response);
} else {
this.onError(msgUploadFailed);
console.trace(response);
}
});
};
}
}

18
client/libs/file_type.js Normal file
View file

@ -0,0 +1,18 @@
const fileTypeMap = {
jpg: "image",
jpeg: "image",
png: "image",
bmp: "image",
gz: "archive",
mov: "video",
mp4: "video",
mov: "video",
avi: "video"
};
export const getFileExt = fileName => fileName.split(".").pop();
export const getFileType = fileName => {
const ext = getFileExt(fileName);
return fileTypeMap[ext] != null ? fileTypeMap[ext] : "file";
};

View file

@ -0,0 +1,34 @@
import { login, logout } from "../api_auth";
import { config } from "../../config";
const serverAddr = config.serverAddr;
const testId = config.testId;
const testPwd = config.testPwd;
export function testAuth() {
return testLogin()
.then(testLogout)
.catch(err => {
console.error("auth: fail", err);
});
}
export function testLogin() {
return login(serverAddr, testId, testPwd).then(ok => {
if (ok === true) {
console.log("login api: ok");
} else {
throw new Error("login api: failed");
}
});
}
export function testLogout() {
return logout(serverAddr).then(ok => {
if (ok === true) {
console.log("logout api: ok");
} else {
throw new Error("logout api: failed");
}
});
}

View file

@ -0,0 +1,167 @@
import { FileUploader } from "../api_upload";
import {
del,
list,
shadowId,
publishId,
setDownLimit,
addLocalFiles
} from "../api_share";
import { testLogin, testLogout } from "./api_auth_test";
const fileName = "filename";
function upload(fileName) {
return new Promise(resolve => {
const onStart = () => true;
const onProgress = () => true;
const onFinish = () => resolve();
const onError = err => {
throw new Error(JSON.stringify(err));
};
const file = new File(["foo"], fileName, {
type: "text/plain"
});
const uploader = new FileUploader(onStart, onProgress, onFinish, onError);
uploader.uploadFile(file);
});
}
function getIdFromList(list, fileName) {
if (list == null) {
throw new Error("list: list fail");
}
// TODO: should verify file name
const filterInfo = list.find(info => {
return info.PathLocal.includes(fileName);
});
if (filterInfo == null) {
console.error(list);
throw new Error("list: file name not found");
} else {
return filterInfo.Id;
}
}
function delWithName(fileName) {
return list().then(infoList => {
const infoToDel = infoList.find(info => {
return info.PathLocal.includes(fileName);
});
if (infoToDel == null) {
console.warn("delWithName: name not found");
} else {
return del(infoToDel.Id);
}
});
}
export function testShadowPublishId() {
return testLogin()
.then(() => upload(fileName))
.then(list)
.then(infoList => {
return getIdFromList(infoList, fileName);
})
.then(shareId => {
return shadowId(shareId).then(secretId => {
if (shareId === secretId) {
throw new Error("shadowId: id not changed");
} else {
return secretId;
}
});
})
.then(secretId => {
return list().then(infoList => {
const info = infoList.find(info => {
return info.Id === secretId;
});
if (info.PathLocal.includes(fileName)) {
console.log("shadowId api: ok", secretId);
return secretId;
} else {
throw new Error("shadowId pai: file not found", infoList, fileName);
}
});
})
.then(secretId => {
return publishId(secretId).then(publicId => {
if (publicId === secretId) {
// TODO: it is not enough to check they are not equal
throw new Error("publicId: id not changed");
} else {
console.log("publishId api: ok", publicId);
return publicId;
}
});
})
.then(shareId => del(shareId))
.then(testLogout)
.catch(err => {
console.error(err);
delWithName(fileName);
});
}
export function testSetDownLimit() {
const downLimit = 777;
return testLogin()
.then(() => upload(fileName))
.then(list)
.then(infoList => {
return getIdFromList(infoList, fileName);
})
.then(shareId => {
return setDownLimit(shareId, downLimit).then(ok => {
if (!ok) {
throw new Error("setDownLimit: failed");
} else {
return shareId;
}
});
})
.then(shareId => {
return list().then(infoList => {
const info = infoList.find(info => {
return info.Id == shareId;
});
if (info.DownLimit === downLimit) {
console.log("setDownLimit api: ok");
return shareId;
} else {
throw new Error("setDownLimit api: limit unchanged");
}
});
})
.then(shareId => del(shareId))
.then(testLogout)
.catch(err => {
console.error(err);
delWithName(fileName);
});
}
// TODO: need to add local file and test
export function testAddLocalFiles() {
return testLogin()
.then(() => addLocalFiles())
.then(ok => {
if (ok) {
console.log("addLocalFiles api: ok");
} else {
throw new Error("addLocalFiles api: failed");
}
})
.then(() => testLogout())
.catch(err => {
console.error(err);
});
}

View file

@ -0,0 +1,25 @@
import { testAuth } from "./api_auth_test";
import { testUploadOneFile } from "./api_upload_test";
import {
testAddLocalFiles,
testSetDownLimit,
testShadowPublishId
} from "./api_share_test";
import { testUpDownBatch } from "./api_up_down_batch_test";
console.log("Test started");
const fileName = `test_filename${Date.now()}`;
const file = new File(["foo"], fileName, {
type: "text/plain"
});
testAuth()
.then(testShadowPublishId)
.then(() => testUploadOneFile(file, fileName))
.then(testSetDownLimit)
.then(testAddLocalFiles)
.then(testUpDownBatch)
.then(() => {
console.log("Tests are finished");
});

View file

@ -0,0 +1,97 @@
import axios from "axios";
import md5 from "md5";
import { config } from "../../config";
import { testUpload } from "./api_upload_test";
import { list, del } from "../api_share";
import { testLogin, testLogout } from "./api_auth_test";
export function testUpDownBatch() {
const fileInfos = [
{
fileName: "test_2MB_1",
content: new Array(1024 * 1024 * 2).join("x")
},
{
fileName: "test_1MB_1",
content: new Array(1024 * 1024 * 1).join("x")
},
{
fileName: "test_2MB_2",
content: new Array(1024 * 1024 * 2).join("x")
},
{
fileName: "test_1B",
content: `${new Array(3).join("o")}${new Array(3).join("x")}`
}
];
return testLogin()
.then(() => {
const promises = fileInfos.map(info => {
const file = new File([info.content], info.fileName, {
type: "text/plain"
});
return testUpAndDownOneFile(file, info.fileName);
});
return Promise.all(promises);
})
.then(() => {
testLogout();
})
.catch(err => console.error(err));
}
export function testUpAndDownOneFile(file, fileName) {
return delTestFile(fileName)
.then(() => testUpload(file))
.then(shareId => testDownload(shareId, file))
.catch(err => console.error(err));
}
function delTestFile(fileName) {
return list().then(infos => {
const info = infos.find(info => {
return info.PathLocal === fileName;
});
if (info == null) {
console.log("up-down: file not found", fileName);
} else {
return del(info.Id);
}
});
}
function testDownload(shareId, file) {
return axios
.get(`${config.serverAddr}/download?shareid=${shareId}`)
.then(response => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = event => {
const upHash = md5(event.target.result);
const downHash = md5(response.data);
if (upHash !== downHash) {
console.error(
"up&down: hash unmatch",
file.name,
upHash,
downHash,
upHash.length,
downHash.length
);
} else {
console.log("up&down: ok: hash match", file.name, upHash, downHash);
resolve();
}
};
reader.onerror = err => reject(err);
reader.readAsText(file);
});
});
}

View file

@ -0,0 +1,63 @@
import { FileUploader } from "../api_upload";
import { list, del } from "../api_share";
import { testLogin, testLogout } from "./api_auth_test";
function verify(fileName) {
return list()
.then(list => {
if (list == null) {
throw new Error("upload: list fail");
}
// TODO: should verify file name
const filterInfo = list.find(info => {
return info.PathLocal.includes(fileName);
});
if (filterInfo == null) {
console.error(list);
throw new Error("upload: file name not found");
} else {
return filterInfo.Id;
}
})
.then(shareId => {
console.log("upload api: ok");
del(shareId);
})
.then(testLogout)
.catch(err => {
throw err;
});
}
export function testUpload(file) {
const onStart = () => true;
const onProgress = () => true;
const onFinish = () => true;
const onError = err => {
throw new Error(JSON.stringify(err));
};
const uploader = new FileUploader(onStart, onProgress, onFinish, onError);
return uploader.uploadFile(file).catch(err => {
console.error(err);
});
}
export function testUploadOneFile(file, fileName) {
const onStart = () => true;
const onProgress = () => true;
const onFinish = () => true;
const onError = err => {
throw new Error(JSON.stringify(err));
};
const uploader = new FileUploader(onStart, onProgress, onFinish, onError);
return testLogin()
.then(() => uploader.uploadFile(file))
.then(() => verify(fileName))
.catch(err => {
console.error(err);
});
}

5
client/libs/utils.js Normal file
View file

@ -0,0 +1,5 @@
export function makePostBody(paramMap) {
return Object.keys(paramMap)
.map(key => `${key}=${paramMap[key]}`)
.join("&");
}

150
client/panels/admin.jsx Normal file
View file

@ -0,0 +1,150 @@
import axios from "axios";
import React from "react";
import ReactDOM from "react-dom";
import { config } from "../config";
import { addLocalFiles, list } from "../libs/api_share";
import { login, logout } from "../libs/api_auth";
import { FilePane } from "../components/composite/file_pane";
import { InfoBar } from "../components/composite/info_bar";
import { Log } from "../components/composite/log";
function getWidth() {
if (window.innerWidth >= window.innerHeight) {
return `${Math.floor(
window.innerWidth * 0.95 / config.rootSize / config.colWidth
) * config.colWidth}rem`;
}
return "auto";
}
const styleLogContainer = {
paddingTop: "1rem",
textAlign: "center",
height: "2rem",
overflowX: "hidden" // TODO: should no hidden
};
const styleLogContent = {
color: "#333",
fontSize: "0.875rem",
opacity: 0.6,
backgroundColor: "#fff",
borderRadius: "1rem",
whiteSpace: "nowrap"
};
class AdminPanel extends React.PureComponent {
constructor(props) {
super(props);
this.state = {
isLogin: false,
filterName: "",
serverAddr: `${window.location.protocol}//${window.location.hostname}:${
window.location.port
}`,
width: getWidth()
};
this.log = {
ok: msg => console.log(msg),
warning: msg => console.log(msg),
info: msg => console.log(msg),
error: msg => console.log(msg),
start: msg => console.log(msg),
end: msg => console.log(msg)
};
this.logComponent = <Log ref={this.assignLog} styleLog={styleLogContent} />;
}
componentWillMount() {
list().then(infos => {
if (infos != null) {
this.setState({ isLogin: true });
}
});
}
setWidth = () => {
this.setState({ width: getWidth() });
};
// componentDidMount() {
// window.addEventListener("resize", this.setWidth);
// }
// componentWillUnmount() {
// window.removeEventListener("resize", this.setWidth);
// }
onLogin = (serverAddr, adminId, adminPwd) => {
login(serverAddr, adminId, adminPwd).then(ok => {
if (ok === true) {
this.setState({ isLogin: true });
} else {
this.log.error("Fail to login");
this.setState({ isLogin: false });
}
});
};
onLogout = serverAddr => {
logout(serverAddr).then(ok => {
if (ok === false) {
this.log.error("Fail to log out");
} else {
this.log.ok("You are logged out");
}
this.setState({ isLogin: false });
});
};
onSearch = fileName => {
this.setState({ filterName: fileName });
};
assignLog = logRef => {
this.log = logRef;
this.log.info(
<span>
Know more about <a href="https://github.com/ihexxa">Quickshare</a>
</span>
);
};
render() {
const width = this.state.width;
return (
<div>
<InfoBar
compact={width === "auto"}
width={width}
serverAddr={this.state.serverAddr}
isLogin={this.state.isLogin}
onLogin={this.onLogin}
onLogout={this.onLogout}
onAddLocalFiles={addLocalFiles}
onSearch={this.onSearch}
onOk={this.log.ok}
onError={this.log.error}
>
<div style={{ ...styleLogContainer, width }}>{this.logComponent}</div>
</InfoBar>
{this.state.isLogin ? (
<FilePane
width={width}
colWidth={config.colWidth}
onList={list}
onOk={this.log.ok}
onError={this.log.error}
filterName={this.state.filterName}
/>
) : (
<div />
)}
</div>
);
}
}
ReactDOM.render(<AdminPanel />, document.getElementById("app"));

View file

@ -0,0 +1,4 @@
import { configure } from "enzyme";
import Adapter from "enzyme-adapter-react-15";
configure({ adapter: new Adapter() });

View file

@ -0,0 +1,73 @@
// function should be called after async operation is finished
export function execFuncs(instance, execs) {
// instance: enzyme mounted component
// const execs = [
// {
// func: "componentWillMount",
// args: []
// }
// ];
return execs.reduce((prePromise, nextFunc) => {
return prePromise.then(() => instance[nextFunc.func](...nextFunc.args));
}, Promise.resolve());
}
export function execsToStr(execs) {
// const execs = [
// {
// func: "componentWillMount",
// args: []
// }
// ];
const execList = execs.map(
funcInfo => `${funcInfo.func}(${funcInfo.args.join(", ")})`
);
return execList.join(", ");
}
export function getDesc(componentName, testCase) {
// const testCase = {
// execs: [
// {
// func: "onAddLocalFiles",
// args: []
// }
// ],
// state: {
// filterFileName: ""
// },
// calls: [
// {
// func: "onAddLocalFiles",
// count: 1
// }
// ]
// }
return `${componentName} should satisfy following by exec ${execsToStr(
testCase.execs
)}
state=${JSON.stringify(testCase.state)}
calls=${JSON.stringify(testCase.calls)} `;
}
export function verifyCalls(calls, stubs) {
// const calls: [
// {
// func: "funcName",
// count: 1
// }
// ];
// const stubs = {
// funcName: jest.fn(),
// };
let err = null;
calls.forEach(called => {
if (stubs[called.func].mock.calls.length != called.count) {
err = `InfoBar: ${called.func} should be called ${called.count} but ${
stubs[called.func].mock.calls.length
}`;
}
});
return err;
}

View file

@ -0,0 +1,60 @@
const webpack = require("webpack");
const CleanWebpackPlugin = require("clean-webpack-plugin");
// const HtmlWebpackPlugin = require("html-webpack-plugin");
const outputPath = `${__dirname}/../public/dist`;
module.exports = {
context: __dirname,
entry: {
assets: ["axios", "immutable", "react", "react-dom"],
admin: "./panels/admin"
},
output: {
path: outputPath,
filename: "[name].bundle.js"
},
module: {
rules: [
{
test: /\.js|jsx$/,
use: [
{
loader: "babel-loader",
options: {
presets: ["es2015", "react", "stage-2"]
}
}
]
},
{
test: /\.css$/,
use: ["style-loader", "css-loader"]
},
{
test: /\.(png|jpg|gif)$/,
use: [
{
loader: "file-loader",
options: {}
}
]
}
]
},
resolve: {
extensions: [".js", ".json", ".jsx", ".css"]
},
plugins: [
new webpack.optimize.CommonsChunkPlugin({
name: "assets",
// filename: "vendor.js"
// (Give the chunk a different name)
minChunks: Infinity
// (with more entries, this ensures that no other module
// goes into the vendor chunk)
}),
// new HtmlWebpackPlugin(),
new CleanWebpackPlugin([outputPath])
]
};

View file

@ -0,0 +1,17 @@
const merge = require("webpack-merge");
const common = require("./webpack.config.common.js");
module.exports = merge(common, {
entry: {
api_test: "./libs/test/api_test"
},
devtool: "inline-source-map",
devServer: {
contentBase: "./dist"
},
watchOptions: {
aggregateTimeout: 1000,
poll: 1000,
ignored: /node_modules/
}
});

View file

@ -0,0 +1,16 @@
const common = require("./webpack.config.common.js");
const merge = require("webpack-merge");
const UglifyJS = require("uglifyjs-webpack-plugin");
const webpack = require("webpack");
module.exports = merge(common, {
devtool: "source-map",
plugins: [
new UglifyJS({
sourceMap: true
}),
new webpack.DefinePlugin({
"process.env.NODE_ENV": JSON.stringify("production")
})
]
});