file uploads: no longer users a library, just raw XHR, adds file loading to the UI

This commit is contained in:
jimmylee 2020-08-14 19:08:17 -07:00
parent e751a2cb48
commit 418d26bf8f
4 changed files with 115 additions and 223 deletions

View File

@ -1,151 +0,0 @@
// NOTE(jim)
// https://github.com/samundrak/fetch-progress (thank you!)
let tick = 1;
let maxTick = 65535;
let resolution = 4;
let inc = function () {
tick = (tick + 1) & maxTick;
};
let timer = setInterval(inc, (1000 / resolution) | 0);
if (timer.unref) timer.unref();
function speedometer(seconds) {
let size = resolution * (seconds || 5);
let buffer = [0];
let pointer = 1;
let last = (tick - 1) & maxTick;
return function (delta) {
let dist = (tick - last) & maxTick;
if (dist > size) dist = size;
last = tick;
while (dist--) {
if (pointer === size) pointer = 0;
buffer[pointer] = buffer[pointer === 0 ? size - 1 : pointer - 1];
pointer++;
}
if (delta) buffer[pointer - 1] += delta;
let top = buffer[pointer - 1];
let btm = buffer.length < size ? 0 : buffer[pointer === size ? 0 : pointer];
return buffer.length < resolution ? top : ((top - btm) * resolution) / buffer.length;
};
}
class Progress {
constructor(length, emitDelay = 1000) {
this.length = parseInt(length, 10) || 0;
this.transferred = 0;
this.speed = 0;
this.streamSpeed = speedometer(this.speed || 5000);
this.initial = false;
this.emitDelay = emitDelay;
this.eventStart = 0;
this.percentage = 0;
}
getRemainingBytes() {
return parseInt(this.length, 10) - parseInt(this.transferred, 10);
}
getEta() {
return this.length >= this.transferred ? (this.getRemainingBytes() / this.speed) * 1000000000 : 0;
}
flow(chunk, onProgress) {
const chunkLength = chunk.length;
this.transferred += chunkLength;
this.speed = this.streamSpeed(chunkLength);
this.percentage = Math.round((this.transferred / this.length) * 100);
if (!this.initial) {
this.eventStart = Date.now();
this.initial = true;
}
if (this.length >= this.transferred || Date.now() - this.eventStart > this.emitDelay) {
this.eventStart = Date.now();
const progress = {
total: this.length,
transferred: this.transferred,
speed: this.speed,
eta: this.getEta(),
};
if (this.length) {
progress.remaining = this.getRemainingBytes();
progress.percentage = this.percentage;
}
onProgress(progress);
}
}
}
export function isFetchProgressSupported() {
return typeof Response !== "undefined" && typeof ReadableStream !== "undefined";
}
export function progress({
defaultSize = 0,
emitDelay = 10,
onProgress = () => null,
onComplete = () => null,
onError = () => null,
}) {
return function FetchProgress(response) {
if (!isFetchProgressSupported()) {
return response;
}
const { body, headers } = response;
const contentLength = headers.get("content-length") || defaultSize;
const progress = new Progress(contentLength, emitDelay);
const reader = body.getReader();
const stream = new ReadableStream({
start(controller) {
function push() {
reader
.read()
.then(({ done, value }) => {
if (done) {
onComplete({});
controller.close();
return;
}
if (value) {
progress.flow(value, onProgress);
}
controller.enqueue(value);
push();
})
.catch((err) => {
onError(err);
});
}
push();
},
});
return new Response(stream, { headers });
};
}
export default (url, options, onProgress) =>
new Promise((resolve, reject) =>
fetch(url, options)
.then(
progress({
onProgress,
onError: (err) => {
reject(err);
},
})
)
.then((data) => {
console.log({ upload: data });
resolve(data);
})
);

View File

@ -5,6 +5,7 @@ import * as State from "~/common/state";
import * as Credentials from "~/common/credentials";
import * as Validations from "~/common/validations";
import * as System from "~/components/system";
import * as Window from "~/common/window";
// NOTE(jim):
// Scenes each have an ID and can be navigated to with _handleAction
@ -39,7 +40,6 @@ import ApplicationHeader from "~/components/core/ApplicationHeader";
import ApplicationLayout from "~/components/core/ApplicationLayout";
import WebsitePrototypeWrapper from "~/components/core/WebsitePrototypeWrapper";
import Cookies from "universal-cookie";
import progressFetch from "~/common/fetch-utilities";
const cookies = new Cookies();
@ -102,38 +102,63 @@ export default class ApplicationPage extends React.Component {
};
_handleSetFile = async ({ file, slate }) => {
this.setState({ fileLoading: true });
let formData = new FormData();
formData.append("data", file);
console.log({ file });
let data = new FormData();
data.append("data", file);
const options = {
method: "POST",
headers: {
Accept: "application/json",
this.setState({
fileLoading: {
[file.lastModified]: {
name: file.name,
loaded: 0,
total: file.size,
},
},
body: data,
};
const response = await progressFetch(`/api/data/${file.name}`, options, (p) => {
console.log(p);
});
const json = await response.json();
const upload = (path) =>
new Promise((resolve, reject) => {
const XHR = new XMLHttpRequest();
XHR.open("post", path, true);
XHR.onerror = (event) => {
console.log(event);
};
XHR.onprogress = (event) => {
console.log("FILE UPLOAD PROGRESS", event);
this.setState({
fileLoading: {
...this.state.fileLoading,
[file.lastModified]: {
name: file.name,
loaded: event.loaded,
total: event.total,
},
},
});
};
XHR.onloadend = (event) => {
console.log("FILE UPLOAD END", event);
resolve(JSON.parse(event.target.response));
};
XHR.send(formData);
});
const json = await upload(`/api/data/${file.name}`);
console.log(json);
if (!json) {
this.setState({ sidebar: null, fileLoading: false });
return;
this.setState({ sidebar: null, fileLoading: null });
return { error: "NO_RESPONSE" };
}
if (json.error) {
this.setState({ sidebar: null, fileLoading: false });
return;
this.setState({ sidebar: null, fileLoading: null });
return json;
}
if (!json.data) {
this.setState({ sidebar: null, fileLoading: false });
return;
this.setState({ sidebar: null, fileLoading: null });
return json;
}
if (json && slate) {
@ -152,9 +177,7 @@ export default class ApplicationPage extends React.Component {
}
}
await this.rehydrate();
this.setState({ sidebar: null, fileLoading: false });
return json;
};
_handleDragEnter = (e) => {
@ -196,13 +219,12 @@ export default class ApplicationPage extends React.Component {
slate = { ...current.target, id: current.target.slateId };
}
let isUploading = false;
if (e.dataTransfer.items) {
for (var i = 0; i < e.dataTransfer.items.length; i++) {
if (e.dataTransfer.items[i].kind === "file") {
var file = e.dataTransfer.items[i].getAsFile();
console.log(file);
if (Validations.isFileTypeAllowed(file.type)) {
this._handleAction({
type: "SIDEBAR",
@ -210,6 +232,7 @@ export default class ApplicationPage extends React.Component {
data: slate,
});
isUploading = true;
await this._handleSetFile({ file, slate });
}
@ -218,10 +241,15 @@ export default class ApplicationPage extends React.Component {
}
}
this.setState({ fileLoading: false });
if (!isUploading) {
return;
}
await this.rehydrate();
this.setState({ sidebar: null, fileLoading: null });
};
rehydrate = async () => {
rehydrate = async (options) => {
const response = await Actions.hydrateAuthenticatedUser();
console.log("REHYDRATION CALL", response);
@ -230,10 +258,16 @@ export default class ApplicationPage extends React.Component {
return null;
}
this.setState({
const updates = {
viewer: State.getInitialState(response.data),
selected: State.getSelectedState(response.data),
});
};
if (options && options.resetFiles) {
updates.fileLoading = null;
}
this.setState(updates);
return { rehydrated: true };
};

View File

@ -30,11 +30,7 @@ const STYLES_DATA_METER = css`
flex-shrink: 0;
height: 100%;
background-color: ${Constants.system.brand};
background-image: linear-gradient(
315deg,
${Constants.system.brand} 0%,
#009ffd 74%
);
background-image: linear-gradient(315deg, ${Constants.system.brand} 0%, #009ffd 74%);
`;
const STYLES_ROW = css`
@ -75,35 +71,14 @@ const STYLES_TITLE = css`
margin-bottom: 4px;
`;
const STYLES_HREF = css`
font-family: ${Constants.font.semiBold};
font-weight: 400;
cursor: pointer;
transition: 200ms ease color;
:hover {
color: ${Constants.system.brand};
}
`;
export default (props) => {
const percentage = props.stats.bytes / props.stats.maximumBytes;
export const DataMeterBar = (props) => {
const percentage = props.bytes / props.maximumBytes;
return (
<div css={STYLES_CONTAINER} style={props.style}>
<System.P style={{ fontSize: 12 }}>
<strong css={STYLES_TITLE}>Usage</strong>
Slate users get 1GB of IPFS storage from Textile. In the future you can
extend this with your own plugins using our SDK.
<br />
<br />
</System.P>
<React.Fragment>
<div css={STYLES_STATS_ROW}>
<div css={STYLES_LEFT}>{Strings.bytesToSize(props.stats.bytes)}</div>
<div css={STYLES_RIGHT}>
{Strings.bytesToSize(props.stats.maximumBytes)}
</div>
<div css={STYLES_LEFT}>{Strings.bytesToSize(props.bytes)}</div>
<div css={STYLES_RIGHT}>{Strings.bytesToSize(props.maximumBytes)}</div>
</div>
<div css={STYLES_ROW}>
@ -112,11 +87,24 @@ export default (props) => {
</div>
<div css={STYLES_DATA} style={{ marginTop: 4 }}>
<div
css={STYLES_DATA_METER}
style={{ width: `${percentage * 100}%` }}
/>
<div css={STYLES_DATA_METER} style={{ width: `${percentage * 100}%` }} />
</div>
</React.Fragment>
);
};
export default (props) => {
return (
<div css={STYLES_CONTAINER} style={props.style}>
<System.P style={{ fontSize: 12 }}>
<strong css={STYLES_TITLE}>Usage</strong>
Slate users get 1GB of IPFS storage from Textile. In the future you can extend this with your own plugins using
our SDK.
<br />
<br />
</System.P>
<DataMeterBar bytes={props.stats.bytes} maximumBytes={props.stats.maximumBytes} />
</div>
);
};

View File

@ -4,6 +4,7 @@ import * as System from "~/components/system";
import * as Validations from "~/common/validations";
import { css } from "@emotion/react";
import { DataMeterBar } from "~/components/core/DataMeter";
const STYLES_FILE_HIDDEN = css`
height: 1px;
@ -42,6 +43,14 @@ const STYLES_IMAGE_PREVIEW = css`
margin-top: 48px;
`;
const STYLES_STRONG = css`
display: block;
margin: 16px 0 4px 0;
font-weight: 400;
font-family: ${Constants.font.medium};
font-size: 0.8rem;
`;
export default class SidebarAddFileToBucket extends React.Component {
_handleUpload = async (e) => {
e.persist();
@ -64,9 +73,13 @@ export default class SidebarAddFileToBucket extends React.Component {
file,
slate: this.props.data && this.props.data.slateId ? { id: this.props.data.slateId } : null,
});
await this.props.onRehydrate({ resetFiles: true });
};
render() {
console.log(this.props.fileLoading);
return (
<React.Fragment>
<System.P style={{ fontFamily: Constants.font.semiBold }}>Upload data</System.P>
@ -83,20 +96,28 @@ export default class SidebarAddFileToBucket extends React.Component {
type="label"
htmlFor="file"
style={{ marginTop: 24 }}
loading={this.props.fileLoading}
>
loading={!!this.props.fileLoading}>
Add file
</System.ButtonPrimary>
{!this.props.fileLoading ? (
<System.ButtonSecondary
full
style={{ marginTop: 16 }}
onClick={this.props.onCancel}
>
<System.ButtonSecondary full style={{ marginTop: 16 }} onClick={this.props.onCancel}>
Cancel
</System.ButtonSecondary>
) : null}
{this.props.fileLoading
? Object.keys(this.props.fileLoading).map((timestamp) => {
const p = this.props.fileLoading[timestamp];
console.log({ p });
return (
<React.Fragment key={timestamp}>
<strong css={STYLES_STRONG}>{p.name}</strong>
<DataMeterBar bytes={p.loaded} maximumBytes={p.total} />
</React.Fragment>
);
})
: null}
</React.Fragment>
);
}