Merge pull request #865 from mozilla/feature/remixing

Scene Remixing
This commit is contained in:
Robert Long 2020-01-08 14:58:54 -08:00 коммит произвёл GitHub
Родитель 97ba55cc40 103b7a8764
Коммит 675be40be4
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
14 изменённых файлов: 463 добавлений и 148 удалений

Просмотреть файл

@ -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": {

Просмотреть файл

@ -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") {

Просмотреть файл

@ -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 {
<RedirectRoute path="/new" exact to="/projects" />
<Route path="/login" exact component={LoginPage} />
<Route path="/logout" exact component={LogoutPage} />
<Route path="/projects/templates" exact component={TemplatesPage} />
<Route path="/projects/create" exact component={CreateProjectPage} />
<RedirectRoute path="/projects/templates" exact to="/projects/create" />
<Route path="/projects" exact component={ProjectsPage} />
<Route path="/projects/:projectId" component={EditorContainer} />
<Route path="/kits/package" component={PackageKitPage} />

Просмотреть файл

@ -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();

Просмотреть файл

@ -1,6 +1,6 @@
import React from "react";
const ApiContext = React.createContext();
export const ApiContext = React.createContext();
export const ApiContextProvider = ApiContext.Provider;

Просмотреть файл

@ -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 (
<>
<NavBar />
<main>
<ProjectsSection>
<ProjectsContainer>
<ProjectsHeader>
<h1>New Project</h1>
<PrimaryLink to="/projects">Back to projects</PrimaryLink>
</ProjectsHeader>
<ProjectGridContainer>
<ProjectGridHeader>
<ProjectGridHeaderRow>
<Filter onClick={onSetFeaturedRemixable} active={params.filter === "featured-remixable"}>
Featured
</Filter>
<Filter onClick={onSetAll} active={params.filter === "remixable"}>
All
</Filter>
<Separator />
<SearchInput placeholder="Search scenes..." value={params.q} onChange={onChangeQuery} />
</ProjectGridHeaderRow>
<ProjectGridHeaderRow>
<Button as={Link} to="/projects/new">
New Empty Project
</Button>
</ProjectGridHeaderRow>
</ProjectGridHeader>
<ProjectGridContent>
<ScrollToTop />
{error && <ErrorMessage>{error.message}</ErrorMessage>}
{!error && (
<InfiniteScroll
initialLoad={false}
pageStart={0}
loadMore={loadMore}
hasMore={hasMore}
threshold={100}
useWindow={true}
>
<ProjectGrid
projects={filteredEntries}
newProjectPath="/projects/new"
newProjectLabel="New Empty Project"
onSelectProject={onSelectScene}
loading={loading}
/>
</InfiniteScroll>
)}
</ProjectGridContent>
</ProjectGridContainer>
</ProjectsContainer>
</ProjectsSection>
</main>
<Footer />
</>
);
}
CreateProjectPage.propTypes = {
history: PropTypes.object.isRequired,
location: PropTypes.object.isRequired
};

Просмотреть файл

@ -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 (
<StyledNewProjectGridItem to={this.props.newProjectUrl}>
<Plus />
<h3>New Project</h3>
</StyledNewProjectGridItem>
);
}
}

Просмотреть файл

@ -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 (
<ProjectGridItemContainer as={Link} to={path}>
<Plus />
<h3>{label}</h3>
</ProjectGridItemContainer>
);
}
NewProjectGridItem.propTypes = {
path: PropTypes.oneOfType([PropTypes.string, PropTypes.object]).isRequired,
label: PropTypes.string.isRequired
};
NewProjectGridItem.defaultProps = {
label: "New Project"
};
export function LoadingProjectGridItem() {
return (
<ProjectGridItemContainer>
<h3>Loading...</h3>
</ProjectGridItemContainer>
);
}
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 (
<StyledProjectGrid>
{this.props.projects.map(project => (
<ProjectGridItem key={project.project_id} project={project} contextMenuId={this.props.contextMenuId} />
))}
{this.props.newProjectUrl && <NewProjectGridItem newProjectUrl={this.props.newProjectUrl} />}
</StyledProjectGrid>
);
}
export function ProjectGrid({ newProjectPath, newProjectLabel, projects, contextMenuId, loading }) {
return (
<StyledProjectGrid>
{newProjectPath && !loading && <NewProjectGridItem path={newProjectPath} label={newProjectLabel} />}
{projects.map(project => (
<ProjectGridItem key={project.project_id || project.id} project={project} contextMenuId={contextMenuId} />
))}
{loading && <LoadingProjectGridItem />}
</StyledProjectGrid>
);
}
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};
`;

Просмотреть файл

@ -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 = (
<>
<ThumbnailContainer>{project.thumbnail_url && <Thumbnail src={project.thumbnail_url} />}</ThumbnailContainer>
<TitleContainer>
<h3>{project.name}</h3>
<Col>
<h3>{project.name}</h3>
{creatorAttribution && <p>{creatorAttribution}</p>}
</Col>
{contextMenuId && (
<MenuButton onClick={this.onShowMenu}>
<EllipsisV />

Просмотреть файл

@ -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 = (
<LoadingContainer>
<Loading message="Loading projects..." />
</LoadingContainer>
);
} else {
content = <ProjectGrid projects={projects} newProjectUrl="/projects/templates" contextMenuId={contextMenuId} />;
}
const ProjectContextMenu = this.ProjectContextMenu;
const topTemplates = [];
@ -185,12 +171,28 @@ class ProjectsPage extends Component {
<ProjectsContainer>
<ProjectsHeader>
<h1>Projects</h1>
<MediumButton as={Link} to="/projects/templates">
New Project
</MediumButton>
</ProjectsHeader>
{error && <ErrorMessage>{error.message || "There was an unknown error."}</ErrorMessage>}
{content}
<ProjectGridContainer>
<ProjectGridHeader>
<ProjectGridHeaderRow></ProjectGridHeaderRow>
<ProjectGridHeaderRow>
<Button as={Link} to="/projects/create">
New Project
</Button>
</ProjectGridHeaderRow>
</ProjectGridHeader>
<ProjectGridContent>
{error && <ErrorMessage>{error.message}</ErrorMessage>}
{!error && (
<ProjectGrid
loading={loading}
projects={projects}
newProjectPath="/projects/templates"
contextMenuId={contextMenuId}
/>
)}
</ProjectGridContent>
</ProjectGridContainer>
</ProjectsContainer>
</ProjectsSection>
<ProjectContextMenu />

Просмотреть файл

@ -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 (
<>
<NavBar />
<main>
<ProjectsSection>
<ProjectsContainer>
<ProjectsHeader>
<h1>Templates</h1>
<PrimaryLink to="/projects">Back to projects</PrimaryLink>
</ProjectsHeader>
<ProjectGrid projects={templates} onSelectProject={this.onSelectTemplate} />
</ProjectsContainer>
</ProjectsSection>
</main>
<Footer />
</>
);
}
}

Просмотреть файл

@ -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 };
}

Просмотреть файл

@ -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;
}

Просмотреть файл

@ -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"