mirror of
https://github.com/flutter/holobooth.git
synced 2025-05-17 13:25:59 +08:00
feat(functions): sharing function styling (#307)
This commit is contained in:
@ -21,7 +21,6 @@ module.exports = {
|
||||
},
|
||||
ignorePatterns: [
|
||||
'/lib/**/*', // Ignore built files.
|
||||
'/tests/**/*',
|
||||
],
|
||||
plugins: [
|
||||
'@typescript-eslint',
|
||||
|
5
functions/.runtimeconfig.json
Normal file
5
functions/.runtimeconfig.json
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"development": {
|
||||
"app_url": "https://io-photobooth.web.app"
|
||||
}
|
||||
}
|
10122
functions/package-lock.json
generated
10122
functions/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -22,20 +22,19 @@
|
||||
"watch": "tsc --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"child-process-promise": "^2.2.1",
|
||||
"firebase-admin": "^9.6.0",
|
||||
"firebase-functions": "^3.13.2"
|
||||
"firebase-functions": "^3.13.2",
|
||||
"mustache": "^4.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/child-process-promise": "^2.2.1",
|
||||
"@types/jest": "^26.0.22",
|
||||
"@types/mustache": "^4.1.1",
|
||||
"@typescript-eslint/eslint-plugin": "^4.21.0",
|
||||
"@typescript-eslint/parser": "^4.21.0",
|
||||
"eslint": "^7.23.0",
|
||||
"eslint-config-google": "^0.14.0",
|
||||
"eslint-plugin-import": "^2.22.1",
|
||||
"eslint-plugin-jest": "^24.1.3",
|
||||
"firebase-functions-test": "^0.2.3",
|
||||
"jest": "^26.6.3",
|
||||
"prettier": "~2.2.1",
|
||||
"prettier-eslint": "~12.0.0",
|
||||
|
@ -1,3 +1,8 @@
|
||||
export const ENV = process.env.NODE_ENV;
|
||||
export const UPLOAD_PATH = 'uploads';
|
||||
export const SHARE_PATH = 'share';
|
||||
export const ALLOWED_HOSTS = [
|
||||
'localhost:5001',
|
||||
'io-photobooth-dev.web.app',
|
||||
'io-photo-booth.web.app',
|
||||
];
|
||||
|
@ -1,5 +1,9 @@
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
import * as functions from 'firebase-functions';
|
||||
|
||||
import { getShareResponse } from './';
|
||||
|
||||
|
||||
jest.mock('firebase-admin', () => {
|
||||
return {
|
||||
storage: jest.fn(() => ({
|
||||
@ -16,22 +20,40 @@ jest.mock('firebase-admin', () => {
|
||||
});
|
||||
|
||||
describe('Share API', () => {
|
||||
const baseReq = {
|
||||
path: '',
|
||||
protocol: 'http',
|
||||
// @ts-ignore
|
||||
get(key: string) {
|
||||
return 'localhost:5001';
|
||||
},
|
||||
} as functions.https.Request;
|
||||
|
||||
test('Invalid path returns 404 and html', async () => {
|
||||
const res = await getShareResponse('');
|
||||
const req = Object.assign({}, baseReq);
|
||||
const res = await getShareResponse(req);
|
||||
expect(res.status).toEqual(404);
|
||||
expect(typeof res.send).toEqual('string');
|
||||
expect(res.send).toContain('<html>');
|
||||
expect(res.send).toContain('DOCTYPE html');
|
||||
});
|
||||
|
||||
test('Invalid file extension returns 404 and html', async () => {
|
||||
const res = await getShareResponse('/share/image.gif');
|
||||
const req = Object.assign({}, baseReq, {
|
||||
path: 'wrong.gif',
|
||||
});
|
||||
const res = await getShareResponse(req);
|
||||
expect(res.status).toEqual(404);
|
||||
expect(typeof res.send).toEqual('string');
|
||||
expect(res.send).toContain('<html>');
|
||||
expect(res.send).toContain('DOCTYPE html');
|
||||
});
|
||||
|
||||
test('Valid file name returns 200 and html', async () => {
|
||||
const res = await getShareResponse('/share/upload.jpeg');
|
||||
const req = Object.assign({}, baseReq, {
|
||||
path: 'test.png',
|
||||
});
|
||||
const res = await getShareResponse(req);
|
||||
expect(res.status).toEqual(200);
|
||||
expect(typeof res.send).toEqual('string');
|
||||
expect(res.send).toContain('<html>');
|
||||
expect(res.send).toContain('DOCTYPE html');
|
||||
});
|
||||
});
|
||||
|
@ -1,23 +1,46 @@
|
||||
import * as path from 'path';
|
||||
import * as querystring from 'querystring';
|
||||
import * as admin from 'firebase-admin';
|
||||
import * as functions from 'firebase-functions';
|
||||
import * as path from 'path';
|
||||
import * as querystring from 'querystring';
|
||||
import mustache from 'mustache';
|
||||
|
||||
import { UPLOAD_PATH, SHARE_PATH } from '../config';
|
||||
import { UPLOAD_PATH, ALLOWED_HOSTS } from '../config';
|
||||
import footerTmpl from './templates/footer';
|
||||
import notFoundTmpl from './templates/notfound';
|
||||
import shareTmpl from './templates/share';
|
||||
import stylesTmpl from './templates/styles';
|
||||
|
||||
|
||||
const htmlMeta = {
|
||||
title: 'Google IO Photobooth',
|
||||
description: 'Take a photo with special IO effects and share it with your community.',
|
||||
ogUrl: 'https://io-photobooth.web.app',
|
||||
ogTwitterSite: '@flutterdev',
|
||||
imgPathBackground: 'public/background.jpg',
|
||||
imgPathLogo: 'public/logo.svg',
|
||||
ctaHeaderText: 'Take a selfie and share your photo with the community.',
|
||||
ctaBtnText: 'Take Your Own',
|
||||
faviconPath: 'public/favicon.png',
|
||||
const VALID_IMAGE_EXT = [ '.png', '.jpeg', '.jpg' ];
|
||||
|
||||
const BaseHTMLContext: Record<string, string | Record<string, string>> = {
|
||||
appUrl: '',
|
||||
shareUrl: '',
|
||||
shareImageUrl: '',
|
||||
assetUrls: {
|
||||
favicon: bucketPathForFile('public/favicon.png'),
|
||||
bg: bucketPathForFile('public/background.jpg'),
|
||||
bgMobile: bucketPathForFile('public/background-mobile.jpg'),
|
||||
notFoundPhoto: bucketPathForFile('public/404-photo.png'),
|
||||
fixedPhotosLeft: bucketPathForFile('public/table-photos-left.png'),
|
||||
fixedPhotosRight: bucketPathForFile('public/table-photos-right.png'),
|
||||
},
|
||||
meta: {
|
||||
title: 'Google I/O Photo Booth',
|
||||
desc: (
|
||||
'Take a photo at the Google I/O Photo Booth! ' +
|
||||
'Built for Google I/O 2021 with Flutter & Firebase.'
|
||||
),
|
||||
message: (
|
||||
'Check out my photo taken at the #IOPhotoBooth. ' +
|
||||
'Join the fun at #GoogleIO and take your own!'
|
||||
),
|
||||
},
|
||||
footer: mustache.render(footerTmpl, {}),
|
||||
styles: '',
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Returns bucket path
|
||||
* @param {string} filename
|
||||
@ -25,186 +48,88 @@ const htmlMeta = {
|
||||
*/
|
||||
function bucketPathForFile(filename: string): string {
|
||||
return (
|
||||
`https://firebasestorage.googleapis.com/v0/b/${admin.storage().bucket().name}` +
|
||||
'https://firebasestorage.googleapis.com/v0' +
|
||||
`/b/${admin.storage().bucket().name}` +
|
||||
`/o/${querystring.escape(filename)}?alt=media`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return not found response
|
||||
* @return {Object} Corrent not found response?
|
||||
* Return a local file HTML template built with context
|
||||
* @param {string} tmpl - html template string
|
||||
* @param {Object} context - html context dict
|
||||
* @return {string} HTML template string
|
||||
*/
|
||||
function getNotFoundHTML(): string {
|
||||
return (`
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<title>${htmlMeta.title}</title>
|
||||
<meta name='viewport' content='width=device-width,initial-scale=1' />
|
||||
<style>
|
||||
html {
|
||||
font-family: 'Google Sans', 'Roboto', sans-serif;
|
||||
background-repeat: no-repeat;
|
||||
background-image: url('${bucketPathForFile(htmlMeta.imgPathBackground)}');
|
||||
background-position: center center;
|
||||
background-attachement: fixed;
|
||||
background-size: cover;
|
||||
}
|
||||
.logo {
|
||||
width: 90%;
|
||||
margin: 3rem auto;
|
||||
display:block;
|
||||
max-width: 800px;
|
||||
}
|
||||
.share-content {
|
||||
background: #fff;
|
||||
width: 95%;
|
||||
max-width: 800px;
|
||||
border-radius: 6px;
|
||||
padding: 2rem 1rem 3rem;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<img class='logo' src='${bucketPathForFile(htmlMeta.imgPathLogo)}'/>
|
||||
<div class='share-content'>
|
||||
<h2>Some clever not found message :)</h2>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
function renderTemplate(
|
||||
tmpl: string, context: Record<string, string | Record<string, string>>
|
||||
): string {
|
||||
context.styles = mustache.render(stylesTmpl, context);
|
||||
return mustache.render(tmpl, context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the 404 html page
|
||||
* @param {string} imageFileName - filename of storage image
|
||||
* @param {string} baseUrl - http base fully qualified URL
|
||||
* @return {string} HTML string
|
||||
*/
|
||||
function renderNotFoundPage(imageFileName: string, baseUrl: string): string {
|
||||
const context = Object.assign({}, BaseHTMLContext, {
|
||||
appUrl: baseUrl,
|
||||
shareUrl: `${baseUrl}/share/${imageFileName}`,
|
||||
shareImageUrl: bucketPathForFile(`${UPLOAD_PATH}/${imageFileName}`),
|
||||
});
|
||||
return renderTemplate(notFoundTmpl, context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Populate and return the share page HTML for given path
|
||||
* @param {string} filePath
|
||||
* @param {string} imageFileName - filename of storage image
|
||||
* @param {string} baseUrl - http base fully qualified URL
|
||||
* @return {string} HTML string
|
||||
*/
|
||||
function generateShareHTML(filePath: string): string {
|
||||
/*eslint-disable */
|
||||
return (`
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<title>${htmlMeta.title}</title>
|
||||
<meta name='viewport' content='width=device-width,initial-scale=1' />
|
||||
<meta name='descripton' content='${htmlMeta.description}'>
|
||||
<meta property='og:description' content='${htmlMeta.description}'>
|
||||
<meta property='og:title' content='${htmlMeta.title}'>
|
||||
<meta property='og:url' content='${htmlMeta.ogUrl}'>
|
||||
<meta property='og:image' content='${bucketPathForFile(filePath)}'>
|
||||
<meta name='twitter:text:title' content='${htmlMeta.title}'>
|
||||
<meta name='twitter:card' content='summary_large_image'>
|
||||
<meta name='twitter:site' content='${htmlMeta.ogTwitterSite}'>
|
||||
<meta name='twitter:title' content='${htmlMeta.title}'>
|
||||
<meta name='twitter:description' content='${htmlMeta.description}'>
|
||||
<meta name='twitter:image' content='${bucketPathForFile(filePath)}'>
|
||||
<link rel='icon' type='image/png' href='${bucketPathForFile(htmlMeta.faviconPath)}'>
|
||||
<style>
|
||||
html {
|
||||
font-family: 'Google Sans', 'Roboto', sans-serif;
|
||||
background-repeat: no-repeat;
|
||||
background-image: url('${bucketPathForFile(htmlMeta.imgPathBackground)}');
|
||||
background-position: center center;
|
||||
background-attachement: fixed;
|
||||
background-size: cover;
|
||||
}
|
||||
.logo {
|
||||
width: 90%;
|
||||
margin: 3rem auto;
|
||||
display:block;
|
||||
max-width: 800px;
|
||||
}
|
||||
.share-content {
|
||||
background: #fff;
|
||||
width: 95%;
|
||||
max-width: 800px;
|
||||
border-radius: 6px;
|
||||
padding: 2rem 1rem 3rem;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
}
|
||||
.share-image {
|
||||
width: 80%;
|
||||
margin: 0 1rem;
|
||||
}
|
||||
.share-header {
|
||||
margin: 4rem auto 0;
|
||||
font-size:1.5rem;
|
||||
}
|
||||
.share-btn {
|
||||
background-color: #1389FD;
|
||||
border-color: #1389FD;
|
||||
border-radius: 0;
|
||||
border: 1px solid transparent;
|
||||
box-sizing: border-box;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
display: inline-block;
|
||||
font-size: 2rem;
|
||||
font-weight: 400;
|
||||
line-height: 1.5;
|
||||
margin-top: 3rem;
|
||||
min-width: 250px;
|
||||
padding: 1rem 1.5rem;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
|
||||
user-select: none;
|
||||
vertical-align: middle;
|
||||
white-space: normal;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<img class='logo' src='${bucketPathForFile(htmlMeta.imgPathLogo)}'/>
|
||||
<div class='share-content'>
|
||||
<img class='share-image' src='${bucketPathForFile(filePath)}'/>
|
||||
<div class='share-header'> ${htmlMeta.ctaHeaderText} </div>
|
||||
<a class='share-btn' href='${htmlMeta.ogUrl}'> ${htmlMeta.ctaBtnText} </a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
/* eslint-enable */
|
||||
function renderSharePage(imageFileName: string, baseUrl: string): string {
|
||||
const context = Object.assign({}, BaseHTMLContext, {
|
||||
appUrl: baseUrl,
|
||||
shareUrl: `${baseUrl}/share/${imageFileName}`,
|
||||
shareImageUrl: bucketPathForFile(`${UPLOAD_PATH}/${imageFileName}`),
|
||||
});
|
||||
return renderTemplate(shareTmpl, context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get share response
|
||||
* @param {string} reqPath
|
||||
* @param {Object} req - request object
|
||||
* @return {Object} share response
|
||||
*/
|
||||
export async function getShareResponse(
|
||||
reqPath: string
|
||||
req: functions.https.Request
|
||||
): Promise<{ status: number; send: string }> {
|
||||
try {
|
||||
const { dir, ext, base } = path.parse(reqPath);
|
||||
const isValidPath = (
|
||||
dir === `/${SHARE_PATH}` && [ '.png', '.jpeg', '.jpg' ].includes(ext)
|
||||
);
|
||||
const host = req.get('host') ?? '';
|
||||
const baseUrl = `${req.protocol}://${host}`;
|
||||
const { ext, base: imageFileName } = path.parse(req.path);
|
||||
|
||||
const storagePath = `${UPLOAD_PATH}/${base}`;
|
||||
let exists: [boolean] | undefined;
|
||||
|
||||
if (isValidPath) {
|
||||
exists = await admin.storage().bucket().file(storagePath).exists();
|
||||
if (exists && exists[0]) {
|
||||
return {
|
||||
status: 200,
|
||||
send: generateShareHTML(storagePath),
|
||||
};
|
||||
}
|
||||
if (!ALLOWED_HOSTS.includes(host) || !VALID_IMAGE_EXT.includes(ext)) {
|
||||
return {
|
||||
status: 404,
|
||||
send: renderNotFoundPage(imageFileName, baseUrl),
|
||||
};
|
||||
}
|
||||
|
||||
functions.logger.info('File path invalid or not found', {
|
||||
storagePath, exists, valid: isValidPath,
|
||||
});
|
||||
const imageBlobPath = `${UPLOAD_PATH}/${imageFileName}`;
|
||||
const imageExists = await admin.storage().bucket().file(imageBlobPath).exists();
|
||||
|
||||
if (Array.isArray(imageExists) && imageExists[0]) {
|
||||
return {
|
||||
status: 200,
|
||||
send: renderSharePage(imageFileName, baseUrl),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: 404,
|
||||
send: getNotFoundHTML(),
|
||||
send: renderNotFoundPage(imageFileName, baseUrl),
|
||||
};
|
||||
} catch (error) {
|
||||
functions.logger.error(error);
|
||||
@ -215,14 +140,12 @@ export async function getShareResponse(
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Public sharing function
|
||||
*/
|
||||
export const shareImage = functions.https.onRequest(async (req, res) => {
|
||||
const { status, send } = await getShareResponse(req.path);
|
||||
|
||||
// Development only, set to actual production hosting domain
|
||||
const { status, send } = await getShareResponse(req);
|
||||
res.set('Access-Control-Allow-Origin', '*');
|
||||
|
||||
res.status(status).send(send);
|
||||
});
|
||||
|
19
functions/src/share/templates/footer.ts
Normal file
19
functions/src/share/templates/footer.ts
Normal file
@ -0,0 +1,19 @@
|
||||
export default `
|
||||
<footer>
|
||||
<div class="left">
|
||||
<span>Made with
|
||||
<a href="https://flutter.dev">Futter</a> &
|
||||
<a href="https://firebase.google.com">Firebase</a>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="right">
|
||||
<ul>
|
||||
<li><a href="https://events.google.com/io/adventure/?lng=en">Google I/O</a></li>
|
||||
<li><a href="https://flutter.dev/docs/codelabs">Codelab</a></li>
|
||||
<li><a href="">How It's Made</a></li>
|
||||
<li><a href="https://policies.google.com/terms">Terms of Service</a></li>
|
||||
<li><a href="https://policies.google.com/privacy">Privacy Policy</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</footer>
|
||||
`;
|
45
functions/src/share/templates/notfound.ts
Normal file
45
functions/src/share/templates/notfound.ts
Normal file
@ -0,0 +1,45 @@
|
||||
export default `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" type="image/png" href="{{{assetUrls.favicon}}}">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
|
||||
<title>{{meta.title}}</title>
|
||||
<meta name="descripton" content="{{meta.desc}}">
|
||||
|
||||
<meta property="og:title" content="{{meta.message}}">
|
||||
<meta property="og:description" content="{{meta.desc}}">
|
||||
<meta property="og:url" content="{{{shareUrl}}}">
|
||||
<meta property="og:image" content="{{{shareImageUrl}}}">
|
||||
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="{{meta.title}}">
|
||||
<meta name="twitter:text:title" content="{{meta.message}}">
|
||||
<meta name="twitter:description" content="{{metaDesc}}">
|
||||
<meta name="twitter:image" content="{{{shareImageUrl}}}">
|
||||
<meta name="twitter:site" content="@flutterdev">
|
||||
|
||||
<link href="https://fonts.googleapis.com/css?family=Google+Sans:400,500" rel="stylesheet">
|
||||
|
||||
<style>{{{styles}}}</style>
|
||||
</head>
|
||||
<body>
|
||||
<img src="{{{assetUrls.fixedPhotosLeft}}}" class="fixed-photos left">
|
||||
<img src="{{{assetUrls.fixedPhotosRight}}}" class="fixed-photos right">
|
||||
<main>
|
||||
<div class="share-image no-shadow">
|
||||
<img src="{{{assetUrls.notFoundPhoto}}}">
|
||||
</div>
|
||||
<div class="text">
|
||||
<h1>Flutter taken with Flutter</h1>
|
||||
<h2>Join the fun! Grab a photo with your favorite Google mascot
|
||||
at the I/O Photo Booth.</h2>
|
||||
<a class="share-btn" href="{{appUrl}}">Take your own</a>
|
||||
</div>
|
||||
</main>
|
||||
{{{footer}}}
|
||||
</body>
|
||||
</html>
|
||||
`;
|
45
functions/src/share/templates/share.ts
Normal file
45
functions/src/share/templates/share.ts
Normal file
@ -0,0 +1,45 @@
|
||||
export default `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" type="image/png" href="{{{assetUrls.favicon}}}">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
|
||||
<title>{{meta.title}}</title>
|
||||
<meta name="descripton" content="{{meta.desc}}">
|
||||
|
||||
<meta property="og:title" content="{{meta.message}}">
|
||||
<meta property="og:description" content="{{meta.desc}}">
|
||||
<meta property="og:url" content="{{{shareUrl}}}">
|
||||
<meta property="og:image" content="{{{shareImageUrl}}}">
|
||||
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="{{meta.title}}">
|
||||
<meta name="twitter:text:title" content="{{meta.message}}">
|
||||
<meta name="twitter:description" content="{{metaDesc}}">
|
||||
<meta name="twitter:image" content="{{{shareImageUrl}}}">
|
||||
<meta name="twitter:site" content="@flutterdev">
|
||||
|
||||
<link href="https://fonts.googleapis.com/css?family=Google+Sans:400,500" rel="stylesheet">
|
||||
|
||||
<style>{{{styles}}}</style>
|
||||
</head>
|
||||
<body>
|
||||
<img src="{{{assetUrls.fixedPhotosLeft}}}" class="fixed-photos left">
|
||||
<img src="{{{assetUrls.fixedPhotosRight}}}" class="fixed-photos right">
|
||||
<main>
|
||||
<div class="share-image">
|
||||
<img src="{{{shareImageUrl}}}">
|
||||
</div>
|
||||
<div class="text">
|
||||
<h1>Photo taken with Flutter</h1>
|
||||
<h2>Join the fun! Grab a photo with your favorite Google mascot
|
||||
at the I/O Photo Booth.</h2>
|
||||
<a class="share-btn" href="{{{appUrl}}}">Get started</a>
|
||||
</div>
|
||||
</main>
|
||||
{{{footer}}}
|
||||
</body>
|
||||
</html>
|
||||
`;
|
195
functions/src/share/templates/styles.ts
Normal file
195
functions/src/share/templates/styles.ts
Normal file
@ -0,0 +1,195 @@
|
||||
export default `
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: "Google Sans", sans-serif;
|
||||
font-size: 12px;
|
||||
background-image: url("{{{assetUrls.bgMobile}}}");
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
background-size: cover;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
*, ::before, ::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.fixed-photos {
|
||||
display: none;
|
||||
}
|
||||
|
||||
main {
|
||||
width: 95%;
|
||||
max-width: 1000px;
|
||||
margin: 0 auto 45px;
|
||||
text-align: center;
|
||||
flex: 1 0 auto;
|
||||
z-index: 10;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.share-image {
|
||||
margin: 2rem auto;
|
||||
width: 90%;
|
||||
transform: rotate(-5deg);
|
||||
}
|
||||
|
||||
.share-image img {
|
||||
width: 100%;
|
||||
box-shadow: -3px 9px 7px 1px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.share-image.no-shadow img {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
h1, h2 {
|
||||
color: white;
|
||||
margin-block-start: 0;
|
||||
margin-block-end: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
line-height: 1.2;
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
width: 67%;
|
||||
margin: 0 auto 25px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
line-height: 1.3;
|
||||
font-size: 18px;
|
||||
font-weight: 100;
|
||||
width: 75%;
|
||||
margin: 0 auto 35px;
|
||||
}
|
||||
|
||||
.share-btn {
|
||||
display: inline-block;
|
||||
font-weight: 400;
|
||||
line-height: 1.5;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
line-height: 1;
|
||||
padding: 16px 0;
|
||||
background-color: #428eff;
|
||||
color: white;
|
||||
font-size: 18px;
|
||||
border-radius: 50px;
|
||||
width: 208px;
|
||||
}
|
||||
|
||||
footer {
|
||||
width: 100%;
|
||||
color: white;
|
||||
font-size: 14px;
|
||||
font-weight: 100;
|
||||
flex-direction: column;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0 5% 1.5rem;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
z-index: 10;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
footer a {
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
footer a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
footer ul {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
line-height: 2;
|
||||
}
|
||||
|
||||
footer .left {
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
margin-bottom: 17px;
|
||||
}
|
||||
|
||||
footer .left a {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
footer li {
|
||||
display: inline;
|
||||
margin-left: 1rem;
|
||||
}
|
||||
|
||||
@media (min-width:768px) {
|
||||
body {
|
||||
background-image: url("{{{assetUrls.bg}}}");
|
||||
}
|
||||
|
||||
.fixed-photos {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
background-repeat: no-repeat;
|
||||
background-size: contain;
|
||||
z-index: -1;
|
||||
display: block;
|
||||
width: 780px;
|
||||
}
|
||||
|
||||
.fixed-photos.left {
|
||||
left: -22%;
|
||||
}
|
||||
|
||||
.fixed-photos.right {
|
||||
right: -22%;
|
||||
}
|
||||
|
||||
.share-image {
|
||||
margin: 4.25rem auto 3rem;
|
||||
width: 710px;
|
||||
position: relative;
|
||||
left: -22px;
|
||||
}
|
||||
|
||||
h1, h2 {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 56px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.share-btn {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
footer {
|
||||
flex-direction: row;
|
||||
text-align: left;
|
||||
padding-left: 2%;
|
||||
padding-right: 2%;
|
||||
}
|
||||
|
||||
footer .left {
|
||||
font-size: 18px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
`;
|
BIN
functions/testdata/upload.jpeg
vendored
BIN
functions/testdata/upload.jpeg
vendored
Binary file not shown.
Before Width: | Height: | Size: 191 KiB |
Reference in New Issue
Block a user