feat: Installable PWA (#1882)
This commit is contained in:
parent
4b603460cb
commit
7e922d8716
|
@ -11,7 +11,7 @@ type Props = {|
|
|||
const Container = styled.div`
|
||||
width: 100%;
|
||||
max-width: 100vw;
|
||||
padding: ${(props) => (props.withStickyHeader ? "4px 20px" : "60px 20px")};
|
||||
padding: ${(props) => (props.withStickyHeader ? "4px 12px" : "60px 12px")};
|
||||
|
||||
${breakpoint("tablet")`
|
||||
padding: ${(props) => (props.withStickyHeader ? "4px 60px" : "60px")};
|
||||
|
|
|
@ -163,8 +163,11 @@ const DocumentLink = styled(Link)`
|
|||
padding: 6px 8px;
|
||||
border-radius: 8px;
|
||||
max-height: 50vh;
|
||||
min-width: 100%;
|
||||
max-width: calc(100vw - 40px);
|
||||
width: calc(100vw - 8px);
|
||||
|
||||
${breakpoint("tablet")`
|
||||
width: auto;
|
||||
`};
|
||||
|
||||
${Actions} {
|
||||
opacity: 0;
|
||||
|
|
|
@ -7,7 +7,7 @@ import { Helmet } from "react-helmet";
|
|||
import { withTranslation, type TFunction } from "react-i18next";
|
||||
import keydown from "react-keydown";
|
||||
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 AuthStore from "stores/AuthStore";
|
||||
import DocumentsStore from "stores/DocumentsStore";
|
||||
|
@ -24,7 +24,6 @@ import Sidebar from "components/Sidebar";
|
|||
import SettingsSidebar from "components/Sidebar/Settings";
|
||||
import SkipNavContent from "components/SkipNavContent";
|
||||
import SkipNavLink from "components/SkipNavLink";
|
||||
import { type Theme } from "types";
|
||||
import { meta } from "utils/keyboard";
|
||||
import {
|
||||
homeUrl,
|
||||
|
@ -40,7 +39,6 @@ type Props = {
|
|||
auth: AuthStore,
|
||||
ui: UiStore,
|
||||
notifications?: React.Node,
|
||||
theme: Theme,
|
||||
i18n: Object,
|
||||
t: TFunction,
|
||||
};
|
||||
|
@ -51,24 +49,12 @@ class Layout extends React.Component<Props> {
|
|||
@observable redirectTo: ?string;
|
||||
@observable keyboardShortcutsOpen: boolean = false;
|
||||
|
||||
constructor(props: Props) {
|
||||
super();
|
||||
this.updateBackground(props);
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
this.updateBackground(this.props);
|
||||
|
||||
if (this.redirectTo) {
|
||||
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}+.`)
|
||||
handleToggleSidebar() {
|
||||
this.props.ui.toggleCollapsedSidebar();
|
||||
|
@ -212,5 +198,5 @@ const Content = styled(Flex)`
|
|||
`;
|
||||
|
||||
export default withTranslation()<Layout>(
|
||||
inject("auth", "ui", "documents")(withTheme(Layout))
|
||||
inject("auth", "ui", "documents")(Layout)
|
||||
);
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -46,6 +46,7 @@ export const ToggleButton = styled.button`
|
|||
`;
|
||||
|
||||
export const Positioner = styled.div`
|
||||
display: none;
|
||||
z-index: 2;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
|
@ -56,6 +57,10 @@ export const Positioner = styled.div`
|
|||
&:hover ${ToggleButton}, &:focus-within ${ToggleButton} {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
display: block;
|
||||
`}
|
||||
`;
|
||||
|
||||
export default Toggle;
|
||||
|
|
|
@ -33,7 +33,7 @@ const Sticky = styled.div`
|
|||
padding: 0 8px;
|
||||
background: ${(props) => props.theme.background};
|
||||
transition: ${(props) => props.theme.backgroundTransition};
|
||||
z-index: ${(props) => props.theme.depths.stickyHeader};
|
||||
z-index: 1;
|
||||
`;
|
||||
|
||||
const Subheading = ({ children, ...rest }: Props) => {
|
||||
|
|
|
@ -19,7 +19,7 @@ const Sticky = styled.div`
|
|||
padding: 0 8px;
|
||||
background: ${(props) => props.theme.background};
|
||||
transition: ${(props) => props.theme.backgroundTransition};
|
||||
z-index: ${(props) => props.theme.depths.stickyHeader};
|
||||
z-index: 1;
|
||||
`;
|
||||
|
||||
export const Separator = styled.span`
|
||||
|
|
19
app/index.js
19
app/index.js
|
@ -10,6 +10,7 @@ import { Router } from "react-router-dom";
|
|||
import { initI18n } from "shared/i18n";
|
||||
import stores from "stores";
|
||||
import ErrorBoundary from "components/ErrorBoundary";
|
||||
import PageTheme from "components/PageTheme";
|
||||
import ScrollToTop from "components/ScrollToTop";
|
||||
import Theme from "components/Theme";
|
||||
import Toasts from "components/Toasts";
|
||||
|
@ -19,13 +20,28 @@ import { initSentry } from "utils/sentry";
|
|||
|
||||
initI18n();
|
||||
|
||||
const element = document.getElementById("root");
|
||||
const element = window.document.getElementById("root");
|
||||
const history = createBrowserHistory();
|
||||
|
||||
if (env.SENTRY_DSN) {
|
||||
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) {
|
||||
render(
|
||||
<Provider {...stores}>
|
||||
|
@ -34,6 +50,7 @@ if (element) {
|
|||
<DndProvider backend={HTML5Backend}>
|
||||
<Router history={history}>
|
||||
<>
|
||||
<PageTheme />
|
||||
<ScrollToTop>
|
||||
<Routes />
|
||||
</ScrollToTop>
|
||||
|
|
|
@ -7,7 +7,6 @@ import { observer, inject } from "mobx-react";
|
|||
import * as React from "react";
|
||||
import type { RouterHistory, Match } from "react-router-dom";
|
||||
import { withRouter } from "react-router-dom";
|
||||
import { withTheme } from "styled-components";
|
||||
import parseDocumentSlug from "shared/utils/parseDocumentSlug";
|
||||
import DocumentsStore from "stores/DocumentsStore";
|
||||
import PoliciesStore from "stores/PoliciesStore";
|
||||
|
@ -22,7 +21,7 @@ import DocumentComponent from "./Document";
|
|||
import HideSidebar from "./HideSidebar";
|
||||
import Loading from "./Loading";
|
||||
import SocketPresence from "./SocketPresence";
|
||||
import { type LocationWithState, type Theme } from "types";
|
||||
import { type LocationWithState } from "types";
|
||||
import { NotFoundError, OfflineError } from "utils/errors";
|
||||
import { matchDocumentEdit, updateDocumentUrl } from "utils/routeHelpers";
|
||||
import { isInternalUrl } from "utils/urls";
|
||||
|
@ -35,7 +34,6 @@ type Props = {|
|
|||
policies: PoliciesStore,
|
||||
revisions: RevisionsStore,
|
||||
ui: UiStore,
|
||||
theme: Theme,
|
||||
history: RouterHistory,
|
||||
|};
|
||||
|
||||
|
@ -49,7 +47,6 @@ class DataLoader extends React.Component<Props> {
|
|||
const { documents, match } = this.props;
|
||||
this.document = documents.getByUrl(match.params.documentSlug);
|
||||
this.loadDocument();
|
||||
this.updateBackground();
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: Props) {
|
||||
|
@ -74,13 +71,6 @@ class DataLoader extends React.Component<Props> {
|
|||
) {
|
||||
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() {
|
||||
|
@ -266,5 +256,5 @@ export default withRouter(
|
|||
"revisions",
|
||||
"policies",
|
||||
"shares"
|
||||
)(withTheme(DataLoader))
|
||||
)(DataLoader)
|
||||
);
|
||||
|
|
|
@ -480,7 +480,7 @@ const ReferencesWrapper = styled("div")`
|
|||
const MaxWidth = styled(Flex)`
|
||||
${(props) =>
|
||||
props.archived && `* { color: ${props.theme.textSecondary} !important; } `};
|
||||
padding: 0 16px;
|
||||
padding: 0 12px;
|
||||
max-width: 100vw;
|
||||
width: 100%;
|
||||
|
||||
|
|
|
@ -206,11 +206,13 @@
|
|||
"url-loader": "^0.6.2",
|
||||
"webpack": "4.44.1",
|
||||
"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": {
|
||||
"dot-prop": "^5.2.0",
|
||||
"js-yaml": "^3.13.1"
|
||||
},
|
||||
"version": "0.52.0"
|
||||
}
|
||||
}
|
||||
|
|
Binary file not shown.
After Width: | Height: | Size: 2.5 KiB |
Binary file not shown.
Before Width: | Height: | Size: 17 KiB |
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 |
|
@ -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"
|
||||
}
|
|
@ -67,6 +67,8 @@ router.get("/_health", (ctx) => (ctx.body = "OK"));
|
|||
if (process.env.NODE_ENV === "production") {
|
||||
router.get("/static/*", async (ctx) => {
|
||||
ctx.set({
|
||||
"Service-Worker-Allowed": "/",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Cache-Control": `max-age=${356 * 24 * 60 * 60}`,
|
||||
});
|
||||
|
||||
|
|
|
@ -2,8 +2,11 @@
|
|||
<html lang="en">
|
||||
<head>
|
||||
<title>Outline</title>
|
||||
<meta name="theme-color" content="#FFF" />
|
||||
<meta name="slack-app-id" content="//inject-slack-app-id//" />
|
||||
<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, & more…">
|
||||
//inject-prefetch//
|
||||
<link
|
||||
|
@ -12,7 +15,12 @@
|
|||
href="/favicon-32.png"
|
||||
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
|
||||
rel="search"
|
||||
type="application/opensearchdescription+xml"
|
||||
|
@ -46,7 +54,9 @@
|
|||
</script>
|
||||
<script>
|
||||
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>
|
||||
</body>
|
||||
|
|
|
@ -37,6 +37,21 @@ export default createGlobalStyle`
|
|||
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 {
|
||||
color: ${(props) => props.theme.link};
|
||||
text-decoration: none;
|
||||
|
|
|
@ -108,13 +108,13 @@ export const base = {
|
|||
|
||||
depths: {
|
||||
sidebar: 1000,
|
||||
stickyHeader: 1500,
|
||||
modalOverlay: 2000,
|
||||
modal: 3000,
|
||||
menu: 4000,
|
||||
toasts: 5000,
|
||||
loadingIndicatorBar: 6000,
|
||||
popover: 9000,
|
||||
pwaSeparator: 10000,
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
@ -4,6 +4,8 @@ const webpack = require('webpack');
|
|||
const HtmlWebpackPlugin = require('html-webpack-plugin');
|
||||
const { RelativeCiAgentWebpackPlugin } = require('@relative-ci/agent');
|
||||
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 });
|
||||
|
||||
|
@ -59,6 +61,30 @@ module.exports = {
|
|||
new HtmlWebpackPlugin({
|
||||
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(),
|
||||
],
|
||||
stats: {
|
||||
|
|
Reference in New Issue