diff --git a/now.api.json b/now.api.json index 06a4845..b183c65 100644 --- a/now.api.json +++ b/now.api.json @@ -5,13 +5,11 @@ "cloud": "v1" }, "public": true, - "alias": [ - "api.internote.app" - ], + "alias": ["api.internote.app"], "scale": { "bru1": { "min": 1, "max": "auto" } } -} \ No newline at end of file +} diff --git a/now.ui.json b/now.ui.json index 0f208da..375cb12 100644 --- a/now.ui.json +++ b/now.ui.json @@ -1,8 +1,15 @@ { "name": "internote-ui", "type": "docker", + "features": { + "cloud": "v1" + }, "public": true, - "alias": [ - "internote.app" - ] -} \ No newline at end of file + "alias": ["internote.app"], + "scale": { + "bru1": { + "min": 1, + "max": "auto" + } + } +} diff --git a/ui/store/index.ts b/ui/store/index.ts index a3d62e4..077691f 100644 --- a/ui/store/index.ts +++ b/ui/store/index.ts @@ -6,8 +6,8 @@ import { removeAuthenticationCookie } from "../utilities/cookie"; import Router from "next/router"; - -const api = makeApi(process.env.API_BASE_URL); +import { makeSubscriber } from "@internote/ui/store/make-subscriber"; +import { AxiosError } from "axios"; export interface State { session: Types.Session | null; @@ -21,6 +21,7 @@ export interface State { } interface Reducers { + resetState: Twine.Reducer0; setSession: Twine.Reducer; setNotes: Twine.Reducer; setNote: Twine.Reducer; @@ -52,12 +53,14 @@ interface Effects { >; signOut: Twine.Effect0; deleteAccount: Twine.Effect0>; + handleApiError: Twine.Effect; } export type Actions = Twine.Actions; -const model: Twine.Model = { - state: { +function defaultState(): State { + removeAuthenticationCookie(); + return { session: null, notes: [], note: null, @@ -66,148 +69,164 @@ const model: Twine.Model = { deleteNoteModalOpen: false, signOutModalOpen: false, deleteAccountModalOpen: false - }, - reducers: { - setSession(state, session) { - if (session) { - setAuthenticationCookie(session.token); - } else { - removeAuthenticationCookie(); + }; +} + +function makeModel(): Twine.Model { + const api = makeApi(process.env.API_BASE_URL); + + return { + state: defaultState(), + reducers: { + resetState: defaultState, + setSession(state, session) { + if (session) { + setAuthenticationCookie(session.token); + } else { + removeAuthenticationCookie(); + } + return { + ...state, + session + }; + }, + setLoading(state, loading) { + return { + ...state, + loading + }; + }, + setNotes(state, notes) { + return { + ...state, + notes + }; + }, + setNote(state, note) { + return { + ...state, + note + }; + }, + setSidebarOpen(state, sidebarOpen) { + return { + ...state, + sidebarOpen + }; + }, + setDeleteNoteModalOpen(state, deleteNoteModalOpen) { + return { + ...state, + deleteNoteModalOpen + }; + }, + setSignOutModalOpen(state, signOutModalOpen) { + return { + ...state, + signOutModalOpen + }; + }, + setDeleteAccountModalOpen(state, deleteAccountModalOpen) { + return { + ...state, + deleteAccountModalOpen + }; } - return { - ...state, - session - }; - }, - setLoading(state, loading) { - return { - ...state, - loading - }; - }, - setNotes(state, notes) { - return { - ...state, - notes - }; - }, - setNote(state, note) { - return { - ...state, - note - }; }, - setSidebarOpen(state, sidebarOpen) { - return { - ...state, - sidebarOpen - }; - }, - setDeleteNoteModalOpen(state, deleteNoteModalOpen) { - return { - ...state, - deleteNoteModalOpen - }; - }, - setSignOutModalOpen(state, signOutModalOpen) { - return { - ...state, - signOutModalOpen - }; - }, - setDeleteAccountModalOpen(state, deleteAccountModalOpen) { - return { - ...state, - deleteAccountModalOpen - }; - } - }, - effects: { - async fetchNotes(state, actions) { - actions.setLoading(true); - const notes = await api.note.findAll(state.session.token); - actions.setNotes(notes); - actions.setLoading(false); - }, - async fetchNote(state, actions, id) { - const existingNote = state.notes.find(note => note.id === id); + effects: { + async fetchNotes(state, actions) { + actions.setLoading(true); + const notes = await api.note.findAll(state.session.token); + actions.setNotes(notes); + actions.setLoading(false); + }, + async fetchNote(state, actions, id) { + const existingNote = state.notes.find(note => note.id === id); - if (existingNote) { - actions.setNote(existingNote); - } else { + if (existingNote) { + actions.setNote(existingNote); + } else { + actions.setLoading(true); + const result = await api.note.findById(state.session.token, id); + result.map(actions.setNote); + actions.setLoading(false); + } + }, + async newNote(state, actions) { actions.setLoading(true); - const result = await api.note.findById(state.session.token, id); - result.map(note => { - actions.setNote(note); + const note = await api.note.create(state.session.token, { + content: "TODO dummy content" }); + Router.push(`/?id=${note.id}`); actions.setLoading(false); + }, + async saveNote(state, actions, { content }) { + const newNote = { + ...state.note, + content + }; + actions.setNote(newNote); + actions.setNotes( + state.notes.map(note => (note.id === newNote.id ? newNote : note)) + ); + actions.setLoading(true); + await api.note.updateById(state.session.token, state.note.id, { + content + }); + actions.setLoading(false); + }, + async deleteNote(state, actions) { + actions.setLoading(true); + await api.note.deleteById(state.session.token, state.note.id); + actions.setNotes(await api.note.findAll(state.session.token)); + actions.setNote(null); + actions.setDeleteNoteModalOpen(false); + // TODO: Show a toast message here + Router.push("/"); + }, + async register(_state, actions, { email, password }) { + const session = await api.auth.register({ email, password }); + actions.setSession(session); + // TODO: Show a toast message here + Router.push("/"); + }, + session(_state, actions, token) { + return api.auth + .session(token) + .then(session => { + actions.setSession(session); + }) + .catch(actions.handleApiError); + }, + async authenticate(_state, actions, { email, password }) { + const session = await api.auth.login({ email, password }); + actions.setSession(session); + // TODO: Show a toast message here + Router.push("/"); + }, + async signOut(_state, actions) { + actions.resetState(); + Router.push("/login"); + }, + async deleteAccount(state, actions) { + actions.resetState(); + await api.user.deleteById(state.session.token, state.session.user.id); + Router.push("/register"); + }, + handleApiError(_state, actions, error) { + if (error.response.status === 401) { + // TODO: Show a toast message here + actions.signOut(); + } } - }, - async newNote(state, actions) { - actions.setLoading(true); - const note = await api.note.create(state.session.token, { - content: "New note" - }); - Router.push(`/?id=${note.id}`); - actions.setLoading(false); - }, - async saveNote(state, actions, { content }) { - const newNote = { - ...state.note, - content - }; - actions.setNote(newNote); - actions.setNotes( - state.notes.map(note => (note.id === newNote.id ? newNote : note)) - ); - actions.setLoading(true); - await api.note.updateById(state.session.token, state.note.id, { - content - }); - actions.setLoading(false); - }, - async deleteNote(state, actions) { - actions.setLoading(true); - await api.note.deleteById(state.session.token, state.note.id); - actions.setNotes(await api.note.findAll(state.session.token)); - actions.setNote(null); - actions.setDeleteNoteModalOpen(false); - Router.push("/"); - }, - async register(_state, actions, { email, password }) { - const session = await api.auth.register({ email, password }); - actions.setSession(session); - Router.push("/"); - }, - async session(_state, actions, token) { - const session = await api.auth.session(token); - actions.setSession(session); - }, - async authenticate(_state, actions, { email, password }) { - const session = await api.auth.login({ email, password }); - actions.setSession(session); - Router.push("/"); - }, - async signOut(_state, actions) { - actions.setSession(null); - actions.setNotes([]); - actions.setNote(null); - actions.setSignOutModalOpen(false); - Router.push("/login"); - }, - async deleteAccount(state, actions) { - actions.setSession(null); - actions.setNotes([]); - actions.setNote(null); - actions.setDeleteAccountModalOpen(false); - await api.user.deleteById(state.session.token, state.session.user.id); - Router.push("/register"); } - } -}; + }; +} export function makeStore() { - return twine(model); + return twine(makeModel()); } export type Store = Twine.Return; + +export const Subscribe = makeSubscriber(makeStore()); diff --git a/ui/store/make-subscriber.tsx b/ui/store/make-subscriber.tsx new file mode 100644 index 0000000..b6a0aa0 --- /dev/null +++ b/ui/store/make-subscriber.tsx @@ -0,0 +1,50 @@ +import * as React from "react"; +import { Twine } from "twine-js"; + +export function makeSubscriber>(store: S) { + type ConnectedComponent = ( + store: { actions: S["actions"]; state: S["state"] } + ) => React.ReactNode; + + interface Props { + children: ConnectedComponent | React.ReactNode; + } + + interface ComponentState { + state: S["actions"]; + actions: S["state"]; + } + + return class Subscribe extends React.Component { + unsubscribe = null; + + constructor(props: Props) { + super(props); + + this.state = { + state: store.state, + actions: store.actions + }; + } + + componentDidMount() { + this.unsubscribe = store.subscribe(this.onStoreChange); + } + + componentWillUnmount() { + if (this.unsubscribe) { + this.unsubscribe; + } + } + + onStoreChange = (state: S["state"]) => { + this.setState({ state }); + }; + + render() { + return typeof this.props.children === "function" + ? this.props.children(this.state) + : this.props.children; + } + }; +} diff --git a/ui/store/subscribe.tsx b/ui/store/subscribe.tsx deleted file mode 100644 index c5f6b8f..0000000 --- a/ui/store/subscribe.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import * as React from "react"; -import { store, State, Actions } from "./index"; - -type ConnectedComponent = ( - store: { actions: Actions; state: State } -) => React.ReactNode; - -interface Props { - children: ConnectedComponent | React.ReactNode; -} - -interface ComponentState { - state: State; - actions: Actions; -} - -export class Subscribe extends React.Component { - constructor(props: Props) { - super(props); - - this.state = { - state: store.state, - actions: store.actions - }; - } - - componentDidMount() { - store.subscribe(this.onStoreChange); - } - - onStoreChange = (state: State) => { - this.setState({ state }); - }; - - render() { - return typeof this.props.children === "function" - ? this.props.children(this.state) - : this.props.children; - } -} diff --git a/ui/store/with-twine.tsx b/ui/store/with-twine.tsx index b709f4d..68eadf4 100644 --- a/ui/store/with-twine.tsx +++ b/ui/store/with-twine.tsx @@ -1,6 +1,7 @@ import * as React from "react"; import { Twine } from "twine-js"; -import { NextContext, QueryStringMapObject } from "next"; +import { NextContext } from "next"; +import { DefaultQuery } from "next/router"; const STORE_KEY = "__TWINE_STORE__"; @@ -98,7 +99,7 @@ export interface NextTwineSFC< State, Actions, ExtraProps = {}, - Query = QueryStringMapObject + Query extends DefaultQuery = DefaultQuery > extends React.StatelessComponent< ExtraProps & { diff --git a/ui/styles/logo.tsx b/ui/styles/logo.tsx index 5f965f9..13f6aac 100644 --- a/ui/styles/logo.tsx +++ b/ui/styles/logo.tsx @@ -1,6 +1,7 @@ import * as React from "react"; import styled from "styled-components"; import { font, color } from "../styles/theme"; +import { Subscribe } from "../store"; const Box = styled.div` font-weight: bold; @@ -18,5 +19,9 @@ const Box = styled.div` `; export function Logo() { - return INTERNOTE; + return ( + + {store => INTERNOTE {store.state.loading.toString()}} + + ); }