Merge pull request #7 from captbaritone/table

Table
This commit is contained in:
Jordan Eldredge 2020-09-15 22:38:15 -07:00 committed by GitHub
commit 2925acb115
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 362 additions and 197 deletions

View file

@ -12,7 +12,7 @@ import * as Actions from "./redux/actionCreators";
import { ABOUT_PAGE } from "./constants";
import { useWindowSize, useScrollbarWidth, useActionCreator } from "./hooks";
import { SCREENSHOT_WIDTH, SKIN_RATIO } from "./constants";
import UploadGrid from "./UploadGrid";
import UploadGrid from "./upload/UploadGrid";
import Metadata from "./components/Metadata";
import SkinReadme from "./SkinReadme";
import { useDropzone } from "react-dropzone";

39
src/DropTarget.js Normal file
View file

@ -0,0 +1,39 @@
import React from "react";
function DropTarget({ getInputProps }) {
return (
<div
style={{
margin: 20,
flexGrow: 1,
border: "8px dashed grey",
borderRadius: 20,
color: "grey",
textAlign: "center",
vericalAlign: "middle",
display: "flex",
justifyContent: "center",
alignItems: "center",
flexDirection: "column",
}}
>
<div style={{ fontSize: 30, color: "white" }}>Drop Skins Here</div>
<div
style={{
paddingTop: 30,
fontSize: 20,
paddingLeft: 25,
paddingRight: 25,
lineHeight: 1.3,
maxWidth: 400,
}}
>
We'll analyze them in your browser to find any that are missing from the
museum
</div>
<input {...getInputProps()} />
</div>
);
}
export default DropTarget;

View file

@ -1,173 +0,0 @@
import React, { useMemo } from "react";
import { useSelector } from "react-redux";
import { HEADING_HEIGHT } from "./constants";
import { useActionCreator } from "./hooks";
import * as Actions from "./redux/actionCreators";
import * as Utils from "./utils";
function DropTarget({ getInputProps }) {
return (
<div
style={{
margin: 20,
flexGrow: 1,
border: "8px dashed grey",
borderRadius: 20,
color: "grey",
textAlign: "center",
vericalAlign: "middle",
display: "flex",
justifyContent: "center",
alignItems: "center",
flexDirection: "column",
}}
>
<div style={{ fontSize: 30, color: "white" }}>Drop Skins Here</div>
<div
style={{
paddingTop: 30,
fontSize: 20,
paddingLeft: 25,
paddingRight: 25,
lineHeight: 1.3,
maxWidth: 400,
}}
>
We'll analyze them in your browser to find any that are missing from the
museum
</div>
<input {...getInputProps()} />
</div>
);
}
function Section({ files, filter, title, render }) {
const matches = useMemo(() => files.filter(filter), [files, filter]);
if (matches.length === 0) {
return null;
}
return (
<>
<h2>
{matches.length.toLocaleString()} {title}
</h2>
<ul>
{matches.map((match, i) => {
return <li key={i}>{render(match)}</li>;
})}
</ul>
</>
);
}
function UploadGrid({ getInputProps, isDragActive }) {
const files = useSelector((state) => state.fileUploads);
const tryToUploadAllFiles = useActionCreator(Actions.tryToUploadAllFiles);
const filesArr = Object.values(files);
return (
<div
style={{
position: "absolute",
top: HEADING_HEIGHT,
left: 0,
bottom: 0,
right: 0,
display: "flex",
flexDirection: "column",
}}
>
{isDragActive || Object.keys(files).length === 0 ? (
<DropTarget getInputProps={getInputProps} />
) : (
<div style={{ color: "white", padding: 15 }}>
<div>
This feature is still in beta. If you have any issues, please reach
out to{" "}
<a href="mailto:jordan@jordaneldredge.com">
jordan@jordaneldredge.com
</a>
.
</div>
<h1>
{`You've dragged in ${filesArr.length.toLocaleString()} files`}
{filesArr.some((file) => file.status === "NEW") &&
" (Analyzing...)"}
</h1>
<Section
files={filesArr}
title={
<>
are new skins!
{filesArr.some((file) => file.status === "MISSING") && (
<div>
<button onClick={tryToUploadAllFiles}>Upload All</button>
</div>
)}
</>
}
filter={(file) =>
file.status === "MISSING" ||
file.status === "UPLOADING" ||
file.status === "UPLOAD_FAILED" ||
file.status === "ARCHIVED"
}
render={(file) => {
switch (file.status) {
case "MISSING":
return file.file.name;
case "UPLOADING":
return <>{file.file.name} (🚀 Uploading...)</>;
case "UPLOAD_FAILED":
return <>{file.file.name} ( Upload Failed)</>;
case "ARCHIVED":
return (
<>
<a
href={Utils.museumUrlFromHash(file.md5)}
target="_blank"
rel="noopener noreferrer"
>
{file.file.name}
</a>{" "}
( Added!)
</>
);
default:
console.error(`Unexpected file status: ${file.status}`);
return null;
}
}}
/>
<Section
files={filesArr}
title="are not skins"
filter={(file) => file.status === "INVALID_FILE_EXTENSION"}
render={(file) => file.file.name}
/>
<Section
files={filesArr}
title="Modern Skins (we're not accepting these yet)"
filter={(file) => file.status === "NOT_CLASSIC_SKIN"}
render={(file) => file.file.name}
/>
<Section
files={filesArr}
title="are already in the museum"
filter={(file) => file.status === "FOUND"}
render={(file) => (
<a
href={Utils.museumUrlFromHash(file.md5)}
target="_blank"
rel="noopener noreferrer"
>
{file.file.name}
</a>
)}
/>
</div>
)}
</div>
);
}
export default UploadGrid;

View file

@ -2,7 +2,7 @@ import React, { useState } from "react";
import DownloadLink from "./DownloadLink";
import * as Utils from "../utils";
import LinkInput from "./LinkInput";
import { API_URL } from "../constants";
// import { API_URL } from "../constants";
// import * as Actions from "../redux/actionCreators";
import * as Selectors from "../redux/selectors";
import * as Actions from "../redux/actionCreators";

View file

@ -67,8 +67,12 @@ export function invalidFileExtension(id) {
return { type: "INVALID_FILE_EXTENSION", id };
}
export function notClassicSkin(id) {
return { type: "NOT_CLASSIC_SKIN", id };
export function invalidArchive(id) {
return { type: "INVALID_ARCHIVE", id };
}
export function gotSkinType(id, skinType) {
return { type: "GOT_SKIN_TYPE", id, skinType };
}
export function gotFileMd5(id, md5) {

View file

@ -18,7 +18,7 @@ import {
import { search } from "../algolia";
import queryParser from "../queryParser";
import { API_URL, CHUNK_SIZE } from "../constants";
import * as UploadUtils from "../uploadUtils";
import * as UploadUtils from "../upload/uploadUtils";
const urlChangedEpic = (actions) =>
actions.pipe(
@ -213,16 +213,17 @@ const uploadSingleFileEpic = (actions) =>
if (!UploadUtils.isValidSkinFilename(file.name)) {
return of(Actions.invalidFileExtension(id));
}
return from(UploadUtils.isClassicSkin(file))
return from(UploadUtils.getSkinType(file))
.pipe(
mergeMap((isClassic) => {
if (!isClassic) {
return of(Actions.notClassicSkin(id));
mergeMap((skinType) => {
if (skinType === null) {
return of(Actions.invalidArchive(id));
}
return from(UploadUtils.hashFile(file)).pipe(
map((md5) => {
return Actions.gotFileMd5(id, md5);
})
return concat(
of(Actions.gotSkinType(id, skinType)),
from(UploadUtils.hashFile(file)).pipe(
map((md5) => Actions.gotFileMd5(id, md5))
)
);
})
)
@ -235,8 +236,8 @@ const checkIfUploadsAreMissingEpic = (actions, state) =>
filter((action) => {
return (
(action.type === "GOT_FILE_MD5" ||
action.type === "NOT_CLASSIC_SKIN" ||
action.type === "INVALID_FILE_EXTENSION") &&
action.type === "INVALID_FILE_EXTENSION" ||
action.type === "INVALID_ARCHIVE") &&
Selectors.getAreReadyToCheckMissingUploads(state.value)
);
}),
@ -323,7 +324,6 @@ const loggingEpic = (actions, state) =>
case "TRY_TO_UPLOAD_ALL_FILES":
case "INVALID_FILE_EXTENSION":
case "GOT_FILE":
case "NOT_CLASSIC_SKIN":
case "GOT_MISSING_AND_FOUND_MD5S":
window.ga("send", "event", "redux", action.type);
break;

View file

@ -66,8 +66,18 @@ export default function reducer(state = defaultState, action) {
true
);
}
case "NOT_CLASSIC_SKIN": {
return setUploadFileStatus(state, action.id, "NOT_CLASSIC_SKIN", true);
case "GOT_SKIN_TYPE": {
const previousFile = state.fileUploads[action.id];
return {
...state,
fileUploads: {
...state.fileUploads,
[action.id]: {
...previousFile,
skinType: action.skinType,
},
},
};
}
case "STARTING_FILE_UPLOAD": {
return setUploadFileStatus(state, action.id, "UPLOADING");
@ -78,6 +88,8 @@ export default function reducer(state = defaultState, action) {
case "ARCHIVED_SKIN": {
return setUploadFileStatus(state, action.id, "ARCHIVED");
}
case "INVALID_ARCHIVE":
return setUploadFileStatus(state, action.id, "INVALID_ARCHIVE");
case "GOT_FILE_MD5": {
return {
...state,
@ -232,7 +244,6 @@ export default function reducer(state = defaultState, action) {
},
};
case "REQUESTED_PAGE":
console.log(action);
return {
...state,
activeContentPage: action.page,

128
src/upload/UploadGrid.js Normal file
View file

@ -0,0 +1,128 @@
import React, { useMemo } from "react";
import { useSelector } from "react-redux";
import { HEADING_HEIGHT } from "../constants";
import { useActionCreator } from "../hooks";
import * as Actions from "../redux/actionCreators";
import DropTarget from "../DropTarget";
import UploadSection from "./UploadSection";
function useBucketed(filesArr) {
return useMemo(() => {
const missing = [];
const notSkins = [];
const foundSkins = [];
filesArr.forEach((file) => {
switch (file.status) {
case "MISSING":
case "UPLOADING":
case "UPLOAD_FAILED":
case "ARCHIVED":
missing.push(file);
break;
case "INVALID_FILE_EXTENSION":
case "INVALID_ARCHIVE":
notSkins.push(file);
break;
case "FOUND":
foundSkins.push(file);
break;
default:
}
});
return { missing, notSkins, foundSkins };
}, [filesArr]);
}
function Plural({ count, single, plural }) {
return count === 1 ? single : plural;
}
function Inner({ files }) {
const tryToUploadAllFiles = useActionCreator(Actions.tryToUploadAllFiles);
const filesArr = Object.values(files);
const { missing, notSkins, foundSkins } = useBucketed(filesArr);
const analyzing = filesArr.some((file) => file.status === "NEW");
if (analyzing) {
return (
<h1>
Analyzing {filesArr.length.toLocaleString()}{" "}
<Plural count={filesArr.length} single="file" plural="files" />
...
</h1>
);
}
const stillHaveFilesToUpload = filesArr.some(
(file) => file.status === "MISSING" || file.status === "UPLOADING"
);
const filesToUpload = missing.length;
const getTitle = () => {
if (filesToUpload > 0) {
if (stillHaveFilesToUpload) {
return (
<>
Found {filesToUpload.toLocaleString()}{" "}
<Plural single="skin" plural="skins" count={filesToUpload} /> to
upload
</>
);
} else {
return `Thanks for your contribution!`;
}
}
return `No missing skins found`;
};
return (
<div style={{ maxWidth: 600, minWidth: 400, lineHeight: 1.2 }}>
<h1>{getTitle()}</h1>
<UploadSection
title="Missing from the museum"
extra={
stillHaveFilesToUpload && (
<button
onClick={tryToUploadAllFiles}
style={{ marginRight: 0, height: 30, fontWeight: "bold" }}
>
Upload All
</button>
)
}
files={missing}
/>
<UploadSection title="Invalid" files={notSkins} />
<UploadSection title="Already collected" files={foundSkins} />
</div>
);
}
function UploadGrid({ getInputProps, isDragActive, ...props }) {
const files = useSelector((state) => state.fileUploads);
return (
<div
style={{
position: "absolute",
top: HEADING_HEIGHT,
left: 0,
bottom: 0,
right: 0,
display: "flex",
flexDirection: "column",
color: "lightgrey",
}}
>
{isDragActive || Object.keys(files).length === 0 ? (
<DropTarget getInputProps={getInputProps} />
) : (
<div style={{ padding: 15, display: "flex", justifyContent: "center" }}>
<Inner files={files} {...props} />
</div>
)}
</div>
);
}
export default UploadGrid;

117
src/upload/UploadRow.js Normal file
View file

@ -0,0 +1,117 @@
import React from "react";
import * as Utils from "../utils";
function Row({ name, loading, right, complete }) {
return (
<div
style={{
borderBottom: "1px solid rgba(32, 31, 51, 1)",
position: "relative",
paddingTop: 4,
paddingBottom: 2,
}}
>
{(loading != null || complete) && (
<div
style={{
backgroundColor: "rgba(51, 71, 88, 1)",
position: "absolute",
left: 0,
top: 0,
width: loading ? `90%` : complete ? `100%` : `0%`,
transitionProperty: "all",
// TODO: Try to learn how long it really takes
transitionDuration: complete ? "200ms" : "9s",
height: "100%",
zIndex: 0,
}}
/>
)}
<div
style={{
zIndex: 1,
position: "relative",
display: "flex",
justifyContent: "space-between",
}}
>
<code>{name}</code>
{right && (
<code
style={{
color: "darkgray",
paddingLeft: 10,
// Ensure we are wide enough that text changes won't affect the layout
minWidth: 100,
textAlign: "right",
}}
>
{right}
</code>
)}
</div>
</div>
);
}
function SkinLink({ md5, children }) {
return (
<a
href={Utils.museumUrlFromHash(md5)}
target="_blank"
rel="noopener noreferrer"
style={{ color: "darkgray" }}
>
{children}
</a>
);
}
// TODO: This is a component
function getRight(file) {
switch (file.status) {
case "ARCHIVED":
switch (file.skinType) {
case "MODERN":
return "archived";
case "CLASSIC":
return <SkinLink md5={file.md5}>added</SkinLink>;
default:
throw new Error(`Unknown skinType "${file.skinType}"`);
}
case "FOUND":
switch (file.skinType) {
case "MODERN":
return "archived";
case "CLASSIC":
return <SkinLink md5={file.md5}>view</SkinLink>;
default:
throw new Error(`Unknown skinType "${file.skinType}"`);
}
case "MISSING":
return "missing";
case "UPLOADING":
return "uploading...";
case "UPLOAD_FAILED":
return "upload failed";
case "INVALID_ARCHIVE":
return "corrupt";
case "INVALID_FILE_EXTENSION":
return "not skin";
default:
return file.status;
}
}
function UploadRow({ file }) {
return (
<Row
name={file.file.name}
loading={file.status === "UPLOADING"}
right={getRight(file)}
complete={file.status === "ARCHIVED"}
/>
);
}
export default UploadRow;

View file

@ -0,0 +1,34 @@
import React from "react";
import UploadRow from "./UploadRow";
function UploadSection({ files, title, extra }) {
if (files.length === 0) {
return null;
}
return (
<div
style={{
paddingBottom: 20,
}}
>
<div
style={{
display: "flex",
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
}}
>
<h2>
{title} ({files.length.toLocaleString()})
</h2>
<div>{extra}</div>
</div>
{files.map((file) => (
<UploadRow key={file.id} file={file} />
))}
</div>
);
}
export default UploadSection;

View file

@ -1,4 +1,4 @@
import { API_URL } from "./constants";
import { API_URL } from "../constants";
export async function upload(file) {
const formData = new FormData();
formData.append("skin", file, file.name);
@ -22,7 +22,7 @@ export async function checkMd5sAreMissing(md5s) {
}
export async function hashFile(file) {
const { hashFile: hasher } = await import("./hashFile");
const { hashFile: hasher } = await import("../hashFile");
return hasher(file);
}
@ -33,14 +33,19 @@ export function isValidSkinFilename(filename) {
return validSkinFilename.test(filename);
}
export async function isClassicSkin(file) {
export async function getSkinType(file) {
const JSZip = await import("jszip");
try {
const zip = await JSZip.loadAsync(file);
return zip.file(/main\.bmp$/i).length > 0;
if (zip.file(/main\.bmp$/i).length > 0) {
return "CLASSIC";
} else if (zip.file(/skin\.xml$/i).length > 0) {
return "MODERN";
}
return null;
} catch (e) {
// TODO: We could give a better message here.
console.error(e);
return false;
return null;
}
}