diff --git a/.gitignore b/.gitignore index b9a9e57..a445daf 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ cypress/screenshots packaged coverage public/service-worker.js +private-key.json diff --git a/components/Announcement.js b/components/Announcement.js new file mode 100644 index 0000000..ae02bea --- /dev/null +++ b/components/Announcement.js @@ -0,0 +1,93 @@ +import React from 'react' + +const key = 'CARBON_CTA_1' + +class Toast extends React.Component { + state = { + open: false + } + + close = () => { + this.setState({ open: false }) + window.localStorage.setItem(key, true) + } + + componentDidMount() { + window.localStorage.removeItem('CARBON_CTA') + if (!window.localStorage.getItem(key)) { + this.setState({ open: true }) + } + } + + render() { + if (process.env.NODE_ENV !== 'production') { + return null + } + + if (!this.state.open) { + return null + } + + return ( +
+
+

Never lose a Carbon again - save your work automatically!

+ Click "Sign up" to try it out! + +
+ +
+ ) + } +} + +export default Toast diff --git a/components/ApiContext.js b/components/ApiContext.js index a2950ac..94c7ad1 100644 --- a/components/ApiContext.js +++ b/components/ApiContext.js @@ -1,4 +1,10 @@ import React from 'react' import api from '../lib/api' -export default React.createContext(api) +const Context = React.createContext(api) + +export function useAPI() { + return React.useContext(Context) +} + +export default Context diff --git a/components/AuthContext.js b/components/AuthContext.js new file mode 100644 index 0000000..1a1f4e3 --- /dev/null +++ b/components/AuthContext.js @@ -0,0 +1,34 @@ +import React from 'react' +import firebase from '../lib/client' +// IDEA: just read from firebase store at request time? +import { client } from '../lib/api' + +export const Context = React.createContext(null) + +export function useAuth() { + return React.useContext(Context) +} + +function AuthContext(props) { + const [user, setState] = React.useState(null) + + React.useEffect(() => { + if (firebase) { + firebase.auth().onAuthStateChanged(newUser => setState(newUser)) + } + }, []) + + React.useEffect(() => { + if (user) { + user.getIdToken().then(jwt => { + client.defaults.headers['Authorization'] = jwt ? `Bearer ${jwt}` : undefined + }) + } else { + delete client.defaults.headers['Authorization'] + } + }, [user]) + + return {props.children} +} + +export default AuthContext diff --git a/components/Button.js b/components/Button.js index c8050e1..1a6fd19 100644 --- a/components/Button.js +++ b/components/Button.js @@ -21,6 +21,7 @@ const Button = ({ margin = 0, title, Component = 'button', + display, ...props }) => ( @@ -29,7 +30,7 @@ const Button = ({ - ) -} - function isImage(file) { return file.type.split('/')[0] === 'image' } diff --git a/components/EditorContainer.js b/components/EditorContainer.js index e43402a..7837ec2 100644 --- a/components/EditorContainer.js +++ b/components/EditorContainer.js @@ -2,11 +2,47 @@ import React from 'react' import Editor from './Editor' -import { THEMES } from '../lib/constants' -import { getThemes, saveThemes } from '../lib/util' +import Toasts from './Toasts' +import { THEMES, DEFAULT_CODE } from '../lib/constants' +import { updateRouteState } from '../lib/routing' +import { getThemes, saveThemes, clearSettings, saveSettings } from '../lib/util' + +import { useAPI } from './ApiContext' +import { useAuth } from './AuthContext' +import { useAsyncCallback } from '@dawnlabs/tacklebox' + +function onReset() { + clearSettings() + + if (window.navigator && navigator.serviceWorker) { + navigator.serviceWorker.getRegistrations().then(registrations => { + for (let registration of registrations) { + registration.unregister() + } + }) + } +} + +function toastsReducer(curr, action) { + switch (action.type) { + case 'ADD': { + if (!curr.find(t => t.children === action.toast.children)) { + return curr.concat(action.toast) + } + return curr + } + case 'SET': { + return action.toasts + } + } + throw new Error('Unsupported action') +} function EditorContainer(props) { const [themes, updateThemes] = React.useState(THEMES) + const api = useAPI() + const user = useAuth() + const [update, { loading }] = useAsyncCallback(api.snippet.update) React.useEffect(() => { const storedThemes = getThemes(localStorage) || [] @@ -19,7 +55,67 @@ function EditorContainer(props) { saveThemes(themes.filter(({ custom }) => custom)) }, [themes]) - return + // XXX use context + const [snippet, setSnippet] = React.useState(props.snippet || null) + const [toasts, setToasts] = React.useReducer(toastsReducer, []) + + const snippetId = snippet && snippet.id + React.useEffect(() => { + if ('/' + (snippetId || '') === props.router.asPath) { + return + } + props.router.replace('/', '/' + (snippetId || ''), { shallow: true }) + }, [snippetId, props.router]) + + function onEditorUpdate(state) { + if (loading) { + return + } + + if (!user) { + updateRouteState(props.router, state) + saveSettings(state) + } else { + const updates = { + ...state, + code: state.code != null ? state.code : DEFAULT_CODE + } + if (!snippet) { + update(snippetId, updates).then(newSnippet => { + if (newSnippet && newSnippet.id) { + setSnippet(newSnippet) + setToasts({ + type: 'ADD', + toast: { children: 'Snippet saved!', closable: true } + }) + } + }) + } else if (snippet.userId === user.uid) { + update(snippetId, updates).then(() => { + setToasts({ + type: 'ADD', + toast: { children: 'Snippet saved!', closable: true } + }) + }) + } + } + } + + return ( + <> + + + + ) } export default EditorContainer diff --git a/components/ExportMenu.js b/components/ExportMenu.js index eb7a4f1..522ce64 100644 --- a/components/ExportMenu.js +++ b/components/ExportMenu.js @@ -141,7 +141,7 @@ function ExportMenu({
- Save as + Export as
{!disablePNG && ( + + +
+ ) +} + +export default managePopout(LoginButton) diff --git a/components/MenuButton.js b/components/MenuButton.js new file mode 100644 index 0000000..1449bcd --- /dev/null +++ b/components/MenuButton.js @@ -0,0 +1,47 @@ +import React from 'react' + +import Button from './Button' +import { COLORS } from '../lib/constants' +import * as Arrows from './svg/Arrows' + +const MenuButton = React.memo(({ name, select, selected, noArrows }) => { + return ( +
+ + +
+ ) +}) + +export default MenuButton diff --git a/components/Page.js b/components/Page.js index 6976a85..dbf884e 100644 --- a/components/Page.js +++ b/components/Page.js @@ -1,7 +1,9 @@ import React from 'react' +import AuthContext from './AuthContext' import Meta from './Meta' import Header from './Header' import Footer from './Footer' +import Announcement from './Announcement' class Page extends React.Component { render() { @@ -9,8 +11,11 @@ class Page extends React.Component { return (
+
-
{children}
+ +
{children}
+
diff --git a/components/RandomImage.js b/components/RandomImage.js index 8d870f2..220deda 100644 --- a/components/RandomImage.js +++ b/components/RandomImage.js @@ -2,13 +2,13 @@ import React from 'react' import Spinner from 'react-spinner' import { useAsyncCallback } from '@dawnlabs/tacklebox' -import ApiContext from './ApiContext' +import { useAPI } from './ApiContext' import PhotoCredit from './PhotoCredit' function RandomImage(props) { const cacheRef = React.useRef([]) const [cacheIndex, updateIndex] = React.useState(0) - const api = React.useContext(ApiContext) + const api = useAPI() const [selectImage, { loading: selecting }] = useAsyncCallback(() => { const image = cacheRef.current[cacheIndex] diff --git a/components/Settings.js b/components/Settings.js index a461576..5bac917 100644 --- a/components/Settings.js +++ b/components/Settings.js @@ -9,10 +9,10 @@ import Toggle from './Toggle' import Popout, { managePopout } from './Popout' import Button from './Button' import Presets from './Presets' +import MenuButton from './MenuButton' import { COLORS, DEFAULT_PRESETS } from '../lib/constants' import { toggle, getPresets, savePresets, generateId, fileToJSON } from '../lib/util' import SettingsIcon from './svg/Settings' -import * as Arrows from './svg/Arrows' const WindowSettings = React.memo( ({ @@ -182,7 +182,6 @@ const MiscSettings = React.memo(({ format, reset, applyPreset, settings }) => { }} /> - -
- ) -}) - const settingButtonStyle = { width: '40px', height: '100%' diff --git a/components/SnippetToolbar.js b/components/SnippetToolbar.js new file mode 100644 index 0000000..ac2c4e2 --- /dev/null +++ b/components/SnippetToolbar.js @@ -0,0 +1,104 @@ +import React from 'react' +import { useAsyncCallback, useOnline } from '@dawnlabs/tacklebox' +import Button from './Button' + +import { useAuth } from './AuthContext' +import Toolbar from './Toolbar' + +function ConfirmButton(props) { + const [confirmed, setConfirmed] = React.useState(false) + + return ( + + ) +} + +function DeleteButton(props) { + const [onClick, { loading }] = useAsyncCallback(props.onClick) + + return ( + + {loading ? 'Deleting...' : 'Delete'} + + ) +} + +function ForkButton(props) { + const [onClick, { loading }] = useAsyncCallback(props.onClick) + + // TODO call it duplicate or fork? + return ( + + ) +} + +function SnippetToolbar(props) { + const user = useAuth() + const online = useOnline() + + if (!online) { + return null + } + + if (!user) { + return null + } + + if (!props.snippet) { + return null + } + + const sameUser = user.uid === props.snippet.userId + + return ( + + + {sameUser && } + + ) +} + +export default SnippetToolbar diff --git a/components/Toasts.js b/components/Toasts.js new file mode 100644 index 0000000..e61217b --- /dev/null +++ b/components/Toasts.js @@ -0,0 +1,116 @@ +import React from 'react' + +function Toast(props) { + const [display, on] = React.useState(true) + + function off() { + return on(false) + } + + React.useEffect(() => { + if (props.timeout) { + const to = setTimeout(off, props.timeout) + return () => clearTimeout(to) + } + }, [props.timeout]) + + return ( +
+
+ {props.children} + {props.closable && ( + + )} +
+ +
+ ) +} + +function ToastContainer(props) { + return ( +
+ {props.toasts + .slice() + .reverse() + .map(toast => ( + + ))} + +
+ ) +} + +export default ToastContainer diff --git a/components/Toolbar.js b/components/Toolbar.js index 1d06501..b9085ba 100644 --- a/components/Toolbar.js +++ b/components/Toolbar.js @@ -1,7 +1,7 @@ import React from 'react' const Toolbar = props => ( -
+
{props.children}