Update to fullcalendar 6, render image on server side for old iPads

Using puppeteer to render the page server side, as FullCalendar 6
dropped support for ES5, and polyfilling everything was becoming
basically impossible
This commit is contained in:
Adam Goldsmith 2023-08-28 20:38:49 -04:00
parent 59f9ab99ac
commit 98065af7d5
18 changed files with 3589 additions and 1764 deletions

6
.gitignore vendored
View File

@ -1,4 +1,6 @@
/node_modules/ node_modules/
/.cache/ .cache/
/dist/ /dist/
/.log/ /.log/
server/src/*.js
server/src/*.js.map

View File

@ -5,35 +5,34 @@
"ios 5.1" "ios 5.1"
], ],
"scripts": { "scripts": {
"start": "npm run serve", "start": "pnpm run --parallel --recursive --include-workspace-root dev",
"build": "webpack build", "build": "webpack build --mode=production",
"serve": "webpack serve" "dev": "webpack serve"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.20.5", "@babel/core": "^7.22.11",
"@babel/plugin-transform-runtime": "^7.19.6", "@babel/plugin-transform-runtime": "^7.22.10",
"@babel/preset-env": "^7.20.2", "@babel/preset-env": "^7.22.10",
"@types/intl": "^1.2.0", "babel-loader": "^9.1.3",
"babel-loader": "^9.1.0", "css-loader": "^6.8.1",
"css-loader": "^6.7.3", "style-loader": "^3.3.3",
"style-loader": "^3.3.1", "ts-loader": "^9.4.4",
"ts-loader": "^9.4.2", "typescript": "^5.2.2",
"typescript": "^4.9.4", "webpack": "^5.88.2",
"webpack": "^5.75.0", "webpack-cli": "^5.1.4",
"webpack-cli": "^5.0.1", "webpack-dev-server": "^4.15.1"
"webpack-dev-server": "^4.11.1"
}, },
"dependencies": { "dependencies": {
"@babel/runtime": "^7.20.6", "@babel/runtime": "^7.22.11",
"@fullcalendar/core": "^5.11.3", "@fullcalendar/core": "^6.1.8",
"@fullcalendar/icalendar": "^5.11.3", "@fullcalendar/icalendar": "^6.1.8",
"@fullcalendar/resource-common": "^5.11.3", "@fullcalendar/resource": "^6.1.8",
"@fullcalendar/resource-timegrid": "^5.11.3", "@fullcalendar/resource-timegrid": "^6.1.8",
"@fullcalendar/resource-timeline": "^5.11.3", "@fullcalendar/resource-timeline": "^6.1.8",
"@fullcalendar/timegrid": "^5.11.3", "@fullcalendar/timegrid": "^6.1.8",
"core-js": "^3.26.1", "core-js": "^3.32.1",
"intl": "^1.2.5", "ical.js": "^1.5.0",
"preact": "^10.11.3", "preact": "^10.17.1",
"unique-colors": "^1.0.1" "unique-colors": "^1.0.1"
} }
} }

File diff suppressed because it is too large Load Diff

20
server/package.json Normal file
View File

@ -0,0 +1,20 @@
{
"type": "module",
"exports": "./server.js",
"dependencies": {
"express": "^4.18.2",
"puppeteer": "^21.1.1"
},
"scripts": {
"start": "npm run dev",
"dev": "tsx watch --clear-screen=false ./src/server.ts",
"build": "tsc",
"serve": "node dist/server.js"
},
"devDependencies": {
"@types/express": "^4.17.17",
"@types/node": "^20.5.7",
"tsx": "^3.12.7",
"typescript": "^5.2.2"
}
}

1543
server/pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load Diff

58
server/src/server.ts Normal file
View File

@ -0,0 +1,58 @@
import express from 'express';
import puppeteer from 'puppeteer';
const PORT = process.env.PORT || 1234;
const app = express();
const puppet_browser = await puppeteer.launch({ headless: 'new' });
app.get('/ipad.png', async (req, res) => {
const puppet_page = await puppet_browser.newPage();
if (typeof req.query.viewport == 'string') {
let [width, height, pixelRatio] = req.query.viewport.split('x', 3);
puppet_page.setViewport({
deviceScaleFactor: parseInt(pixelRatio),
width: parseInt(width),
height: parseInt(height),
});
}
let frontend_url = new URL('/wall-display.html', 'http://' + req.get('host'));
console.debug(frontend_url);
if (typeof req.query.tool == 'string') {
frontend_url.searchParams.set('tool', req.query.tool);
}
await puppet_page.goto(frontend_url.toString());
// TODO: handle timeout
await puppet_page.waitForNetworkIdle({ timeout: 5000 });
const screenshot = await puppet_page.screenshot();
await puppet_page.close();
res.send(screenshot);
});
const server = app.listen(PORT, () => {
console.log(`Listening on http://localhost:${PORT}`);
});
// graceful shutdown
process.on('SIGTERM', () => {
console.debug('SIGTERM signal received: shutting down');
Promise.all([
new Promise((resolve, reject) =>
server.close((err) => {
if (err) {
return reject(err);
}
return resolve(null);
})
).then(() => console.debug('HTTP server closed')),
puppet_browser
.close()
.then(() => console.debug('Puppeteer puppet_browser instance closed')),
])
.then(() => process.exit(0))
.catch((err) => {
console.error(err);
process.exit(1);
});
});

15
server/tsconfig.json Normal file
View File

@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "esnext",
"useDefineForClassFields": true,
"module": "ES2022",
"moduleResolution": "node",
"strict": true,
"jsx": "preserve",
"sourceMap": true,
"resolveJsonModule": true,
"esModuleInterop": true,
"lib": ["esnext", "dom"]
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"]
}

View File

@ -139,6 +139,7 @@ export function main(
); );
calendar.render(); calendar.render();
//calendar.gotoDate('2022-06-27');
return calendar; return calendar;
} }

View File

@ -8,6 +8,6 @@
</head> </head>
<body> <body>
<div id="calendar"></div> <div id="calendar"></div>
<script src="bundle.js"></script> <script src="bundle-index.js"></script>
</body> </body>
</html> </html>

View File

@ -1,9 +1,8 @@
import '@fullcalendar/core';
import type { CalendarOptions, EventContentArg } from '@fullcalendar/core'; import type { CalendarOptions, EventContentArg } from '@fullcalendar/core';
import type { ResourceLabelContentArg } from '@fullcalendar/resource-common'; import type { ResourceLabelContentArg } from '@fullcalendar/resource';
import timeGridPlugin from '@fullcalendar/timegrid'; import timeGridPlugin from '@fullcalendar/timegrid';
import resourceTimelinePlugin from '@fullcalendar/resource-timeline'; import resourceTimelinePlugin from '@fullcalendar/resource-timeline';
import type { createElement } from 'preact'; import type { createElement } from '@fullcalendar/core/preact';
import { common_calendarOptions, main } from './common'; import { common_calendarOptions, main } from './common';

1
src/intl.d.ts vendored
View File

@ -1 +0,0 @@
declare module 'intl/locale-data/jsonp/en.js';

View File

@ -1,28 +0,0 @@
.fc .fc-toolbar.fc-header-toolbar {
/* Save some vertical space */
margin-bottom: 0;
/* iOS 8 Safari doesn't support flexbox */
text-align: center;
}
/* Fix rendering of now indicators on iOS 8 Safari */
.fc .fc-timegrid-now-indicator-container {
overflow: initial;
}
.fc .fc-timegrid-now-indicator-line {
border-color: rgba(255, 0, 0, 0.4);
border-top-width: 1px;
border-top-color: red;
border-bottom-width: 9px;
}
.fc-direction-ltr .fc-timegrid-now-indicator-arrow {
border-width: 10px 0 10px 11px;
margin-top: -10px;
}
.fc .fc-timegrid-slots {
/* Fix timegrid lines rendering over events on iOS 5.1.1 */
z-index: 0 !important;
}

21
src/ipad.html Normal file
View File

@ -0,0 +1,21 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="viewport" content="initial-scale=1, user-scalable=no" />
<title>Tool Reservations</title>
</head>
<body style="margin: 0;">
<div id="errorContainer"></div>
<div class="modal" id="loadingSpinner"></div>
<div class="modal" id="errorModal" style="display: none;">
<div class="modal-content">
<div class="modal-icon">&#9888;</div>
<div class="modal-message"></div>
</div>
</div>
<img id="calendar"></img>
<script src="bundle-ipad.js"></script>
</body>
</html>

61
src/ipad.ts Normal file
View File

@ -0,0 +1,61 @@
import 'core-js/stable/url';
import './ipad.html';
import './modal.css';
console.log = function (message) {
let div = document.createElement('div');
div.textContent = message;
document.getElementById('errorContainer')!.appendChild(div);
};
console.error = console.log;
console.info = console.log;
window.onerror = function (message, url, line) {
console.log(`${message} ${url} ${line}`);
};
document.body.addEventListener('touchmove', (e) => e.preventDefault(), {
passive: false,
});
const calendarImg = document.getElementById('calendar')! as HTMLImageElement;
const spinner = document.getElementById('loadingSpinner')!;
const errorModal = document.getElementById('errorModal')!;
calendarImg.addEventListener('load', () => {
spinner.style.display = 'none';
errorModal.style.display = 'none';
});
calendarImg.addEventListener('error', (event) => {
spinner.style.display = 'none';
console.error('Error fetching calendar image: ', event);
errorModal.style.display = '';
const message = errorModal.querySelector('.modal-message')!;
message.textContent = `Failed to fetch events: ${event.message}. Displayed events may not be accurate.`;
});
function refresh() {
spinner.style.display = '';
const url = new URL('/ipad.png', window.location.href);
url.searchParams.set('viewport', `${window.innerWidth}x${window.innerHeight}x${window.devicePixelRatio}`);
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.has('tool')) {
url.searchParams.set('tool', urlParams.get('tool')!);
}
calendarImg.src = url.toString();
calendarImg.width = window.innerWidth;
calendarImg.height = window.innerHeight;
}
window.addEventListener('load', () => refresh());
// refresh data every five minutes
window.setInterval(refresh, 5 * 60 * 1000);
// refresh page every hour
window.setInterval(() => window.location.reload(), 60 * 60 * 1000);

11
src/wall-display.css Normal file
View File

@ -0,0 +1,11 @@
.fc .fc-toolbar.fc-header-toolbar {
/* Save some vertical space */
margin-bottom: 0;
}
.fc .fc-timegrid-now-indicator-line {
border-color: rgba(255, 0, 0, 0.4);
border-top-width: 1px;
border-top-color: red;
border-bottom-width: 9px;
}

View File

@ -7,6 +7,7 @@
<title>Tool Reservations</title> <title>Tool Reservations</title>
</head> </head>
<body> <body>
<div id="errorContainer"></div>
<div class="modal" id="loadingSpinner"></div> <div class="modal" id="loadingSpinner"></div>
<div class="modal" id="errorModal"> <div class="modal" id="errorModal">
<div class="modal-content"> <div class="modal-content">

View File

@ -1,17 +1,22 @@
import 'core-js/stable/url';
import 'core-js/stable/function';
import Intl from 'intl';
import 'intl/locale-data/jsonp/en.js';
window.Intl = Intl;
import type { CalendarOptions } from '@fullcalendar/core'; import type { CalendarOptions } from '@fullcalendar/core';
import { common_calendarOptions, main } from './common'; import { common_calendarOptions, main } from './common';
import './wall-display.html'; import './wall-display.html';
import './modal.css'; import './modal.css';
import './ios-fixes.css'; import './wall-display.css';
console.log = function (message) {
let div = document.createElement('div');
div.textContent = message;
document.getElementById('errorContainer')!.appendChild(div);
};
console.error = console.log;
console.info = console.log;
window.onerror = function (message, url, line) {
console.log(`${message} ${url} ${line}`);
};
document.body.addEventListener('touchmove', (e) => e.preventDefault(), { document.body.addEventListener('touchmove', (e) => e.preventDefault(), {
passive: false, passive: false,

View File

@ -31,7 +31,10 @@ module.exports = [
{ {
...common, ...common,
name: 'default', name: 'default',
entry: './src/index.ts', entry: {
index: './src/index.ts',
'wall-display': './src/wall-display.ts',
},
devServer: { devServer: {
allowedHosts: 'all', allowedHosts: 'all',
proxy: { proxy: {
@ -39,6 +42,9 @@ module.exports = [
target: 'https://calendar.google.com', target: 'https://calendar.google.com',
changeOrigin: true, changeOrigin: true,
}, },
'/ipad.png': {
target: 'http://localhost:1234',
},
}, },
}, },
module: { module: {
@ -53,18 +59,18 @@ module.exports = [
}, },
output: { output: {
path: path.resolve(__dirname, 'dist'), path: path.resolve(__dirname, 'dist'),
filename: 'bundle.js', filename: 'bundle-[name].js',
}, },
}, },
{ {
...common, ...common,
name: 'wall-display', name: 'ipad',
entry: './src/wall-display.ts', entry: ['./src/ipad.ts'],
module: { module: {
rules: [ rules: [
{ {
test: /\.m?js$/, test: /\.m?js$/,
exclude: /node_modules/, include: /node_modules\/@fullcalendar/,
loader: 'babel-loader', loader: 'babel-loader',
}, },
{ {
@ -77,7 +83,7 @@ module.exports = [
}, },
output: { output: {
path: path.resolve(__dirname, 'dist'), path: path.resolve(__dirname, 'dist'),
filename: 'bundle-wall-display.js', filename: 'bundle-ipad.js',
}, },
}, },
]; ];