diff --git a/package.json b/package.json index a8da7a72..b42b4ad1 100644 --- a/package.json +++ b/package.json @@ -18,15 +18,18 @@ "devDependencies": { "@types/acorn": "^4.0.3", "@types/codemirror": "0.0.51", + "@types/history": "^4.6.2", "@types/node": "^8.9.4", "acorn": "^5.5.0", "codemirror": "^5.35.0", "he": "^1.1.1", + "history": "^4.7.2", "ncp": "^2.0.0", "node-fetch": "^1.7.3", "node-sass": "^4.9.0", "parcel-bundler": "^1.8.1", "parcel-plugin-markdown": "^0.3.1", + "path-to-regexp": "^2.2.1", "preact": "^8.2.7", "prettier": "^1.12.1", "puppeteer": "^0.13.0", diff --git a/src/app.tsx b/src/app.tsx new file mode 100644 index 00000000..952f1551 --- /dev/null +++ b/src/app.tsx @@ -0,0 +1,234 @@ +/*! + Copyright 2018 Propel http://propel.site/. All rights reserved. + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +import { Component, ComponentConstructor, h } from "preact"; +import * as db from "./db"; +import { push, Router } from "./router"; +import * as types from "./types"; +import { equal } from "./util"; + +import { ErrorPage } from "./components/error"; +import { GlobalHeader } from "./components/header"; +import { Home } from "./components/home"; +import { Loading } from "./components/loading"; +import { UserMenu } from "./components/menu"; +import { Notebook } from "./components/notebook"; +import { Profile } from "./components/profile"; +import { Recent } from "./components/recent"; + +type Partial = { [K in keyof T]?: T[K] }; + +type ReadOnly = { readonly [K in keyof T]: T[K] }; + +interface PageProps { + path: string; + matches?: { [key: string]: string }; + onReady?: () => void; +} + +type BindProps

= { + [K in keyof P]?: (props: ReadOnly>) => Promise +}; + +type BoundProps

= PageProps & BindProps

; + +interface BindStateNormal

{ + data: { [K in keyof P]: P[K] }; + error: null; +} + +interface BindStateError { + data: null; + error: string; +} + +type BindState

= BindStateNormal

| BindStateError; + +/** + * This react HOC can be used to bind result of some async + * methods to props of the given component (C). + * see: https://reactjs.org/docs/higher-order-components.html + * + * const newComponent = bind(Component, { + * async prop(props) { + * const re = await someAsyncActions(); + * return re; + * } + * }); + */ +function bind

(C: ComponentConstructor, bindProps: BindProps

) { + return class extends Component, BindState

> { + state = { data: null, error: null }; + prevMatches = null; + componentRef; + + private onReady() { + if (this.props.onReady) this.props.onReady(); + } + + async loadData() { + if (equal(this.props.matches, this.prevMatches)) return; + this.prevMatches = this.props.matches; + const data: Partial

= {}; + for (const key in bindProps) { + if (!bindProps[key]) continue; + try { + data[key] = await bindProps[key](this.props); + } catch (e) { + this.setState({ data: null, error: e.message }); + return; + } + } + this.setState({ data: data as P, error: null }); + } + + render() { + this.loadData(); + const { data, error } = this.state; + if (error) return ; + if (!data) return ; + this.onReady(); + return (this.componentRef = r)} {...this.props} {...data} />; + } + }; +} + +// An anonymous notebook doc for when users aren't logged in +export const anonDoc = { + anonymous: true, + cells: [], + created: new Date(), + owner: { + displayName: "Anonymous", + photoURL: require("./img/anon_profile.png"), + uid: "" + }, + title: "Anonymous Notebook", + updated: new Date() +}; + +// TODO Move these components to ./pages.tsx. +// tslint:disable:variable-name +async function onNewNotebook() { + const nbId = await db.active.create(); + // Redirect to new notebook. + push(`/notebook/${nbId}`); +} + +async function onOpenNotebook(nbId: string) { + // Redirect to notebook. + push(`/notebook/${nbId}`); +} + +export const RecentPage = bind(Recent, { + notebooks() { + return db.active.queryLatest(); + }, + async onNewNotebook() { + return () => onNewNotebook(); + }, + async onOpenNotebook() { + return (nbId: string) => onOpenNotebook(nbId); + } +}); + +export const ProfilePage = bind(Profile, { + notebooks(props) { + const uid = props.matches.userId; + return db.active.queryProfile(uid, 100); + }, + async onNewNotebook() { + return () => onNewNotebook(); + }, + async onOpenNotebook() { + return (nbId: string) => onOpenNotebook(nbId); + } +}); + +export const NotebookPage = bind(Notebook, { + initialDoc(props) { + const nbId = props.matches.nbId; + return nbId === "anonymous" + ? Promise.resolve(anonDoc) + : db.active.getDoc(nbId); + }, + save(props) { + const nbId = props.matches.nbId; + const cb = async doc => { + if (doc.anonymous) return; + if (!props.userInfo) return; + if (props.userInfo.uid !== doc.owner.uid) return; + try { + await db.active.updateDoc(nbId, doc); + } catch (e) { + // TODO + console.log(e); + } + }; + return Promise.resolve(cb); + }, + clone(props) { + const cb = async doc => { + const cloneId = await db.active.clone(doc); + // Redirect to new notebook. + push(`/notebook/${cloneId}`); + }; + return Promise.resolve(cb); + } +}); + +export const HomePage = bind(Home as any, {}); +// tslint:enable:variable-name + +export interface AppState { + loadingAuth: boolean; + userInfo: types.UserInfo; +} + +export class App extends Component<{}, AppState> { + state = { + loadingAuth: true, + userInfo: null + }; + + unsubscribe: db.UnsubscribeCb; + componentWillMount() { + this.unsubscribe = db.active.subscribeAuthChange(userInfo => { + this.setState({ loadingAuth: false, userInfo }); + }); + } + + componentWillUnmount() { + this.unsubscribe(); + } + + render() { + const { userInfo } = this.state; + return ( +

+ + + + + + + + + + +
+ ); + } +} diff --git a/src/components/error.tsx b/src/components/error.tsx new file mode 100644 index 00000000..2f11a224 --- /dev/null +++ b/src/components/error.tsx @@ -0,0 +1,32 @@ +/*! + Copyright 2018 Propel http://propel.site/. All rights reserved. + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +import { h } from "preact"; + +export interface ErrorPageProps { + header?: string; + message: string; +} + +export function ErrorPage(props: ErrorPageProps) { + return ( +
+
+

{ props.header || "Error" }

+

{ props.message }

+
+
+ ); +} diff --git a/src/components/footer.tsx b/src/components/footer.tsx index b0efefd1..9e93f257 100644 --- a/src/components/footer.tsx +++ b/src/components/footer.tsx @@ -14,14 +14,15 @@ */ import { h } from "preact"; +import { Link } from "./link"; export function Footer(): JSX.Element { return ( ); } diff --git a/src/components/home.tsx b/src/components/home.tsx index bbce7ad1..0b682095 100644 --- a/src/components/home.tsx +++ b/src/components/home.tsx @@ -13,14 +13,12 @@ limitations under the License. */ import { h } from "preact"; -import { GlobalHeader } from "./header"; // tslint:disable-next-line:variable-name export const Home = props => { const md = require("../../README.md"); return (
-
diff --git a/src/components/link.tsx b/src/components/link.tsx new file mode 100644 index 00000000..fb66236f --- /dev/null +++ b/src/components/link.tsx @@ -0,0 +1,28 @@ +/*! + Copyright 2018 Propel http://propel.site/. All rights reserved. + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +import { h } from "preact"; +import { push } from "../router"; + +export interface LinkProps extends JSX.HTMLAttributes { + href: string; +} + +export function Link(props: LinkProps) { + const { href, children, ...otherProps } = props; + return ( + push(href)} {...otherProps} > { children } + ); +} diff --git a/src/components/logo.tsx b/src/components/logo.tsx index 192aa58e..3a98f0f5 100644 --- a/src/components/logo.tsx +++ b/src/components/logo.tsx @@ -14,6 +14,7 @@ */ import { h } from "preact"; +import { Link } from "./link"; export interface PropelLogoProps { subtitle?: string; @@ -26,7 +27,7 @@ export function PropelLogo(props: PropelLogoProps): JSX.Element { if (props.subtitle) { Subtitle = (

- {props.subtitle} + {props.subtitle}

); } @@ -45,7 +46,7 @@ export function PropelLogo(props: PropelLogoProps): JSX.Element {

- Propel + Propel

{Subtitle}
diff --git a/src/components/menu.tsx b/src/components/menu.tsx index fea805bc..7e9a39e4 100644 --- a/src/components/menu.tsx +++ b/src/components/menu.tsx @@ -26,6 +26,7 @@ export interface UserMenuProps { export function UserMenu(props): JSX.Element { if (props.userInfo) { + // TODO "Your notebooks" link return (