feat: Installable PWA (#1882)

This commit is contained in:
Tom Moor 2021-02-15 15:19:51 -08:00 committed by GitHub
parent 4b603460cb
commit 7e922d8716
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 1093 additions and 75 deletions

View File

@ -11,7 +11,7 @@ type Props = {|
const Container = styled.div` const Container = styled.div`
width: 100%; width: 100%;
max-width: 100vw; max-width: 100vw;
padding: ${(props) => (props.withStickyHeader ? "4px 20px" : "60px 20px")}; padding: ${(props) => (props.withStickyHeader ? "4px 12px" : "60px 12px")};
${breakpoint("tablet")` ${breakpoint("tablet")`
padding: ${(props) => (props.withStickyHeader ? "4px 60px" : "60px")}; padding: ${(props) => (props.withStickyHeader ? "4px 60px" : "60px")};

View File

@ -163,8 +163,11 @@ const DocumentLink = styled(Link)`
padding: 6px 8px; padding: 6px 8px;
border-radius: 8px; border-radius: 8px;
max-height: 50vh; max-height: 50vh;
min-width: 100%; width: calc(100vw - 8px);
max-width: calc(100vw - 40px);
${breakpoint("tablet")`
width: auto;
`};
${Actions} { ${Actions} {
opacity: 0; opacity: 0;

View File

@ -7,7 +7,7 @@ import { Helmet } from "react-helmet";
import { withTranslation, type TFunction } from "react-i18next"; import { withTranslation, type TFunction } from "react-i18next";
import keydown from "react-keydown"; import keydown from "react-keydown";
import { Switch, Route, Redirect } from "react-router-dom"; import { Switch, Route, Redirect } from "react-router-dom";
import styled, { withTheme } from "styled-components"; import styled from "styled-components";
import breakpoint from "styled-components-breakpoint"; import breakpoint from "styled-components-breakpoint";
import AuthStore from "stores/AuthStore"; import AuthStore from "stores/AuthStore";
import DocumentsStore from "stores/DocumentsStore"; import DocumentsStore from "stores/DocumentsStore";
@ -24,7 +24,6 @@ import Sidebar from "components/Sidebar";
import SettingsSidebar from "components/Sidebar/Settings"; import SettingsSidebar from "components/Sidebar/Settings";
import SkipNavContent from "components/SkipNavContent"; import SkipNavContent from "components/SkipNavContent";
import SkipNavLink from "components/SkipNavLink"; import SkipNavLink from "components/SkipNavLink";
import { type Theme } from "types";
import { meta } from "utils/keyboard"; import { meta } from "utils/keyboard";
import { import {
homeUrl, homeUrl,
@ -40,7 +39,6 @@ type Props = {
auth: AuthStore, auth: AuthStore,
ui: UiStore, ui: UiStore,
notifications?: React.Node, notifications?: React.Node,
theme: Theme,
i18n: Object, i18n: Object,
t: TFunction, t: TFunction,
}; };
@ -51,24 +49,12 @@ class Layout extends React.Component<Props> {
@observable redirectTo: ?string; @observable redirectTo: ?string;
@observable keyboardShortcutsOpen: boolean = false; @observable keyboardShortcutsOpen: boolean = false;
constructor(props: Props) {
super();
this.updateBackground(props);
}
componentDidUpdate() { componentDidUpdate() {
this.updateBackground(this.props);
if (this.redirectTo) { if (this.redirectTo) {
this.redirectTo = undefined; this.redirectTo = undefined;
} }
} }
updateBackground(props: Props) {
// ensure the wider page color always matches the theme
window.document.body.style.background = props.theme.background;
}
@keydown(`${meta}+.`) @keydown(`${meta}+.`)
handleToggleSidebar() { handleToggleSidebar() {
this.props.ui.toggleCollapsedSidebar(); this.props.ui.toggleCollapsedSidebar();
@ -212,5 +198,5 @@ const Content = styled(Flex)`
`; `;
export default withTranslation()<Layout>( export default withTranslation()<Layout>(
inject("auth", "ui", "documents")(withTheme(Layout)) inject("auth", "ui", "documents")(Layout)
); );

View File

@ -0,0 +1,35 @@
// @flow
import * as React from "react";
import { useTheme } from "styled-components";
import useStores from "hooks/useStores";
export default function PageTheme() {
const { ui } = useStores();
const theme = useTheme();
React.useEffect(() => {
// wider page background beyond the React root
if (document.body) {
document.body.style.background = theme.background;
}
// theme-color adjusts the title bar color for desktop PWA
const themeElement = document.querySelector('meta[name="theme-color"]');
if (themeElement) {
themeElement.setAttribute("content", theme.background);
}
// status bar color for iOS PWA
const statusElement = document.querySelector(
'meta[name="apple-mobile-web-app-status-bar-style"]'
);
if (statusElement) {
statusElement.setAttribute(
"content",
ui.resolvedTheme === "dark" ? "black-translucent" : "default"
);
}
}, [theme, ui.resolvedTheme]);
return null;
}

View File

@ -46,6 +46,7 @@ export const ToggleButton = styled.button`
`; `;
export const Positioner = styled.div` export const Positioner = styled.div`
display: none;
z-index: 2; z-index: 2;
position: absolute; position: absolute;
top: 0; top: 0;
@ -56,6 +57,10 @@ export const Positioner = styled.div`
&:hover ${ToggleButton}, &:focus-within ${ToggleButton} { &:hover ${ToggleButton}, &:focus-within ${ToggleButton} {
opacity: 1; opacity: 1;
} }
${breakpoint("tablet")`
display: block;
`}
`; `;
export default Toggle; export default Toggle;

View File

@ -33,7 +33,7 @@ const Sticky = styled.div`
padding: 0 8px; padding: 0 8px;
background: ${(props) => props.theme.background}; background: ${(props) => props.theme.background};
transition: ${(props) => props.theme.backgroundTransition}; transition: ${(props) => props.theme.backgroundTransition};
z-index: ${(props) => props.theme.depths.stickyHeader}; z-index: 1;
`; `;
const Subheading = ({ children, ...rest }: Props) => { const Subheading = ({ children, ...rest }: Props) => {

View File

@ -19,7 +19,7 @@ const Sticky = styled.div`
padding: 0 8px; padding: 0 8px;
background: ${(props) => props.theme.background}; background: ${(props) => props.theme.background};
transition: ${(props) => props.theme.backgroundTransition}; transition: ${(props) => props.theme.backgroundTransition};
z-index: ${(props) => props.theme.depths.stickyHeader}; z-index: 1;
`; `;
export const Separator = styled.span` export const Separator = styled.span`

View File

@ -10,6 +10,7 @@ import { Router } from "react-router-dom";
import { initI18n } from "shared/i18n"; import { initI18n } from "shared/i18n";
import stores from "stores"; import stores from "stores";
import ErrorBoundary from "components/ErrorBoundary"; import ErrorBoundary from "components/ErrorBoundary";
import PageTheme from "components/PageTheme";
import ScrollToTop from "components/ScrollToTop"; import ScrollToTop from "components/ScrollToTop";
import Theme from "components/Theme"; import Theme from "components/Theme";
import Toasts from "components/Toasts"; import Toasts from "components/Toasts";
@ -19,13 +20,28 @@ import { initSentry } from "utils/sentry";
initI18n(); initI18n();
const element = document.getElementById("root"); const element = window.document.getElementById("root");
const history = createBrowserHistory(); const history = createBrowserHistory();
if (env.SENTRY_DSN) { if (env.SENTRY_DSN) {
initSentry(history); initSentry(history);
} }
if ("serviceWorker" in window.navigator) {
window.addEventListener("load", () => {
window.navigator.serviceWorker
.register("/static/service-worker.js", {
scope: "/",
})
.then((registration) => {
console.log("SW registered: ", registration);
})
.catch((registrationError) => {
console.log("SW registration failed: ", registrationError);
});
});
}
if (element) { if (element) {
render( render(
<Provider {...stores}> <Provider {...stores}>
@ -34,6 +50,7 @@ if (element) {
<DndProvider backend={HTML5Backend}> <DndProvider backend={HTML5Backend}>
<Router history={history}> <Router history={history}>
<> <>
<PageTheme />
<ScrollToTop> <ScrollToTop>
<Routes /> <Routes />
</ScrollToTop> </ScrollToTop>

View File

@ -7,7 +7,6 @@ import { observer, inject } from "mobx-react";
import * as React from "react"; import * as React from "react";
import type { RouterHistory, Match } from "react-router-dom"; import type { RouterHistory, Match } from "react-router-dom";
import { withRouter } from "react-router-dom"; import { withRouter } from "react-router-dom";
import { withTheme } from "styled-components";
import parseDocumentSlug from "shared/utils/parseDocumentSlug"; import parseDocumentSlug from "shared/utils/parseDocumentSlug";
import DocumentsStore from "stores/DocumentsStore"; import DocumentsStore from "stores/DocumentsStore";
import PoliciesStore from "stores/PoliciesStore"; import PoliciesStore from "stores/PoliciesStore";
@ -22,7 +21,7 @@ import DocumentComponent from "./Document";
import HideSidebar from "./HideSidebar"; import HideSidebar from "./HideSidebar";
import Loading from "./Loading"; import Loading from "./Loading";
import SocketPresence from "./SocketPresence"; import SocketPresence from "./SocketPresence";
import { type LocationWithState, type Theme } from "types"; import { type LocationWithState } from "types";
import { NotFoundError, OfflineError } from "utils/errors"; import { NotFoundError, OfflineError } from "utils/errors";
import { matchDocumentEdit, updateDocumentUrl } from "utils/routeHelpers"; import { matchDocumentEdit, updateDocumentUrl } from "utils/routeHelpers";
import { isInternalUrl } from "utils/urls"; import { isInternalUrl } from "utils/urls";
@ -35,7 +34,6 @@ type Props = {|
policies: PoliciesStore, policies: PoliciesStore,
revisions: RevisionsStore, revisions: RevisionsStore,
ui: UiStore, ui: UiStore,
theme: Theme,
history: RouterHistory, history: RouterHistory,
|}; |};
@ -49,7 +47,6 @@ class DataLoader extends React.Component<Props> {
const { documents, match } = this.props; const { documents, match } = this.props;
this.document = documents.getByUrl(match.params.documentSlug); this.document = documents.getByUrl(match.params.documentSlug);
this.loadDocument(); this.loadDocument();
this.updateBackground();
} }
componentDidUpdate(prevProps: Props) { componentDidUpdate(prevProps: Props) {
@ -74,13 +71,6 @@ class DataLoader extends React.Component<Props> {
) { ) {
this.loadRevision(); this.loadRevision();
} }
this.updateBackground();
}
updateBackground() {
// ensure the wider page color always matches the theme. This is to
// account for share links which don't sit in the wider Layout component
window.document.body.style.background = this.props.theme.background;
} }
get isEditing() { get isEditing() {
@ -266,5 +256,5 @@ export default withRouter(
"revisions", "revisions",
"policies", "policies",
"shares" "shares"
)(withTheme(DataLoader)) )(DataLoader)
); );

View File

@ -480,7 +480,7 @@ const ReferencesWrapper = styled("div")`
const MaxWidth = styled(Flex)` const MaxWidth = styled(Flex)`
${(props) => ${(props) =>
props.archived && `* { color: ${props.theme.textSecondary} !important; } `}; props.archived && `* { color: ${props.theme.textSecondary} !important; } `};
padding: 0 16px; padding: 0 12px;
max-width: 100vw; max-width: 100vw;
width: 100%; width: 100%;

View File

@ -206,11 +206,13 @@
"url-loader": "^0.6.2", "url-loader": "^0.6.2",
"webpack": "4.44.1", "webpack": "4.44.1",
"webpack-cli": "^3.3.12", "webpack-cli": "^3.3.12",
"webpack-manifest-plugin": "^3.0.0" "webpack-manifest-plugin": "^3.0.0",
"webpack-pwa-manifest": "^4.3.0",
"workbox-webpack-plugin": "^6.1.0"
}, },
"resolutions": { "resolutions": {
"dot-prop": "^5.2.0", "dot-prop": "^5.2.0",
"js-yaml": "^3.13.1" "js-yaml": "^3.13.1"
}, },
"version": "0.52.0" "version": "0.52.0"
} }

BIN
public/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

BIN
public/icon-256.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 48 KiB

View File

@ -1,20 +0,0 @@
{
"short_name": "Outline",
"name": "Outline",
"icons": [
{
"src": "/icon-192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "/icon-512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": "/home?source=pwa",
"background_color": "#FFFFFF",
"display": "standalone",
"theme_color": "#FFFFFF"
}

View File

@ -67,6 +67,8 @@ router.get("/_health", (ctx) => (ctx.body = "OK"));
if (process.env.NODE_ENV === "production") { if (process.env.NODE_ENV === "production") {
router.get("/static/*", async (ctx) => { router.get("/static/*", async (ctx) => {
ctx.set({ ctx.set({
"Service-Worker-Allowed": "/",
"Access-Control-Allow-Origin": "*",
"Cache-Control": `max-age=${356 * 24 * 60 * 60}`, "Cache-Control": `max-age=${356 * 24 * 60 * 60}`,
}); });

View File

@ -2,8 +2,11 @@
<html lang="en"> <html lang="en">
<head> <head>
<title>Outline</title> <title>Outline</title>
<meta name="theme-color" content="#FFF" />
<meta name="slack-app-id" content="//inject-slack-app-id//" /> <meta name="slack-app-id" content="//inject-slack-app-id//" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="description" content="A modern team knowledge base for your internal documentation, product specs, support answers, meeting notes, onboarding, &amp; more…"> <meta name="description" content="A modern team knowledge base for your internal documentation, product specs, support answers, meeting notes, onboarding, &amp; more…">
//inject-prefetch// //inject-prefetch//
<link <link
@ -12,7 +15,12 @@
href="/favicon-32.png" href="/favicon-32.png"
sizes="32x32" sizes="32x32"
/> />
<link rel="manifest" href="/manifest.json" crossorigin="use-credentials" /> <link
rel="apple-touch-icon"
type="image/png"
href="/apple-touch-icon.png"
sizes="192x192"
/>
<link <link
rel="search" rel="search"
type="application/opensearchdescription+xml" type="application/opensearchdescription+xml"
@ -46,7 +54,9 @@
</script> </script>
<script> <script>
if (window.localStorage && window.localStorage.getItem("theme") === "dark") { if (window.localStorage && window.localStorage.getItem("theme") === "dark") {
window.document.querySelector("#root").style.background = "#111319"; var color = "#111319";
document.querySelector("#root").style.background = color;
document.querySelector('meta[name="theme-color"]').setAttribute("content", color);
} }
</script> </script>
</body> </body>

View File

@ -37,6 +37,21 @@ export default createGlobalStyle`
text-rendering: optimizeLegibility; text-rendering: optimizeLegibility;
} }
@media (min-width: ${(props) =>
props.theme.breakpoints.tablet}px) and (display-mode: standalone) {
body:after {
content: "";
display: block;
position: fixed;
top: 0;
left: 0;
right: 0;
height: 1px;
background: ${(props) => props.theme.divider};
z-index: ${(props) => props.theme.depths.pwaSeparator};
}
}
a { a {
color: ${(props) => props.theme.link}; color: ${(props) => props.theme.link};
text-decoration: none; text-decoration: none;

View File

@ -108,13 +108,13 @@ export const base = {
depths: { depths: {
sidebar: 1000, sidebar: 1000,
stickyHeader: 1500,
modalOverlay: 2000, modalOverlay: 2000,
modal: 3000, modal: 3000,
menu: 4000, menu: 4000,
toasts: 5000, toasts: 5000,
loadingIndicatorBar: 6000, loadingIndicatorBar: 6000,
popover: 9000, popover: 9000,
pwaSeparator: 10000,
}, },
}; };

View File

@ -4,6 +4,8 @@ const webpack = require('webpack');
const HtmlWebpackPlugin = require('html-webpack-plugin'); const HtmlWebpackPlugin = require('html-webpack-plugin');
const { RelativeCiAgentWebpackPlugin } = require('@relative-ci/agent'); const { RelativeCiAgentWebpackPlugin } = require('@relative-ci/agent');
const pkg = require("rich-markdown-editor/package.json"); const pkg = require("rich-markdown-editor/package.json");
const WebpackPwaManifest = require("webpack-pwa-manifest");
const WorkboxPlugin = require("workbox-webpack-plugin");
require('dotenv').config({ silent: true }); require('dotenv').config({ silent: true });
@ -59,6 +61,30 @@ module.exports = {
new HtmlWebpackPlugin({ new HtmlWebpackPlugin({
template: 'server/static/index.html', template: 'server/static/index.html',
}), }),
new WebpackPwaManifest({
name: "Outline",
short_name: "Outline",
background_color: "#fff",
theme_color: "#fff",
start_url: process.env.URL,
display: "standalone",
icons: [
{
src: path.resolve("public/icon-512.png"),
// For Chrome, you must provide at least a 192x192 pixel icon, and a 512x512 pixel icon.
// If only those two icon sizes are provided, Chrome will automatically scale the icons
// to fit the device. If you'd prefer to scale your own icons, and adjust them for
// pixel-perfection, provide icons in increments of 48dp.
sizes: [512, 192],
purpose: "any maskable",
},
]
}),
new WorkboxPlugin.GenerateSW({
clientsClaim: true,
skipWaiting: true,
maximumFileSizeToCacheInBytes: 15 * 1024 * 1024, // For large bundles
}),
new RelativeCiAgentWebpackPlugin(), new RelativeCiAgentWebpackPlugin(),
], ],
stats: { stats: {

977
yarn.lock

File diff suppressed because it is too large Load Diff