diff --git a/generic-tsconfig.json b/generic-tsconfig.json index 1b41eb10..63be8ac0 100644 --- a/generic-tsconfig.json +++ b/generic-tsconfig.json @@ -16,6 +16,7 @@ "paths": { "static-build/*": ["src/static-build/*"], "client/*": ["src/client/*"], + "shared/*": ["src/shared/*"], "features/*": ["src/features/*"] } } diff --git a/lib/css-plugin.js b/lib/css-plugin.js index 44d78243..f2f4a6a3 100644 --- a/lib/css-plugin.js +++ b/lib/css-plugin.js @@ -76,7 +76,7 @@ export default function (resolveFileUrl) { }), postCSSUrl({ url: ({ relativePath, url }) => { - if (/^https?:\/\//.test(url)) return url; + if (/^(https?|data):/.test(url)) return url; const parsedPath = parsePath(relativePath); const source = readFileSync( resolvePath(dirname(path), relativePath), diff --git a/lib/initial-css-plugin.js b/lib/initial-css-plugin.js new file mode 100644 index 00000000..f9b2e3d8 --- /dev/null +++ b/lib/initial-css-plugin.js @@ -0,0 +1,53 @@ +/** + * Copyright 2020 Google Inc. 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 { promisify } from 'util'; + +import path from 'path'; +import glob from 'glob'; + +const globP = promisify(glob); + +const moduleId = 'initial-css:'; + +export default function initialCssPlugin() { + return { + name: 'initial-css-plugin', + resolveId(id) { + if (id === moduleId) return moduleId; + }, + async load(id) { + if (id !== moduleId) return; + + const matches = await globP('shared/initial-app/**/*.css', { + nodir: true, + cwd: path.join(process.cwd(), 'src'), + }); + + // Sort the matches so the parentmost items appear first. + // This is a bit of a hack, but it means the util stuff appears in the cascade first. + const sortedMatches = matches + .map((match) => match.split(path.sep)) + .sort((a, b) => a.length - b.length) + .map((match) => path.join(...match)); + + const imports = sortedMatches + .map((id, i) => `import css${i} from 'css:${id}';\n`) + .join(''); + + return ( + imports + + `export default ${sortedMatches.map((_, i) => `css${i}`).join(' + ')};` + ); + }, + }; +} diff --git a/lib/asset-plugin.js b/lib/url-plugin.js similarity index 100% rename from lib/asset-plugin.js rename to lib/url-plugin.js diff --git a/missing-types.d.ts b/missing-types.d.ts index a1ed1d40..c01a181a 100644 --- a/missing-types.d.ts +++ b/missing-types.d.ts @@ -27,4 +27,9 @@ declare module 'css:*' { export default source; } +declare var ga: { + (...args: any[]): void; + q: any[]; +}; + declare const __PRODUCTION__: boolean; diff --git a/rollup.config.js b/rollup.config.js index c76e2e5d..5fb2d4db 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -23,11 +23,12 @@ import simpleTS from './lib/simple-ts'; import clientBundlePlugin from './lib/client-bundle-plugin'; import nodeExternalPlugin from './lib/node-external-plugin'; import cssPlugin from './lib/css-plugin'; -import assetPlugin from './lib/asset-plugin'; +import urlPlugin from './lib/url-plugin'; import resolveDirsPlugin from './lib/resolve-dirs-plugin'; import runScript from './lib/run-script'; import emitFiles from './lib/emit-files-plugin'; import imageWorkerPlugin from './lib/image-worker-plugin'; +import initialCssPlugin from './lib/initial-css-plugin'; function resolveFileUrl({ fileName }) { return JSON.stringify(fileName.replace(/^static\//, '/')); @@ -57,10 +58,11 @@ export default async function ({ watch }) { resolveDirsPlugin([ 'src/static-build', 'src/client', + 'src/shared', 'src/features', 'codecs', ]), - assetPlugin(), + urlPlugin(), cssPlugin(resolveFileUrl), ]; const dir = '.tmp/build'; @@ -104,7 +106,8 @@ export default async function ({ watch }) { nodeExternalPlugin(), imageWorkerPlugin(), replace({ __PRERENDER__: true, __PRODUCTION__: isProduction }), - runScript(dir + '/index.js'), + initialCssPlugin(), + runScript(dir + '/static-build/index.js'), ], }; } diff --git a/src/client/initial-app/App/index.tsx b/src/client/initial-app/App/index.tsx index 58d34f3f..c3b2df32 100644 --- a/src/client/initial-app/App/index.tsx +++ b/src/client/initial-app/App/index.tsx @@ -1,16 +1,16 @@ import type { FileDropEvent } from 'file-drop-element'; -import type SnackBarElement from 'client/initial-app/custom-els/snack-bar'; -import type { SnackOptions } from 'client/initial-app/custom-els/snack-bar'; +import type SnackBarElement from 'shared/initial-app/custom-els/snack-bar'; +import type { SnackOptions } from 'shared/initial-app/custom-els/snack-bar'; import { h, Component } from 'preact'; -import { linkRef } from 'client/initial-app/util'; +import { linkRef } from 'shared/initial-app/util'; import * as style from './style.css'; import 'add-css:./style.css'; import 'file-drop-element'; -import 'client/initial-app/custom-els/snack-bar'; -//import Intro from '../intro'; -import 'client/initial-app/custom-els/loading-spinner'; +import 'shared/initial-app/custom-els/snack-bar'; +import Intro from 'shared/initial-app/Intro'; +import 'shared/initial-app/custom-els/loading-spinner'; const ROUTE_EDITOR = '/editor'; @@ -127,8 +127,7 @@ export default class App extends Component { // 'TODO: uncomment above' ) : ( - // - 'TODO: show intro here' + )} diff --git a/src/client/initial-app/custom-els/missing-types.d.ts b/src/client/initial-app/custom-els/missing-types.d.ts index 64dcc636..37aa4e00 100644 --- a/src/client/initial-app/custom-els/missing-types.d.ts +++ b/src/client/initial-app/custom-els/missing-types.d.ts @@ -1,3 +1,5 @@ +/// +/// import type { FileDropElement, FileDropEvent } from 'file-drop-element'; interface FileDropAttributes extends preact.JSX.HTMLAttributes { diff --git a/src/client/missing-types.d.ts b/src/client/missing-types.d.ts index 4cb316ca..92689ade 100644 --- a/src/client/missing-types.d.ts +++ b/src/client/missing-types.d.ts @@ -11,11 +11,7 @@ * limitations under the License. */ /// - -declare var ga: { - (...args: any[]): void; - q: any[]; -}; +/// interface Navigator { readonly standalone: boolean; diff --git a/src/client/tsconfig.json b/src/client/tsconfig.json index 4a164d4a..47bc2e7f 100644 --- a/src/client/tsconfig.json +++ b/src/client/tsconfig.json @@ -4,5 +4,5 @@ "lib": ["esnext", "dom", "dom.iterable"], "types": [] }, - "references": [{ "path": "../features/worker" }] + "references": [{ "path": "../features/worker" }, { "path": "../shared" }] } diff --git a/src/shared/initial-app/Intro/imgs/demos/demo-artwork.jpg b/src/shared/initial-app/Intro/imgs/demos/demo-artwork.jpg new file mode 100644 index 00000000..8da9183b Binary files /dev/null and b/src/shared/initial-app/Intro/imgs/demos/demo-artwork.jpg differ diff --git a/src/shared/initial-app/Intro/imgs/demos/demo-device-screen.png b/src/shared/initial-app/Intro/imgs/demos/demo-device-screen.png new file mode 100644 index 00000000..b4c237c5 Binary files /dev/null and b/src/shared/initial-app/Intro/imgs/demos/demo-device-screen.png differ diff --git a/src/shared/initial-app/Intro/imgs/demos/demo-large-photo.jpg b/src/shared/initial-app/Intro/imgs/demos/demo-large-photo.jpg new file mode 100644 index 00000000..6f77519b Binary files /dev/null and b/src/shared/initial-app/Intro/imgs/demos/demo-large-photo.jpg differ diff --git a/src/shared/initial-app/Intro/imgs/demos/icon-demo-artwork.jpg b/src/shared/initial-app/Intro/imgs/demos/icon-demo-artwork.jpg new file mode 100644 index 00000000..79d05f02 Binary files /dev/null and b/src/shared/initial-app/Intro/imgs/demos/icon-demo-artwork.jpg differ diff --git a/src/shared/initial-app/Intro/imgs/demos/icon-demo-device-screen.jpg b/src/shared/initial-app/Intro/imgs/demos/icon-demo-device-screen.jpg new file mode 100644 index 00000000..5f140f0f Binary files /dev/null and b/src/shared/initial-app/Intro/imgs/demos/icon-demo-device-screen.jpg differ diff --git a/src/shared/initial-app/Intro/imgs/demos/icon-demo-large-photo.jpg b/src/shared/initial-app/Intro/imgs/demos/icon-demo-large-photo.jpg new file mode 100644 index 00000000..f418b268 Binary files /dev/null and b/src/shared/initial-app/Intro/imgs/demos/icon-demo-large-photo.jpg differ diff --git a/src/shared/initial-app/Intro/imgs/demos/icon-demo-logo.png b/src/shared/initial-app/Intro/imgs/demos/icon-demo-logo.png new file mode 100644 index 00000000..44ff9216 Binary files /dev/null and b/src/shared/initial-app/Intro/imgs/demos/icon-demo-logo.png differ diff --git a/src/shared/initial-app/Intro/imgs/logo.svg b/src/shared/initial-app/Intro/imgs/logo.svg new file mode 100644 index 00000000..72a3656e --- /dev/null +++ b/src/shared/initial-app/Intro/imgs/logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/shared/initial-app/Intro/index.tsx b/src/shared/initial-app/Intro/index.tsx new file mode 100644 index 00000000..10b01cbb --- /dev/null +++ b/src/shared/initial-app/Intro/index.tsx @@ -0,0 +1,255 @@ +import { h, Component } from 'preact'; + +import { linkRef } from 'shared/initial-app/util'; +import '../custom-els/loading-spinner'; + +import logo from 'url:./imgs/logo.svg'; +import largePhoto from 'url:./imgs/demos/demo-large-photo.jpg'; +import artwork from 'url:./imgs/demos/demo-artwork.jpg'; +import deviceScreen from 'url:./imgs/demos/demo-device-screen.png'; +import largePhotoIcon from 'url:./imgs/demos/icon-demo-large-photo.jpg'; +import artworkIcon from 'url:./imgs/demos/icon-demo-artwork.jpg'; +import deviceScreenIcon from 'url:./imgs/demos/icon-demo-device-screen.jpg'; +import logoIcon from 'url:./imgs/demos/icon-demo-logo.png'; +import * as style from './style.css'; +import type SnackBarElement from 'shared/initial-app/custom-els/snack-bar'; +import 'shared/initial-app/custom-els/snack-bar'; + +const demos = [ + { + description: 'Large photo (2.8mb)', + filename: 'photo.jpg', + url: largePhoto, + iconUrl: largePhotoIcon, + }, + { + description: 'Artwork (2.9mb)', + filename: 'art.jpg', + url: artwork, + iconUrl: artworkIcon, + }, + { + description: 'Device screen (1.6mb)', + filename: 'pixel3.png', + url: deviceScreen, + iconUrl: deviceScreenIcon, + }, + { + description: 'SVG icon (13k)', + filename: 'squoosh.svg', + url: logo, + iconUrl: logoIcon, + }, +]; + +const installButtonSource = 'introInstallButton-Purple'; + +interface Props { + onFile?: (file: File) => void; + showSnack?: SnackBarElement['showSnackbar']; +} +interface State { + fetchingDemoIndex?: number; + beforeInstallEvent?: BeforeInstallPromptEvent; +} + +export default class Intro extends Component { + state: State = {}; + private fileInput?: HTMLInputElement; + private installingViaButton = false; + + constructor() { + super(); + + if (__PRERENDER__) return; + // Listen for beforeinstallprompt events, indicating Squoosh is installable. + window.addEventListener( + 'beforeinstallprompt', + this.onBeforeInstallPromptEvent, + ); + + // Listen for the appinstalled event, indicating Squoosh has been installed. + window.addEventListener('appinstalled', this.onAppInstalled); + } + + private resetFileInput = () => { + this.fileInput!.value = ''; + }; + + private onFileChange = (event: Event): void => { + const fileInput = event.target as HTMLInputElement; + const file = fileInput.files && fileInput.files[0]; + if (!file) return; + this.resetFileInput(); + this.props.onFile!(file); + }; + + private onButtonClick = () => { + this.fileInput!.click(); + }; + + private onDemoClick = async (index: number, event: Event) => { + try { + this.setState({ fetchingDemoIndex: index }); + const demo = demos[index]; + const blob = await fetch(demo.url).then((r) => r.blob()); + + // Firefox doesn't like content types like 'image/png; charset=UTF-8', which Webpack's dev + // server returns. https://bugzilla.mozilla.org/show_bug.cgi?id=1497925. + const type = /[^;]*/.exec(blob.type)![0]; + const file = new File([blob], demo.filename, { type }); + this.props.onFile!(file); + } catch (err) { + this.setState({ fetchingDemoIndex: undefined }); + this.props.showSnack!("Couldn't fetch demo image"); + } + }; + + private onBeforeInstallPromptEvent = (event: BeforeInstallPromptEvent) => { + // Don't show the mini-infobar on mobile + event.preventDefault(); + + // Save the beforeinstallprompt event so it can be called later. + this.setState({ beforeInstallEvent: event }); + + // Log the event. + const gaEventInfo = { + eventCategory: 'pwa-install', + eventAction: 'promo-shown', + nonInteraction: true, + }; + ga('send', 'event', gaEventInfo); + }; + + private onInstallClick = async (event: Event) => { + // Get the deferred beforeinstallprompt event + const beforeInstallEvent = this.state.beforeInstallEvent; + // If there's no deferred prompt, bail. + if (!beforeInstallEvent) return; + + this.installingViaButton = true; + + // Show the browser install prompt + beforeInstallEvent.prompt(); + + // Wait for the user to accept or dismiss the install prompt + const { outcome } = await beforeInstallEvent.userChoice; + // Send the analytics data + const gaEventInfo = { + eventCategory: 'pwa-install', + eventAction: 'promo-clicked', + eventLabel: installButtonSource, + eventValue: outcome === 'accepted' ? 1 : 0, + }; + ga('send', 'event', gaEventInfo); + + // If the prompt was dismissed, we aren't going to install via the button. + if (outcome === 'dismissed') { + this.installingViaButton = false; + } + }; + + private onAppInstalled = () => { + // We don't need the install button, if it's shown + this.setState({ beforeInstallEvent: undefined }); + + // Don't log analytics if page is not visible + if (document.hidden) { + return; + } + + // Try to get the install, if it's not set, use 'browser' + const source = this.installingViaButton ? installButtonSource : 'browser'; + ga('send', 'event', 'pwa-install', 'installed', source); + + // Clear the install method property + this.installingViaButton = false; + }; + + render({}: Props, { fetchingDemoIndex, beforeInstallEvent }: State) { + return ( +
+
+
+
+ Squoosh +
+
+

+ Drag & drop or{' '} + + +

+

Or try one of these:

+
    + {demos.map((demo, i) => ( +
  • + +
  • + ))} +
+
+ {beforeInstallEvent && ( + + )} + +
+ ); + } +} diff --git a/src/shared/initial-app/Intro/missing-types.d.ts b/src/shared/initial-app/Intro/missing-types.d.ts new file mode 100644 index 00000000..a35205c5 --- /dev/null +++ b/src/shared/initial-app/Intro/missing-types.d.ts @@ -0,0 +1,31 @@ +/** + * The BeforeInstallPromptEvent is fired at the Window.onbeforeinstallprompt handler + * before a user is prompted to "install" a web site to a home screen on mobile. + */ +interface BeforeInstallPromptEvent extends Event { + /** + * Returns an array of DOMString items containing the platforms on which the event was dispatched. + * This is provided for user agents that want to present a choice of versions to the user such as, + * for example, "web" or "play" which would allow the user to chose between a web version or + * an Android version. + */ + readonly platforms: Array; + + /** + * Returns a Promise that resolves to a DOMString containing either "accepted" or "dismissed". + */ + readonly userChoice: Promise<{ + outcome: 'accepted' | 'dismissed'; + platform: string; + }>; + + /** + * Allows a developer to show the install prompt at a time of their own choosing. + * This method returns a Promise. + */ + prompt(): Promise; +} + +interface WindowEventMap { + beforeinstallprompt: BeforeInstallPromptEvent; +} diff --git a/src/shared/initial-app/Intro/style.css b/src/shared/initial-app/Intro/style.css new file mode 100644 index 00000000..6e936e5c --- /dev/null +++ b/src/shared/initial-app/Intro/style.css @@ -0,0 +1,228 @@ +@font-face { + font-family: 'intro-text'; + font-style: normal; + font-weight: 300; + font-display: block; + /* This only contains the chars for "Drag & drop or" */ + src: url('data:font/woff2;base64,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') + format('woff2'); +} + +@font-face { + font-family: 'intro-text'; + font-style: normal; + font-weight: 500; + font-display: block; + /* Only contains the chars for "select an image" */ + src: url('data:font/woff2;base64,d09GMgABAAAAAAXMAA4AAAAACwQAAAV5AAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGhYbg1IcMAZgAGwRCAqJUId/CxoAATYCJAMwBCAFgnAHIBskCcjEh6dNff8Ou/9Tj9VZGnUhJeqFzWGiVVOkxkTthr9f6kWkRdsBbkIj3YuLaloFZWr7aBg22z7IOoWqBWCW5cZU3GBrh0n+dAcBYAUlzYHAzWTYchqKXEyAT0zOLIS1qqm+B8q2ur4OhEMy0PHHUH8KklaSr8T0mp/EU7kRvXlI1E09HXA1qLN8Djxa0AsSDOg3cJARb9mtQJoSK3gvEn372/gcAigg/gOnbsT/MYv491GTReW4rJC5LA+h5FFclF6QQgoZ5Kx7GbsuGeytUgFClkOomY2Gdake3m9HegkHieAx/a0hBTALsy4jvpxBcnFXUnjC+2ZS5zHnDeEaJVwi+ZWqzOm4Uvgy4k6kGv4kFDVkfjk1gkVRRkk2zlo42PBbRJmG30cClJQjak7BnfQqHza4ITKftQZ/ZMUaEiyy1+mYCh4clKhDA5rQglZ0oG9jw+qiNvT+SfxIXCeuFdeIq8VV4gpxyRaGOl0JiChiCocfc7e93DwZIPvWgPiZJLcJugxyjW7UQyl4TJk6dWqYU7Cn0WQiWnNJCdGeprqjW63fpVS3mKy4YGZ6I3ya4nbIVgM1mwkpNEBzixlNxfPmH7owvdE4973OM9quvk11dwvnzDUy/Zn5S5Ywpn/PeqXBQI2m4lna05CRtsI6+GIENjS9K4jWRHUGYA2ozdZm2Smmf0DI3aqpeNbsJfxe7YdFmcZAn5gXLCFa2/Umqiu017APFhMZ0rfQp4sJX0ZrJ+n9UtAljr5VYWb6oj1MrpvX3qe6u8WRJg0bj7aPkDOa7m+E0Oa9Y8eY/gbRbr+efH7hcO49bMd28fbDVHcUmm3XkozQGKjeBHSJ4TQnI879LIFmF2v/BJuEQlffJPfE9oKayS/PsPE44fvM4MsBESxbuEEV39d5pw6oW4vD6S1WQC3UpSbHNbK0Jikl0bphSs+0CGW8Ew4Kzw7zarmcVz873JHTFhKYay18R8vY0ozPiHPAGyROAqlW5fLj5+HbWBn9TpgekKsOy8N+4dlFfL9i/Nk3+gY1bwzZUAvLVNiFpvqHRenetSoVrgn2obGtPsltEVxEeHJAQFhyBIcHT9rDvTJm06e0TLgase2gd2RffGJNg0o1zrdRyi9s1bYE5bi85cK+o/nUwvBR5+jweEBaSMoCub29fEFISmib9Dn5yl5kVFpoGrPQSmZhafQ7WimttNCH7Cktohb6kFpoEfIsdDF7SgvZTbaY3mKFyLXQh+wpzWE3mUNQQkWnR+lgiW9Afkunej49Nz3sYlI8XFTRdkNhUR5d6h4oOpJc8OjcItMXVqoHW2fSW6ycWuiM8NDYoICouLAZ9BYrwwmhycvKlt4Q8hUlCV5nZ7vOm2ut2rizcFpuWpSrT3K1Z3xinbuHnXBTyGAljV/XzHZaNGu6y7vLDziMpIyUdOBBRXXlxznUQiOoheZsZk9njG8er1mNmz2eOCQ3x9BbLP+Zxt+VrbEz9aWxmimRvyl4/sumyoM/nw+LNzV4/uP0/9T/P5f08cvhl38USAS/xTYs2fL/VNFF0vd+SVsRB/khPwW4SCi5SHhx8fDgVPAiAqIJRQL/EuK5bsRzNnAiezD1i7u2VyHHAKRU+E2YUaA5DUsE2ZfbApAmsJcxjBwmQ2Xk4Y2BqZJ+oxSzsNEogz1O3/xkBMKEBHSiC8PoQStaoEIflPCHL/wQBCUKoUITlMhHP+olt5ojykUPOrEQTWgY+f449KMPKnSiB72jGrLQhEZO24925LtbkG5DndTkD2/4um+NQBEyUIJsRBxX24tcDGmbWtGJjq05jhzTaMgCcR+6EA4f+KAXDay5u6RsL7xJsm3w3mzvFvggB8nI/AYBdVnxNvx/2wA=') + format('woff2'); +} + +@keyframes fade-in { + from { + opacity: 0; + } +} + +.intro { + display: grid; + grid-template-rows: 1fr min-content; + align-items: center; + background: rgba(255, 255, 255, 0.25); + text-align: center; + font-size: 2rem; + -webkit-overflow-scrolling: touch; + overflow: auto; + padding: 20px 0 0; + height: 100%; + box-sizing: border-box; + overscroll-behavior: contain; + position: relative; +} + +.logo-container { + position: relative; + padding-top: 100%; +} + +.logo-sizer { + width: 90%; + max-width: 52vh; + margin: 0 auto; +} + +.logo { + composes: abs-fill from '../util.css'; + pointer-events: none; +} + +.open-image-guide { + font: 300 11vw intro-text, sans-serif; + margin-bottom: 0; + + @media (min-width: 460px) { + font-size: 50.6px; + padding: 0 40px; + } +} + +.select-button { + composes: unbutton from '../util.css'; + font-weight: 500; + color: #5d509e; + + &:hover, + &:focus { + text-decoration: underline; + } +} + +.hide { + display: none; +} + +.demos { + display: block; + padding: 0; + border-top: 1px solid #e8e8e8; + margin: 0 auto; + + @media (min-width: 400px) { + display: grid; + grid-template-columns: 1fr 1fr; + } + + @media (min-width: 580px) { + border-top: none; + width: 523px; + } + + @media (min-width: 900px) { + display: grid; + grid-template-columns: 1fr 1fr 1fr 1fr; + width: 773px; + } +} + +.demo-item { + background: #fff; + display: flex; + border-bottom: 1px solid #e8e8e8; + + @media (min-width: 580px) { + border: 1px solid #e8e8e8; + border-radius: 4px; + margin: 3px; + } +} + +.demo-button { + composes: unbutton from '../util.css'; + flex: 1; + + &:hover, + &:focus { + background: #f5f5f5; + } +} + +.demo { + display: flex; + align-items: center; + padding: 7px; + font-size: 1.3rem; +} + +.demo-img-container { + overflow: hidden; + display: block; + width: 47px; + background: #ccc; + border-radius: 3px; + flex: 0 0 auto; +} + +.demo-img-aspect { + position: relative; + padding-top: 100%; +} + +.demo-icon { + composes: abs-fill from '../util.css'; + pointer-events: none; +} + +.demo-description { + display: flex; + align-items: center; + justify-content: flex-start; + text-align: left; + flex: 1; + padding: 0 10px; +} + +.demo-loading { + composes: abs-fill from '../util.css'; + background: rgba(0, 0, 0, 0.6); + display: flex; + align-items: center; + justify-content: center; + animation: fade-in 300ms ease-in-out; +} + +.demo-loading-spinner { + --color: #fff; +} + +.install-button { + composes: unbutton from '../util.css'; + + &:hover, + &:focus { + background: #504488; + } + + background: #5d509e; + border: 1px solid #e8e8e8; + color: #fff; + padding: 14px; + font-size: 1.3rem; + + position: absolute; + top: 1rem; + right: 1rem; + + animation: fade-in 0.3s ease-in-out; +} + +@keyframes fade-in { + from { + opacity: 0; + } +} + +.related-links { + display: flex; + padding: 0; + justify-content: center; + font-size: 1.3rem; + + & li { + display: block; + border-left: 1px solid #000; + padding: 0 0.6em; + + &:first-child { + border-left: none; + } + } + + & a:link { + color: #5d509e; + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } +} diff --git a/src/client/initial-app/custom-els/loading-spinner/index.ts b/src/shared/initial-app/custom-els/loading-spinner/index.ts similarity index 86% rename from src/client/initial-app/custom-els/loading-spinner/index.ts rename to src/shared/initial-app/custom-els/loading-spinner/index.ts index e1af112d..705d9169 100644 --- a/src/client/initial-app/custom-els/loading-spinner/index.ts +++ b/src/shared/initial-app/custom-els/loading-spinner/index.ts @@ -1,5 +1,10 @@ import * as styles from './styles.css'; +// So it doesn't cause an error when running in node +const HTMLEl = ((__PRERENDER__ + ? Object + : HTMLElement) as unknown) as typeof HTMLElement; + /** * A simple spinner. This custom element has no JS API. Just put it in the document, and it'll * spin. You can configure the following using CSS custom properties: @@ -10,11 +15,12 @@ import * as styles from './styles.css'; * --delay: Once the spinner enters the DOM, how long until it shows. This prevents the spinner * appearing on the screen for short operations. Default: 300ms. */ -export default class LoadingSpinner extends HTMLElement { +export default class LoadingSpinner extends HTMLEl { private _delayTimeout: number = 0; constructor() { super(); + if (!__PRERENDER__) return; // Ideally we'd use shadow DOM here, but we're targeting browsers without shadow DOM support. // You can't set attributes/content in a custom element constructor, so I'm waiting a microtask. @@ -59,4 +65,4 @@ export default class LoadingSpinner extends HTMLElement { } } -customElements.define('loading-spinner', LoadingSpinner); +if (!__PRERENDER__) customElements.define('loading-spinner', LoadingSpinner); diff --git a/src/client/initial-app/custom-els/loading-spinner/missing-types.d.ts b/src/shared/initial-app/custom-els/loading-spinner/missing-types.d.ts similarity index 100% rename from src/client/initial-app/custom-els/loading-spinner/missing-types.d.ts rename to src/shared/initial-app/custom-els/loading-spinner/missing-types.d.ts diff --git a/src/client/initial-app/custom-els/loading-spinner/styles.css b/src/shared/initial-app/custom-els/loading-spinner/styles.css similarity index 100% rename from src/client/initial-app/custom-els/loading-spinner/styles.css rename to src/shared/initial-app/custom-els/loading-spinner/styles.css diff --git a/src/client/initial-app/custom-els/snack-bar/index.ts b/src/shared/initial-app/custom-els/snack-bar/index.ts similarity index 89% rename from src/client/initial-app/custom-els/snack-bar/index.ts rename to src/shared/initial-app/custom-els/snack-bar/index.ts index 0992fbcc..8e530c46 100644 --- a/src/client/initial-app/custom-els/snack-bar/index.ts +++ b/src/shared/initial-app/custom-els/snack-bar/index.ts @@ -1,5 +1,10 @@ import * as style from './styles.css'; +// So it doesn't cause an error when running in node +const HTMLEl = ((__PRERENDER__ + ? Object + : HTMLElement) as unknown) as typeof HTMLElement; + export interface SnackOptions { timeout?: number; actions?: string[]; @@ -46,7 +51,7 @@ function createSnack( return [el, result]; } -export default class SnackBarElement extends HTMLElement { +export default class SnackBarElement extends HTMLEl { private _snackbars: [ string, SnackOptions, @@ -92,4 +97,4 @@ export default class SnackBarElement extends HTMLElement { } } -customElements.define('snack-bar', SnackBarElement); +if (!__PRERENDER__) customElements.define('snack-bar', SnackBarElement); diff --git a/src/client/initial-app/custom-els/snack-bar/missing-types.d.ts b/src/shared/initial-app/custom-els/snack-bar/missing-types.d.ts similarity index 90% rename from src/client/initial-app/custom-els/snack-bar/missing-types.d.ts rename to src/shared/initial-app/custom-els/snack-bar/missing-types.d.ts index 16c86a94..98fbd8a1 100644 --- a/src/client/initial-app/custom-els/snack-bar/missing-types.d.ts +++ b/src/shared/initial-app/custom-els/snack-bar/missing-types.d.ts @@ -1,4 +1,5 @@ import type { SnackOptions } from '.'; +import type preact from 'preact'; interface SnackBarAttributes extends preact.JSX.HTMLAttributes { showSnackbar?: (options: SnackOptions) => Promise; diff --git a/src/client/initial-app/custom-els/snack-bar/styles.css b/src/shared/initial-app/custom-els/snack-bar/styles.css similarity index 100% rename from src/client/initial-app/custom-els/snack-bar/styles.css rename to src/shared/initial-app/custom-els/snack-bar/styles.css diff --git a/src/shared/initial-app/util.css b/src/shared/initial-app/util.css new file mode 100644 index 00000000..b8c7ba48 --- /dev/null +++ b/src/shared/initial-app/util.css @@ -0,0 +1,22 @@ +.abs-fill { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + box-sizing: border-box; + contain: strict; +} + +.unbutton { + cursor: pointer; + background: none; + border: none; + font: inherit; + padding: 0; + margin: 0; + + &:focus { + outline: none; + } +} diff --git a/src/client/initial-app/util.ts b/src/shared/initial-app/util.ts similarity index 100% rename from src/client/initial-app/util.ts rename to src/shared/initial-app/util.ts diff --git a/src/shared/missing-preact-types.d.ts b/src/shared/missing-preact-types.d.ts new file mode 100644 index 00000000..c7c1c81d --- /dev/null +++ b/src/shared/missing-preact-types.d.ts @@ -0,0 +1,11 @@ +declare module 'preact' { + namespace JSX { + interface HTMLAttributes { + decoding?: 'sync' | 'async' | 'auto'; + } + } +} + +// Thing break unless this file is a module. +// Don't ask me why. I don't know. +export {}; diff --git a/src/shared/missing-types.d.ts b/src/shared/missing-types.d.ts new file mode 100644 index 00000000..baf404ec --- /dev/null +++ b/src/shared/missing-types.d.ts @@ -0,0 +1,15 @@ +/** + * Copyright 2020 Google Inc. 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. + */ +/// + +declare const __PRERENDER__: boolean; diff --git a/src/shared/tsconfig.json b/src/shared/tsconfig.json new file mode 100644 index 00000000..19363e94 --- /dev/null +++ b/src/shared/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../generic-tsconfig.json", + "compilerOptions": { + "lib": ["esnext", "dom", "dom.iterable"], + "types": [] + } +} diff --git a/src/static-build/assets/icon-small.png b/src/static-build/assets/icon-small.png new file mode 100644 index 00000000..709b1829 Binary files /dev/null and b/src/static-build/assets/icon-small.png differ diff --git a/src/static-build/missing-types.d.ts b/src/static-build/missing-types.d.ts index 799d7f26..12b7cad7 100644 --- a/src/static-build/missing-types.d.ts +++ b/src/static-build/missing-types.d.ts @@ -11,9 +11,15 @@ * limitations under the License. */ /// +/// declare module 'client-bundle:*' { const url: string; export default url; export const imports: string[]; } + +declare module 'initial-css:' { + const css: string; + export default css; +} diff --git a/src/static-build/pages/index/all.css b/src/static-build/pages/index/all.css deleted file mode 100644 index 290cb123..00000000 --- a/src/static-build/pages/index/all.css +++ /dev/null @@ -1,3 +0,0 @@ -html { - background: green; -} diff --git a/src/static-build/pages/index/base.css b/src/static-build/pages/index/base.css new file mode 100644 index 00000000..71399504 --- /dev/null +++ b/src/static-build/pages/index/base.css @@ -0,0 +1,55 @@ +button, +a, +img, +input, +select, +textarea { + -webkit-tap-highlight-color: rgba(0, 0, 0, 0); +} + +a, +button, +img, +[inert], +.inert { + user-select: none; + -webkit-user-select: none; + user-drag: none; + -webkit-user-drag: none; + touch-callout: none; + -webkit-touch-callout: none; +} + +html, +body { + height: 100%; + padding: 0; + margin: 0; + font: 12px/1.3 system-ui, -apple-system, BlinkMacSystemFont, Roboto, Helvetica, + sans-serif; + overflow: hidden; + overscroll-behavior: none; + contain: strict; + background: url('data:image/svg+xml,'); + background-size: 20px 20px; +} + +:root { + --gray-dark: rgba(0, 0, 0, 0.8); + + --button-fg-color: 95, 180, 228; + --button-fg: rgb(95, 180, 228); + + --negative: rgb(207, 113, 127); + --positive: rgb(149, 212, 159); +} + +:global(#app) { + position: absolute; + left: 0; + top: 0; + contain: strict; + width: 100%; + height: 100%; + overflow: hidden; +} diff --git a/src/static-build/pages/index/index.tsx b/src/static-build/pages/index/index.tsx index 9dec6889..8f433282 100644 --- a/src/static-build/pages/index/index.tsx +++ b/src/static-build/pages/index/index.tsx @@ -12,10 +12,12 @@ */ import { h, FunctionalComponent } from 'preact'; -import css from 'css:./all.css'; +import baseCss from 'css:./base.css'; +import initialCss from 'initial-css:'; import clientBundleURL, { imports } from 'client-bundle:client/initial-app'; import favicon from 'url:static-build/assets/favicon.ico'; import { escapeStyleScriptContent } from 'static-build/utils'; +import Intro from 'shared/initial-app/Intro'; interface Props {} @@ -37,7 +39,12 @@ const Index: FunctionalComponent = () => (