diff --git a/package.json b/package.json
index fee83bbe..d1a30523 100644
--- a/package.json
+++ b/package.json
@@ -89,7 +89,7 @@
"react-helmet": "^5.2.0",
"react-infinite-scroller": "^1.2.4",
"react-modal": "^3.10.1",
- "react-router-dom": "^5.0.1",
+ "react-router-dom": "^5.1.2",
"react-select": "^3.0.4",
"react-toggle": "^4.0.2",
"react-tooltip": "^3.11.1",
@@ -101,6 +101,7 @@
"three": "https://github.com/MozillaReality/three.js.git#0f9b0024725f0dd917caa54c2934a4ba1fc12c4f",
"three-mesh-bvh": "^0.1.4",
"url-toolkit": "^2.1.6",
+ "use-http": "^0.2.4",
"uuid": "^3.3.3"
},
"devDependencies": {
diff --git a/src/api/Api.js b/src/api/Api.js
index 10983024..6a928b65 100644
--- a/src/api/Api.js
+++ b/src/api/Api.js
@@ -94,6 +94,7 @@ export default class Project extends EventEmitter {
const { protocol, host } = new URL(window.location.href);
this.serverURL = protocol + "//" + host;
+ this.apiURL = `https://${RETICULUM_SERVER}`;
this.projectDirectoryPath = "/api/files/";
@@ -357,8 +358,20 @@ export default class Project extends EventEmitter {
const resp = await this.fetch(url, { headers, signal });
+ if (signal.aborted) {
+ const error = new Error("Media search aborted");
+ error.aborted = true;
+ throw error;
+ }
+
const json = await resp.json();
+ if (signal.aborted) {
+ const error = new Error("Media search aborted");
+ error.aborted = true;
+ throw error;
+ }
+
const thumbnailedEntries = json.entries.map(entry => {
if (entry.images && entry.images.preview && entry.images.preview.url) {
if (entry.images.preview.type === "mp4") {
diff --git a/src/ui/App.js b/src/ui/App.js
index 121bc26c..ca01d68a 100644
--- a/src/ui/App.js
+++ b/src/ui/App.js
@@ -18,7 +18,7 @@ import WhatsNewPage from "./whats-new/WhatsNewPage";
import LoginPage from "./auth/LoginPage";
import LogoutPage from "./auth/LogoutPage";
import ProjectsPage from "./projects/ProjectsPage";
-import TemplatesPage from "./projects/TemplatesPage";
+import CreateProjectPage from "./projects/CreateProjectPage";
import { ThemeProvider } from "styled-components";
@@ -76,7 +76,8 @@ export default class App extends Component {
-
+
+
diff --git a/src/ui/assets/useAssetSearch.js b/src/ui/assets/useAssetSearch.js
index 860c8b4a..c72c7a86 100644
--- a/src/ui/assets/useAssetSearch.js
+++ b/src/ui/assets/useAssetSearch.js
@@ -14,7 +14,7 @@ function useIsMounted() {
return () => ref.current;
}
-function useLoadAsync(callback, initialResults = [], initialCursor = 0) {
+export function useLoadAsync(callback, initialResults = [], initialCursor = 0) {
const currentPromise = useRef();
const abortControllerRef = useRef();
const getIsMounted = useIsMounted();
diff --git a/src/ui/contexts/ApiContext.js b/src/ui/contexts/ApiContext.js
index fe559cd5..bbe4d681 100644
--- a/src/ui/contexts/ApiContext.js
+++ b/src/ui/contexts/ApiContext.js
@@ -1,6 +1,6 @@
import React from "react";
-const ApiContext = React.createContext();
+export const ApiContext = React.createContext();
export const ApiContextProvider = ApiContext.Provider;
diff --git a/src/ui/projects/CreateProjectPage.js b/src/ui/projects/CreateProjectPage.js
new file mode 100644
index 00000000..d35dca19
--- /dev/null
+++ b/src/ui/projects/CreateProjectPage.js
@@ -0,0 +1,164 @@
+import React, { useCallback, useState, useContext } from "react";
+import PropTypes from "prop-types";
+import ScrollToTop from "../router/ScrollToTop";
+import NavBar from "../navigation/NavBar";
+import {
+ ProjectGrid,
+ ProjectGridContainer,
+ ProjectGridHeader,
+ ProjectGridHeaderRow,
+ Filter,
+ Separator,
+ SearchInput,
+ ProjectGridContent,
+ ErrorMessage
+} from "./ProjectGrid";
+import Footer from "../navigation/Footer";
+import PrimaryLink from "../inputs/PrimaryLink";
+import { Button } from "../inputs/Button";
+import { ProjectsSection, ProjectsContainer, ProjectsHeader } from "./ProjectsPage";
+import { ApiContext } from "../contexts/ApiContext";
+import { Link } from "react-router-dom";
+import InfiniteScroll from "react-infinite-scroller";
+import usePaginatedSearch from "./usePaginatedSearch";
+
+export default function CreateProjectPage({ history, location }) {
+ const api = useContext(ApiContext);
+
+ const queryParams = new URLSearchParams(location.search);
+
+ const [params, setParams] = useState({
+ source: "scene_listings",
+ filter: queryParams.get("filter") || "featured-remixable",
+ q: queryParams.get("q") || ""
+ });
+
+ const updateParams = useCallback(
+ nextParams => {
+ const search = new URLSearchParams();
+
+ for (const name in nextParams) {
+ if (name === "source" || !nextParams[name]) {
+ continue;
+ }
+
+ search.set(name, nextParams[name]);
+ }
+
+ history.push(`/projects/create?${search}`);
+
+ setParams(nextParams);
+ },
+ [history]
+ );
+
+ const onChangeQuery = useCallback(
+ value => {
+ updateParams({
+ source: "scene_listings",
+ filter: "remixable",
+ q: value
+ });
+ },
+ [updateParams]
+ );
+
+ const onSetFeaturedRemixable = useCallback(() => {
+ updateParams({
+ ...params,
+ filter: "featured-remixable",
+ q: ""
+ });
+ }, [updateParams, params]);
+
+ const onSetAll = useCallback(() => {
+ updateParams({
+ ...params,
+ filter: "remixable",
+ q: ""
+ });
+ }, [updateParams, params]);
+
+ const onSelectScene = useCallback(
+ scene => {
+ const search = new URLSearchParams();
+ search.set("sceneId", scene.id);
+ history.push(`/projects/new?${search}`);
+ },
+ [history]
+ );
+
+ const { loading, error, entries, hasMore, loadMore } = usePaginatedSearch(
+ `${api.apiURL}/api/v1/media/search`,
+ params
+ );
+
+ const filteredEntries = entries.map(result => ({
+ ...result,
+ url: `/projects/new?sceneId=${result.id}`,
+ thumbnail_url: result && result.images && result.images.preview && result.images.preview.url
+ }));
+
+ return (
+ <>
+
+
+
+
+
+ New Project
+ Back to projects
+
+
+
+
+
+ Featured
+
+
+ All
+
+
+
+
+
+
+
+
+
+
+ {error && {error.message}}
+ {!error && (
+
+
+
+ )}
+
+
+
+
+
+
+ >
+ );
+}
+
+CreateProjectPage.propTypes = {
+ history: PropTypes.object.isRequired,
+ location: PropTypes.object.isRequired
+};
diff --git a/src/ui/projects/NewProjectGridItem.js b/src/ui/projects/NewProjectGridItem.js
deleted file mode 100644
index e7832564..00000000
--- a/src/ui/projects/NewProjectGridItem.js
+++ /dev/null
@@ -1,42 +0,0 @@
-import React, { Component } from "react";
-import { Link } from "react-router-dom";
-import PropTypes from "prop-types";
-import styled from "styled-components";
-import { Plus } from "styled-icons/fa-solid/Plus";
-
-const StyledNewProjectGridItem = styled(Link)`
- display: flex;
- flex-direction: column;
- height: 220px;
- border-radius: 6px;
- text-decoration: none;
- border: 5px dashed ${props => props.theme.panel};
- justify-content: center;
- align-items: center;
-
- &:hover {
- color: ${props => props.theme.text};
- border-color: ${props => props.theme.selected};
- }
-
- svg {
- width: 3em;
- height: 3em;
- margin-bottom: 20px;
- }
-`;
-
-export default class NewProjectGridItem extends Component {
- static propTypes = {
- newProjectUrl: PropTypes.string.isRequired
- };
-
- render() {
- return (
-
-
- New Project
-
- );
- }
-}
diff --git a/src/ui/projects/ProjectGrid.js b/src/ui/projects/ProjectGrid.js
index efcd68de..a2dda960 100644
--- a/src/ui/projects/ProjectGrid.js
+++ b/src/ui/projects/ProjectGrid.js
@@ -1,8 +1,60 @@
-import React, { Component } from "react";
+import React from "react";
import PropTypes from "prop-types";
import styled from "styled-components";
import ProjectGridItem from "./ProjectGridItem";
-import NewProjectGridItem from "./NewProjectGridItem";
+import { Row } from "../layout/Flex";
+import StringInput from "../inputs/StringInput";
+import { Link } from "react-router-dom";
+import { Plus } from "styled-icons/fa-solid/Plus";
+
+const ProjectGridItemContainer = styled.div`
+ display: flex;
+ flex-direction: column;
+ height: 220px;
+ border-radius: 6px;
+ text-decoration: none;
+ background-color: ${props => props.theme.toolbar};
+ justify-content: center;
+ align-items: center;
+ border: 1px solid transparent;
+
+ &:hover {
+ color: inherit;
+ border-color: ${props => props.theme.selected};
+ }
+
+ svg {
+ width: 3em;
+ height: 3em;
+ margin-bottom: 20px;
+ }
+`;
+
+export function NewProjectGridItem({ path, label }) {
+ return (
+
+
+ {label}
+
+ );
+}
+
+NewProjectGridItem.propTypes = {
+ path: PropTypes.oneOfType([PropTypes.string, PropTypes.object]).isRequired,
+ label: PropTypes.string.isRequired
+};
+
+NewProjectGridItem.defaultProps = {
+ label: "New Project"
+};
+
+export function LoadingProjectGridItem() {
+ return (
+
+ Loading...
+
+ );
+}
const StyledProjectGrid = styled.div`
display: grid;
@@ -11,21 +63,84 @@ const StyledProjectGrid = styled.div`
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
`;
-export default class ProjectGrid extends Component {
- static propTypes = {
- contextMenuId: PropTypes.string,
- projects: PropTypes.arrayOf(PropTypes.object).isRequired,
- newProjectUrl: PropTypes.string
- };
-
- render() {
- return (
-
- {this.props.projects.map(project => (
-
- ))}
- {this.props.newProjectUrl && }
-
- );
- }
+export function ProjectGrid({ newProjectPath, newProjectLabel, projects, contextMenuId, loading }) {
+ return (
+
+ {newProjectPath && !loading && }
+ {projects.map(project => (
+
+ ))}
+ {loading && }
+
+ );
}
+
+ProjectGrid.propTypes = {
+ contextMenuId: PropTypes.string,
+ projects: PropTypes.arrayOf(PropTypes.object).isRequired,
+ newProjectPath: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),
+ newProjectLabel: PropTypes.string,
+ loading: PropTypes.bool
+};
+
+export const ProjectGridContainer = styled.div`
+ display: flex;
+ flex: 1;
+ flex-direction: column;
+ background-color: ${props => props.theme.panel2};
+ border-radius: 3px;
+`;
+
+export const ProjectGridContent = styled.div`
+ display: flex;
+ flex: 1;
+ flex-direction: column;
+ padding: 20px;
+`;
+
+export const ProjectGridHeader = styled.div`
+ display: flex;
+ background-color: ${props => props.theme.toolbar2};
+ border-radius: 3px 3px 0px 0px;
+ height: 48px;
+ justify-content: space-between;
+ align-items: center;
+ padding: 0 10px;
+`;
+
+export const Filter = styled.a`
+ font-size: 1.25em;
+ cursor: pointer;
+ color: ${props => (props.active ? props.theme.blue : props.theme.text)};
+`;
+
+export const Separator = styled.div`
+ height: 48px;
+ width: 1px;
+ background-color: ${props => props.theme.border};
+`;
+
+export const ProjectGridHeaderRow = styled(Row)`
+ align-items: center;
+
+ & > * {
+ margin: 0 10px;
+ }
+`;
+
+export const SearchInput = styled(StringInput)`
+ width: auto;
+ min-width: 200px;
+ height: 28px;
+`;
+
+export const CenteredMessage = styled.div`
+ display: flex;
+ flex: 1;
+ justify-content: center;
+ align-items: center;
+`;
+
+export const ErrorMessage = styled(CenteredMessage)`
+ color: ${props => props.theme.red};
+`;
diff --git a/src/ui/projects/ProjectGridItem.js b/src/ui/projects/ProjectGridItem.js
index c6dcd5db..67006667 100644
--- a/src/ui/projects/ProjectGridItem.js
+++ b/src/ui/projects/ProjectGridItem.js
@@ -16,7 +16,7 @@ const StyledProjectGridItem = styled(Link)`
flex-direction: column;
height: 220px;
border-radius: 6px;
- background-color: ${props => props.theme.panel2};
+ background-color: ${props => props.theme.toolbar};
text-decoration: none;
border: 1px solid transparent;
@@ -71,11 +71,18 @@ const Thumbnail = styled.div`
background-size: cover;
background-position: 50%;
background-repeat: no-repeat;
- border-top-left-radius: inherit;
- border-top-right-radius: inherit;
background-image: url(${props => props.src});
`;
+const Col = styled.div`
+ display: flex;
+ flex-direction: column;
+
+ p {
+ color: ${props => props.theme.text2};
+ }
+`;
+
export default class ProjectGridItem extends Component {
static propTypes = {
contextMenuId: PropTypes.string,
@@ -100,12 +107,16 @@ export default class ProjectGridItem extends Component {
render() {
const { project, contextMenuId } = this.props;
+ const creatorAttribution = project.attributions && project.attributions.creator;
const content = (
<>
{project.thumbnail_url && }
- {project.name}
+
+ {project.name}
+ {creatorAttribution && {creatorAttribution}
}
+
{contextMenuId && (
diff --git a/src/ui/projects/ProjectsPage.js b/src/ui/projects/ProjectsPage.js
index 0b6667b4..237950a0 100644
--- a/src/ui/projects/ProjectsPage.js
+++ b/src/ui/projects/ProjectsPage.js
@@ -3,11 +3,18 @@ import PropTypes from "prop-types";
import configs from "../../configs";
import { withApi } from "../contexts/ApiContext";
import NavBar from "../navigation/NavBar";
-import ProjectGrid from "./ProjectGrid";
+import {
+ ProjectGrid,
+ ProjectGridContainer,
+ ProjectGridHeader,
+ ProjectGridHeaderRow,
+ ProjectGridContent,
+ ErrorMessage
+} from "./ProjectGrid";
+import { Button } from "../inputs/Button";
import Footer from "../navigation/Footer";
import { MediumButton } from "../inputs/Button";
import { Link } from "react-router-dom";
-import Loading from "../Loading";
import LatestUpdate from "../whats-new/LatestUpdate";
import { connectMenu, ContextMenu, MenuItem } from "../layout/ContextMenu";
import templates from "./templates";
@@ -16,6 +23,7 @@ import styled from "styled-components";
export const ProjectsSection = styled.section`
padding-bottom: 100px;
display: flex;
+ flex: 1;
&:first-child {
padding-top: 100px;
@@ -62,16 +70,6 @@ export const ProjectsHeader = styled.div`
align-items: center;
`;
-const LoadingContainer = styled.div`
- display: flex;
- flex: 1;
-`;
-
-const ErrorMessage = styled.div`
- margin-bottom: 20px;
- color: ${props => props.theme.red};
-`;
-
const contextMenuId = "project-menu";
class ProjectsPage extends Component {
@@ -141,18 +139,6 @@ class ProjectsPage extends Component {
render() {
const { error, loading, projects, isAuthenticated } = this.state;
- let content;
-
- if (loading) {
- content = (
-
-
-
- );
- } else {
- content = ;
- }
-
const ProjectContextMenu = this.ProjectContextMenu;
const topTemplates = [];
@@ -185,12 +171,28 @@ class ProjectsPage extends Component {
Projects
-
- New Project
-
- {error && {error.message || "There was an unknown error."}}
- {content}
+
+
+
+
+
+
+
+
+ {error && {error.message}}
+ {!error && (
+
+ )}
+
+
diff --git a/src/ui/projects/TemplatesPage.js b/src/ui/projects/TemplatesPage.js
deleted file mode 100644
index 6c2797b4..00000000
--- a/src/ui/projects/TemplatesPage.js
+++ /dev/null
@@ -1,40 +0,0 @@
-import React, { Component } from "react";
-import PropTypes from "prop-types";
-import NavBar from "../navigation/NavBar";
-import ProjectGrid from "./ProjectGrid";
-import Footer from "../navigation/Footer";
-import templates from "./templates";
-import PrimaryLink from "../inputs/PrimaryLink";
-import { ProjectsSection, ProjectsContainer, ProjectsHeader } from "./ProjectsPage";
-
-export default class TemplatesPage extends Component {
- static propTypes = {
- history: PropTypes.object.isRequired
- };
-
- onSelectTemplate = template => {
- const search = new URLSearchParams();
- search.set("template", template.project_url);
- this.props.history.push(`/projects/new?${search}`);
- };
-
- render() {
- return (
- <>
-
-
-
-
-
- Templates
- Back to projects
-
-
-
-
-
-
- >
- );
- }
-}
diff --git a/src/ui/projects/usePaginatedSearch.js b/src/ui/projects/usePaginatedSearch.js
new file mode 100644
index 00000000..91494011
--- /dev/null
+++ b/src/ui/projects/usePaginatedSearch.js
@@ -0,0 +1,66 @@
+import { useState, useCallback, useEffect, useRef } from "react";
+import { useFetch } from "use-http";
+
+export default function usePaginatedSearch(path, queryParams, options = {}) {
+ const urlRef = useRef();
+
+ if (!urlRef.current) {
+ urlRef.current = new URL(path, window.location);
+
+ for (const name in queryParams) {
+ urlRef.current.searchParams.set(name, queryParams[name]);
+ }
+ }
+
+ const [href, setHref] = useState(urlRef.current.href);
+
+ useEffect(() => {
+ urlRef.current = new URL(path, window.location);
+
+ for (const name in queryParams) {
+ urlRef.current.searchParams.set(name, queryParams[name]);
+ }
+
+ setHref(urlRef.current.href);
+ }, [path, urlRef, queryParams]);
+
+ const cursor = urlRef.current.searchParams.get("cursor");
+
+ const {
+ loading,
+ error,
+ data: {
+ entries,
+ meta: { next_cursor }
+ }
+ } = useFetch(
+ href,
+ {
+ headers: {
+ "content-type": "application/json",
+ ...options.headers
+ },
+ onNewData: (data, newData) => {
+ if (!cursor) {
+ return newData;
+ } else {
+ return {
+ entries: [...data.entries, ...newData.entries],
+ meta: newData.meta
+ };
+ }
+ },
+ data: { entries: [], meta: { next_cursor: null } }
+ },
+ [href]
+ );
+
+ const loadMore = useCallback(() => {
+ urlRef.current.searchParams.set("cursor", next_cursor);
+ setHref(urlRef.current.href);
+ }, [urlRef, next_cursor]);
+
+ const hasMore = next_cursor && cursor !== next_cursor;
+
+ return { loading, error, entries: !cursor && loading ? [] : entries, loadMore, hasMore };
+}
diff --git a/src/ui/router/ScrollToTop.js b/src/ui/router/ScrollToTop.js
new file mode 100644
index 00000000..d1adaa13
--- /dev/null
+++ b/src/ui/router/ScrollToTop.js
@@ -0,0 +1,12 @@
+import { useEffect } from "react";
+import { useLocation } from "react-router-dom";
+
+export default function ScrollToTop() {
+ const { pathname } = useLocation();
+
+ useEffect(() => {
+ window.scrollTo(0, 0);
+ }, [pathname]);
+
+ return null;
+}
diff --git a/yarn.lock b/yarn.lock
index b06f7377..35de89c3 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -10828,23 +10828,23 @@ react-popper@^1.3.3:
typed-styles "^0.0.7"
warning "^4.0.2"
-react-router-dom@^5.0.1:
- version "5.0.1"
- resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-5.0.1.tgz#ee66f4a5d18b6089c361958e443489d6bab714be"
- integrity sha512-zaVHSy7NN0G91/Bz9GD4owex5+eop+KvgbxXsP/O+iW1/Ln+BrJ8QiIR5a6xNPtrdTvLkxqlDClx13QO1uB8CA==
+react-router-dom@^5.1.2:
+ version "5.1.2"
+ resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-5.1.2.tgz#06701b834352f44d37fbb6311f870f84c76b9c18"
+ integrity sha512-7BPHAaIwWpZS074UKaw1FjVdZBSVWEk8IuDXdB+OkLb8vd/WRQIpA4ag9WQk61aEfQs47wHyjWUoUGGZxpQXew==
dependencies:
"@babel/runtime" "^7.1.2"
history "^4.9.0"
loose-envify "^1.3.1"
prop-types "^15.6.2"
- react-router "5.0.1"
+ react-router "5.1.2"
tiny-invariant "^1.0.2"
tiny-warning "^1.0.0"
-react-router@5.0.1:
- version "5.0.1"
- resolved "https://registry.yarnpkg.com/react-router/-/react-router-5.0.1.tgz#04ee77df1d1ab6cb8939f9f01ad5702dbadb8b0f"
- integrity sha512-EM7suCPNKb1NxcTZ2LEOWFtQBQRQXecLxVpdsP4DW4PbbqYWeRiLyV/Tt1SdCrvT2jcyXAXmVTmzvSzrPR63Bg==
+react-router@5.1.2:
+ version "5.1.2"
+ resolved "https://registry.yarnpkg.com/react-router/-/react-router-5.1.2.tgz#6ea51d789cb36a6be1ba5f7c0d48dd9e817d3418"
+ integrity sha512-yjEuMFy1ONK246B+rsa0cUam5OeAQ8pyclRDgpxuSCrAlJ1qN9uZ5IgyKC7gQg0w8OM50NXHEegPh/ks9YuR2A==
dependencies:
"@babel/runtime" "^7.1.2"
history "^4.9.0"
@@ -13409,6 +13409,18 @@ url@^0.11.0:
punycode "1.3.2"
querystring "0.2.0"
+use-http@^0.2.4:
+ version "0.2.4"
+ resolved "https://registry.yarnpkg.com/use-http/-/use-http-0.2.4.tgz#d107b5c809d6f9fc4ed44d9a37cf7fa37cb94a6e"
+ integrity sha512-4bkXaZc7Ac44N2VnewYkla1fsy4jX1j2wf2JMUfJD3j/e8W4RHbF6qZOJfbU0keaPTk+aUe8XYYN12IMIFsqEQ==
+ dependencies:
+ use-ssr "^1.0.22"
+
+use-ssr@^1.0.22:
+ version "1.0.22"
+ resolved "https://registry.yarnpkg.com/use-ssr/-/use-ssr-1.0.22.tgz#a43c2587b1907fabda61c6542b80542c619228fe"
+ integrity sha512-0kA0qfI4uw7PeRsz7X0XOdl2CdbgMBSFhj3n5JzMu8ZNlcZ2NrarhGjdmoxW1Q5d3WtNKbCNIjns/CKhpL7z8g==
+
use@^3.1.0:
version "3.1.1"
resolved "https://registry.yarnpkg.com/use/-/use-3.1.1.tgz#d50c8cac79a19fbc20f2911f56eb973f4e10070f"