зеркало из https://github.com/mozilla/Spoke.git
Коммит
675be40be4
|
@ -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;
|
||||
}
|
30
yarn.lock
30
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"
|
||||
|
|
Загрузка…
Ссылка в новой задаче