├── .editorconfig
├── .gitignore
├── .npmrc
├── LICENSE
├── README.md
├── api
├── config.json
├── index.js
├── manifest.js
├── page.js
├── public
│ └── icons
│ │ ├── PG_144-night.png
│ │ ├── PG_144.png
│ │ ├── PG_192-night.png
│ │ ├── PG_192.png
│ │ ├── PG_512-night.png
│ │ ├── PG_512.png
│ │ ├── PG_IOS-night.png
│ │ ├── PG_IOS.png
│ │ ├── favicon-night.ico
│ │ ├── favicon.ico
│ │ └── share.png
├── routes.js
├── stats.js
├── sw.js
└── utils
│ ├── atob.js
│ ├── load.js
│ ├── store.js
│ └── track.js
├── package.json
├── rollup.config.js
└── src
├── components
├── App.js
├── Form.js
├── Item.js
├── List.js
├── Modal.js
├── Settings.js
└── Toolbar.js
├── index.js
├── styling
├── app.css
├── form.css
├── item.css
├── list.css
├── modal.css
├── settings.css
└── toolbar.css
└── utils
├── localStorage.js
└── serviceWorker.js
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true;
2 |
3 | [*]
4 | indent_style = space
5 | indent_size = 2
6 | end_of_line = lf
7 | charset = utf-8
8 | trim_trailing_spaces = true
9 | insert_final_newline = true
10 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | .DS_Store
3 | package-lock.json
4 | build
5 | pg.css
6 | pg.js
7 | *.html
8 |
--------------------------------------------------------------------------------
/.npmrc:
--------------------------------------------------------------------------------
1 | registry=https://registry.npmjs.org/
2 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2018 Colin van Eenige
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Preact Gallery
2 | > A modern gallery experience disguised as a tiny (8kb) Preact based PWA.
3 |
4 | [https://preact.gallery](https://preact.gallery)
5 |
6 | ### Features
7 | * Tiny (total: 8kb, javascript: 6kb)
8 | * 100 / 100 Lighthouse score ([source](https://preact.gallery/lighthouse))
9 | * Add, edit and remove external images
10 | * Visible storage sizes (bytes)
11 | * View stored resources offline
12 | * Clear all images (user cache control)
13 | * Subtle and smooth transitions
14 | * Custom day and night theme
15 | * [Preact](https://github.com/developit/preact) (front-end), [Polka](https://github.com/lukeed/polka) (back-end)
16 |
17 | ### Behind the scenes
18 | Interested in why and how I created this? Read my article! 🙇
19 |
20 | ## License
21 |
22 | MIT © [Colin van Eenige](https://use-the-platform.com)
23 |
--------------------------------------------------------------------------------
/api/config.json:
--------------------------------------------------------------------------------
1 | {
2 | "mongodb": "",
3 | "track": false
4 | }
5 |
--------------------------------------------------------------------------------
/api/index.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 |
3 | const polka = require('polka');
4 | const MongoClient = require('mongodb').MongoClient;
5 |
6 | const middleware = require('unvault-middleware');
7 | const shrinkRay = require('shrink-ray');
8 | const serveStatic = require('serve-static');
9 |
10 | const routes = require('./routes');
11 | const store = require('./utils/store');
12 | const load = require('./utils/load');
13 | const track = require('./utils/track');
14 |
15 | const config = require('./config');
16 |
17 | async function loadFiles() {
18 | const files = [
19 | {
20 | filename: '/sw.js',
21 | name: 'sw',
22 | collapse: true,
23 | },
24 | {
25 | filename: '/public/pg.css',
26 | name: 'css',
27 | },
28 | {
29 | filename: '/public/pg.js',
30 | name: 'js',
31 | },
32 | {
33 | filename: '/public/lighthouse.html',
34 | name: 'lighthouse',
35 | },
36 | ];
37 | return load(files, store);
38 | }
39 |
40 | async function initialize() {
41 | await loadFiles();
42 | const server = polka();
43 | server.use(
44 | 'assets',
45 | serveStatic(path.join(__dirname, 'public'), {
46 | setHeaders(res) {
47 | res.setHeader('Cache-Control', `public,max-age=31536000,immutable`);
48 | },
49 | })
50 | );
51 | server.use(shrinkRay({ threshold: false, brotli: { quality: 11 } }));
52 | server.use((req, res, next) => {
53 | if (req.originalUrl === '/') track(req.headers['user-agent'], 'views');
54 | next();
55 | });
56 | server.use(middleware(routes.static));
57 | server.listen(4004);
58 | routes.dynamic(server);
59 | }
60 |
61 | if (config.track) {
62 | MongoClient.connect(config.mongodb, function(error, client) {
63 | if (error !== null) {
64 | console.log(error);
65 | return;
66 | }
67 | store.set('db-views', client.db('preact-gallery').collection('views'));
68 | store.set('db-images', client.db('preact-gallery').collection('images'));
69 | initialize();
70 | });
71 | } else {
72 | initialize();
73 | }
74 |
--------------------------------------------------------------------------------
/api/manifest.js:
--------------------------------------------------------------------------------
1 | function manifest(night) {
2 | const suffix = night ? '-night' : '';
3 | const color = night ? '#263238' : '#673ab8';
4 | return Buffer.from(
5 | JSON.stringify({
6 | name: 'Preact gallery',
7 | short_name: 'Gallery',
8 | icons: [
9 | {
10 | src: `/assets/icons/PG_144${suffix}.png`,
11 | type: 'image/png',
12 | sizes: '144x144',
13 | },
14 | {
15 | src: `/assets/icons/PG_192${suffix}.png`,
16 | type: 'image/png',
17 | sizes: '192x192',
18 | },
19 | {
20 | src: `/assets/icons/PG_512${suffix}.png`,
21 | type: 'image/png',
22 | sizes: '512x512',
23 | },
24 | ],
25 | start_url: '/',
26 | scope: '/',
27 | display: 'standalone',
28 | orientation: 'portrait',
29 | background_color: color,
30 | theme_color: color,
31 | }),
32 | 'utf8'
33 | );
34 | }
35 |
36 | module.exports = manifest;
37 |
--------------------------------------------------------------------------------
/api/page.js:
--------------------------------------------------------------------------------
1 | const gzip = require('gzip-size');
2 | const minify = require('html-minifier').minify;
3 |
4 | const store = require('./utils/store');
5 | const { description } = require('./../package.json');
6 |
7 | const meta = {
8 | title: 'Preact Gallery',
9 | description,
10 | image: 'https://preact.gallery/assets/icons/share.png',
11 | url: 'https://preact.gallery/',
12 | themeColor: '#673ab8',
13 | };
14 |
15 | module.exports = async () => {
16 | const { DEBUG = false } = process.env;
17 | const html = minify(
18 | `
19 |
20 |
21 | ${meta.title}
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 | ${DEBUG ? `` : ``}
43 |
44 |
45 |
46 |
53 |
54 | ${
55 | DEBUG
56 | ? ``
57 | : ``
58 | }
59 |
60 |
61 |
62 |
63 | `,
64 | {
65 | collapseWhitespace: 'true',
66 | }
67 | );
68 | store.set('html', html);
69 | return Buffer.from(html, 'utf8');
70 | };
71 |
--------------------------------------------------------------------------------
/api/public/icons/PG_144-night.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vaneenige/preact-gallery/ec39c5475052e82efbc2ff1b39574b17c4688194/api/public/icons/PG_144-night.png
--------------------------------------------------------------------------------
/api/public/icons/PG_144.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vaneenige/preact-gallery/ec39c5475052e82efbc2ff1b39574b17c4688194/api/public/icons/PG_144.png
--------------------------------------------------------------------------------
/api/public/icons/PG_192-night.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vaneenige/preact-gallery/ec39c5475052e82efbc2ff1b39574b17c4688194/api/public/icons/PG_192-night.png
--------------------------------------------------------------------------------
/api/public/icons/PG_192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vaneenige/preact-gallery/ec39c5475052e82efbc2ff1b39574b17c4688194/api/public/icons/PG_192.png
--------------------------------------------------------------------------------
/api/public/icons/PG_512-night.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vaneenige/preact-gallery/ec39c5475052e82efbc2ff1b39574b17c4688194/api/public/icons/PG_512-night.png
--------------------------------------------------------------------------------
/api/public/icons/PG_512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vaneenige/preact-gallery/ec39c5475052e82efbc2ff1b39574b17c4688194/api/public/icons/PG_512.png
--------------------------------------------------------------------------------
/api/public/icons/PG_IOS-night.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vaneenige/preact-gallery/ec39c5475052e82efbc2ff1b39574b17c4688194/api/public/icons/PG_IOS-night.png
--------------------------------------------------------------------------------
/api/public/icons/PG_IOS.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vaneenige/preact-gallery/ec39c5475052e82efbc2ff1b39574b17c4688194/api/public/icons/PG_IOS.png
--------------------------------------------------------------------------------
/api/public/icons/favicon-night.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vaneenige/preact-gallery/ec39c5475052e82efbc2ff1b39574b17c4688194/api/public/icons/favicon-night.ico
--------------------------------------------------------------------------------
/api/public/icons/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vaneenige/preact-gallery/ec39c5475052e82efbc2ff1b39574b17c4688194/api/public/icons/favicon.ico
--------------------------------------------------------------------------------
/api/public/icons/share.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vaneenige/preact-gallery/ec39c5475052e82efbc2ff1b39574b17c4688194/api/public/icons/share.png
--------------------------------------------------------------------------------
/api/routes.js:
--------------------------------------------------------------------------------
1 | const send = require('@polka/send');
2 | const fetch = require('node-fetch');
3 | const Validator = require('better-validator');
4 | const gzip = require('gzip-size');
5 |
6 | const page = require('./page');
7 | const stats = require('./stats');
8 | const manifest = require('./manifest');
9 | const store = require('./utils/store');
10 | const atob = require('./utils/atob');
11 | const track = require('./utils/track');
12 |
13 | const static = [
14 | {
15 | path: '/',
16 | headers: { 'Content-Type': 'text/html' },
17 | interval: 0,
18 | update: async () => page(),
19 | },
20 | {
21 | path: '/sw.js',
22 | headers: {
23 | 'Content-Type': 'application/javascript',
24 | 'Cache-Control': 'public,max-age=300',
25 | },
26 | interval: 0,
27 | update: () => store.get('sw'),
28 | },
29 | {
30 | path: '/manifest.json',
31 | headers: { 'Content-Type': 'application/json' },
32 | interval: 0,
33 | update: () => manifest(false),
34 | },
35 | {
36 | path: '/manifest-night.json',
37 | headers: { 'Content-Type': 'application/json' },
38 | interval: 0,
39 | update: () => manifest(true),
40 | },
41 | {
42 | path: '/stats',
43 | headers: { 'Content-Type': 'application/json' },
44 | interval: 10 * 1000,
45 | update: async () => stats(),
46 | },
47 | {
48 | path: '/lighthouse',
49 | headers: { 'Content-Type': 'text/html' },
50 | interval: 0,
51 | update: () => store.get('lighthouse'),
52 | },
53 | ];
54 |
55 | function dynamic(server) {
56 | server.get('/image/:base64', async (req, res) => {
57 | const validator = new Validator();
58 | validator(req.params.base64)
59 | .isString()
60 | .isBase64();
61 | if (validator.run().length !== 0) {
62 | send(res, 404);
63 | return;
64 | }
65 | const result = await new Promise((resolve, reject) => {
66 | fetch(atob(req.params.base64)).then(response => resolve(response));
67 | });
68 | const supportedMimeTypes = ['image/gif', 'image/png', 'image/jpeg', 'image/bmp', 'image/webp'];
69 | if (supportedMimeTypes.includes(result.headers.get('content-type'))) {
70 | track(req.headers['user-agent'], 'images', {
71 | url: req.params.base64,
72 | bytes: parseInt(result.headers.get('content-length'), 10),
73 | });
74 | send(res, 200, await result.buffer(), {
75 | 'Content-Length': result.headers.get('content-length'),
76 | });
77 | } else {
78 | send(res, 404);
79 | }
80 | });
81 | }
82 |
83 | module.exports = { static, dynamic };
84 |
--------------------------------------------------------------------------------
/api/stats.js:
--------------------------------------------------------------------------------
1 | const gzip = require('gzip-size');
2 | const brotli = require('brotli-size');
3 |
4 | const store = require('./utils/store');
5 |
6 | const config = require('./config');
7 |
8 | const size = { uncompressed: str => Buffer.byteLength(str, 'utf8'), gzip, brotli: brotli.sync };
9 |
10 | async function getFileSizes(type) {
11 | const css = await size[type](store.get('css'));
12 | const js = await size[type](store.get('js'));
13 | const html = (await size[type](store.get('html'))) - css - js;
14 | const total = html + css + js;
15 |
16 | return {
17 | total,
18 | js,
19 | css,
20 | html,
21 | };
22 | }
23 |
24 | async function getAnalytics() {
25 | const users = await store
26 | .get('db-views')
27 | .find()
28 | .count();
29 |
30 | const images = await store
31 | .get('db-images')
32 | .find()
33 | .count();
34 |
35 | const bytes = (await store
36 | .get('db-images')
37 | .find()
38 | .toArray()).reduce((acc, { bytes }) => acc + bytes, 0);
39 |
40 | return {
41 | users,
42 | images,
43 | bytes,
44 | readable: `${(bytes / 1e6).toFixed(2)}MB`,
45 | };
46 | }
47 |
48 | async function stats() {
49 | return JSON.stringify(
50 | {
51 | analytics: config.track ? await getAnalytics() : null,
52 | files: {
53 | uncompressed: await getFileSizes('uncompressed'),
54 | gzip: await getFileSizes('gzip'),
55 | brotli: await getFileSizes('brotli'),
56 | },
57 | },
58 | null,
59 | 2
60 | );
61 | }
62 |
63 | module.exports = stats;
64 |
--------------------------------------------------------------------------------
/api/sw.js:
--------------------------------------------------------------------------------
1 | const VERSION = '__VERSION__';
2 | const CACHE_NAME = `preact-gallery-${VERSION}`;
3 |
4 | self.addEventListener('install', event => {
5 | self.skipWaiting();
6 | event.waitUntil(caches.open(CACHE_NAME).then(cache => cache.addAll(['/'])));
7 | });
8 |
9 | self.addEventListener('fetch', event => {
10 | const url = new URL(event.request.url);
11 | event.respondWith(caches.match(event.request).then(response => response || fetch(event.request)));
12 | });
13 |
14 | self.addEventListener('activate', event => {
15 | event.waitUntil(
16 | caches
17 | .keys()
18 | .then(keys =>
19 | Promise.all(
20 | keys.map(key => {
21 | if (![CACHE_NAME].includes(key)) {
22 | return caches.delete(key);
23 | }
24 | })
25 | )
26 | )
27 | .then(() => {
28 | console.log(`${CACHE_NAME} now ready to handle fetches!`);
29 | return clients.claim();
30 | })
31 | .then(() => {
32 | return self.clients
33 | .matchAll()
34 | .then(clients =>
35 | Promise.all(
36 | clients.map(client => client.postMessage('Service Worker is ready for use!'))
37 | )
38 | );
39 | })
40 | );
41 | });
42 |
43 | self.addEventListener('message', event => {
44 | caches.open('custom').then(cache => {
45 | switch (event.data.type) {
46 | case 'insert':
47 | return fetch(new Request(event.data.value, { mode: 'no-cors' }))
48 | .then(response => {
49 | cache.put(event.data.value, response);
50 | return response.headers.get('content-length');
51 | })
52 | .then(length => {
53 | event.ports[0].postMessage({
54 | error: null,
55 | data: event.data,
56 | length: Math.round(length),
57 | });
58 | });
59 | case 'remove':
60 | return cache.delete(event.data.value).then(success => {
61 | event.ports[0].postMessage({
62 | error: success ? null : 'Item was not found in the cache.',
63 | });
64 | });
65 | case 'removeAll':
66 | return cache.keys().then(keys =>
67 | Promise.all(
68 | keys.map(key => {
69 | return cache.delete(key);
70 | })
71 | )
72 | );
73 | default:
74 | throw Error(`Unknown command: ${event.data.type}`);
75 | }
76 | });
77 | });
78 |
--------------------------------------------------------------------------------
/api/utils/atob.js:
--------------------------------------------------------------------------------
1 | function atob(str) {
2 | return new Buffer(str, 'base64').toString('binary');
3 | }
4 |
5 | module.exports = atob;
6 |
--------------------------------------------------------------------------------
/api/utils/load.js:
--------------------------------------------------------------------------------
1 | const { version } = require('./../../package.json');
2 | const fs = require('fs');
3 | const minify = require('html-minifier').minify;
4 |
5 | module.exports = (files, store) =>
6 | new Promise((resolve, reject) => {
7 | let count = files.length;
8 | for (let i = 0; i < files.length; i += 1) {
9 | const { filename, name, collapse = false } = files[i];
10 | fs.readFile(`${__dirname}/..${filename}`, 'utf8', (error, data) => {
11 | if (typeof data === 'undefined') {
12 | store.set(name, 'Resource not found!');
13 | } else {
14 | data = data.replace('__VERSION__', version);
15 | store.set(
16 | name,
17 | Buffer.from(
18 | collapse
19 | ? minify(data, {
20 | collapseWhitespace: 'true',
21 | })
22 | : data,
23 | 'utf8'
24 | )
25 | );
26 | }
27 | count -= 1;
28 | if (count === 0) resolve();
29 | });
30 | }
31 | });
32 |
--------------------------------------------------------------------------------
/api/utils/store.js:
--------------------------------------------------------------------------------
1 | module.exports = new Map();
2 |
--------------------------------------------------------------------------------
/api/utils/track.js:
--------------------------------------------------------------------------------
1 | const store = require('./store');
2 |
3 | const config = require('./../config');
4 |
5 | function track(ua, type, payload = {}) {
6 | if (config.track === false) return;
7 | const collection = store.get(`db-${type}`);
8 | const time = Date.now().toString();
9 | collection.insert({ ua, time, ...payload });
10 | }
11 |
12 | module.exports = track;
13 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "preact-gallery",
3 | "version": "1.2.0",
4 | "description": "A modern gallery experience disguised as a tiny (8kb) Preact based PWA.",
5 | "main": "index.js",
6 | "scripts": {
7 | "start": "preact watch",
8 | "build": "rollup -c && npm run size",
9 | "size": "gzip-size ./api/public/pg.js"
10 | },
11 | "license": "MIT",
12 | "author": {
13 | "name": "Colin van Eenige",
14 | "email": "cvaneenige@gmail.com",
15 | "url": "https://use-the-platform.com"
16 | },
17 | "dependencies": {
18 | "@polka/send": "^0.3.0",
19 | "better-validator": "^2.1.7",
20 | "brotli-size": "0.0.2",
21 | "compression": "^1.7.1",
22 | "gzip-size": "^4.1.0",
23 | "html-minifier": "^3.5.8",
24 | "mongodb": "^3.0.3",
25 | "node-fetch": "^2.0.0",
26 | "polka": "^0.3.0",
27 | "preact": "^8.2.7",
28 | "serve-static": "^1.13.1",
29 | "shrink-ray": "^0.1.3",
30 | "unvault-middleware": "^0.2.0"
31 | },
32 | "devDependencies": {
33 | "autoprefixer": "^8.0.0",
34 | "babel-plugin-external-helpers": "^6.22.0",
35 | "babel-preset-es2015": "^6.24.1",
36 | "babel-preset-stage-0": "^6.24.1",
37 | "gzip-size-cli": "^2.1.0",
38 | "preact-cli": "^2.1.1",
39 | "rollup": "^0.56.2",
40 | "rollup-plugin-babel": "^3.0.3",
41 | "rollup-plugin-commonjs": "^8.3.0",
42 | "rollup-plugin-node-resolve": "^3.0.3",
43 | "rollup-plugin-postcss": "^1.2.9",
44 | "rollup-plugin-uglify": "^3.0.0",
45 | "webpack-bundle-analyzer": "^2.10.0"
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/rollup.config.js:
--------------------------------------------------------------------------------
1 | import resolve from 'rollup-plugin-node-resolve';
2 | import commonjs from 'rollup-plugin-commonjs';
3 | import babel from 'rollup-plugin-babel';
4 | import uglify from 'rollup-plugin-uglify';
5 | import postcss from 'rollup-plugin-postcss';
6 |
7 | const uglifySettings = {
8 | compress: {
9 | negate_iife: false,
10 | unsafe_comps: true,
11 | properties: true,
12 | keep_fargs: false,
13 | pure_getters: true,
14 | collapse_vars: true,
15 | unsafe: true,
16 | warnings: false,
17 | sequences: true,
18 | dead_code: true,
19 | drop_debugger: true,
20 | comparisons: true,
21 | conditionals: true,
22 | evaluate: true,
23 | booleans: true,
24 | loops: true,
25 | unused: true,
26 | hoist_funs: true,
27 | if_return: true,
28 | join_vars: true,
29 | drop_console: true,
30 | pure_funcs: ['classCallCheck', 'invariant', 'warning'],
31 | },
32 | output: {
33 | comments: false,
34 | },
35 | };
36 |
37 | export default {
38 | input: 'src/index.js',
39 | output: {
40 | file: 'api/public/pg.js',
41 | format: 'iife',
42 | name: 'pg',
43 | sourcemap: false,
44 | },
45 | plugins: [
46 | /**
47 | * Seamless integration between Rollup and PostCSS.
48 | * @see https://github.com/egoist/rollup-plugin-postcss
49 | */
50 | postcss({
51 | extract: true,
52 | minimize: true,
53 | }),
54 | /**
55 | * Convert ES2015 with buble.
56 | * @see https://github.com/rollup/rollup-plugin-buble
57 | */
58 | babel({
59 | babelrc: false,
60 | presets: [['es2015', { loose: true, modules: false }], 'stage-0'],
61 | plugins: ['external-helpers', ['transform-react-jsx', { pragma: 'h' }]],
62 | }),
63 | /**
64 | * Use the Node.js resolution algorithm with Rollup.
65 | * @see https://github.com/rollup/rollup-plugin-node-resolve
66 | */
67 | resolve({ jsnext: true }),
68 | /**
69 | * Convert CommonJS modules to ES2015.
70 | * @see https://github.com/rollup/rollup-plugin-commonjs
71 | */
72 | commonjs(),
73 | /**
74 | * Rollup plugin to minify generated bundle.
75 | * @see https://github.com/TrySound/rollup-plugin-uglify
76 | */
77 | uglify(uglifySettings),
78 | ],
79 | };
80 |
--------------------------------------------------------------------------------
/src/components/App.js:
--------------------------------------------------------------------------------
1 | import { h, Component } from 'preact';
2 |
3 | import localStorage from './../utils/localStorage';
4 | import serviceWorker from './../utils/serviceWorker';
5 |
6 | import Toolbar from './Toolbar';
7 | import List from './List';
8 | import Modal from './Modal';
9 | import Form from './Form';
10 | import Settings from './Settings';
11 |
12 | import './../styling/app.css';
13 |
14 | class App extends Component {
15 | state = {
16 | items: localStorage.find('items') || [],
17 | total: localStorage.find('total') || 0,
18 | modal: false,
19 | edit: false,
20 | settings: false,
21 | selected: null,
22 | added: 0,
23 | network: true,
24 | };
25 |
26 | componentDidMount() {
27 | window.addEventListener('online', this.network);
28 | window.addEventListener('offline', this.network);
29 | this.network();
30 | }
31 |
32 | network = () => {
33 | this.setState({ network: navigator.onLine });
34 | };
35 |
36 | edit = selected => {
37 | this.setState({
38 | modal: true,
39 | edit: true,
40 | settings: false,
41 | selected: selected.type !== 'click' ? selected : null,
42 | });
43 | };
44 |
45 | settings = () => {
46 | this.setState({
47 | modal: true,
48 | settings: true,
49 | edit: false,
50 | });
51 | };
52 |
53 | close = () => {
54 | this.setState({
55 | modal: false,
56 | });
57 | };
58 |
59 | loadImage = (item, update) => {
60 | serviceWorker.message(
61 | {
62 | type: 'insert',
63 | value: `${window.location.href}image/${btoa(update.src)}`,
64 | },
65 | value => {
66 | update.size = value.length;
67 | this.update(item.key, update);
68 | },
69 | error => {
70 | this.update(item.key, update);
71 | }
72 | );
73 | };
74 |
75 | insert = (key, update) => {
76 | const { items } = this.state;
77 |
78 | if (update.src === '') {
79 | const rand = window.devicePixelRatio * 200 + Math.random() * 200;
80 | update.src = `https://picsum.photos/${rand}/${rand}`;
81 | }
82 |
83 | const item = {
84 | ...update,
85 | key: key !== null ? key : Math.random(),
86 | size: 0,
87 | };
88 |
89 | if (window.sw) item.src = null;
90 | if (key === null) items.push(item);
91 | if (window.sw) this.loadImage(item, update);
92 |
93 | this.save(items);
94 | };
95 |
96 | update = (key, update) => {
97 | this.save(
98 | this.state.items.map(item => {
99 | if (item.key === key) Object.assign(item, update);
100 | return item;
101 | })
102 | );
103 | };
104 |
105 | remove = ({ src, key }) => {
106 | if (window.sw) {
107 | serviceWorker.message({
108 | type: 'remove',
109 | value: `${window.location.href}image/${src}`,
110 | });
111 | }
112 | this.save(this.state.items.filter(item => item.key !== key));
113 | };
114 |
115 | save = (items, modal = false) => {
116 | const total = items.reduce((acc, { size }) => acc + size, 0);
117 | this.setState({
118 | items,
119 | total,
120 | modal,
121 | added: this.state.added + 1,
122 | });
123 | localStorage.insert('items', items);
124 | localStorage.insert('total', total);
125 | };
126 |
127 | clear = () => {
128 | if (window.sw) {
129 | serviceWorker.message({
130 | type: 'removeAll',
131 | });
132 | }
133 | this.save([], true);
134 | };
135 |
136 | render() {
137 | const { items, selected, edit, total, settings, modal, added, network } = this.state;
138 | return (
139 |
140 |
141 |
142 |
143 | {this.state.edit ? (
144 |
152 | ) : (
153 |
154 | )}
155 |
156 |
164 |
165 | );
166 | }
167 | }
168 |
169 | export default App;
170 |
--------------------------------------------------------------------------------
/src/components/Form.js:
--------------------------------------------------------------------------------
1 | import { h, Component } from 'preact';
2 |
3 | import localStorage from './../utils/localStorage';
4 |
5 | import Item from './Item';
6 |
7 | import './../styling/form.css';
8 |
9 | class Form extends Component {
10 | onSubmit = e => {
11 | e.preventDefault();
12 | const { insert, update, selected } = this.props;
13 | const item = { src: e.currentTarget.elements[0].value };
14 | if (selected === null) {
15 | insert(null, item);
16 | return;
17 | }
18 | insert(selected.key, item);
19 | };
20 |
21 | remove = e => {
22 | e.preventDefault();
23 | const { selected, remove } = this.props;
24 | remove(selected);
25 | };
26 |
27 | render() {
28 | setTimeout(() => {
29 | this.source.focus();
30 | }, 400);
31 |
32 | const { selected } = this.props;
33 | return (
34 |
48 | );
49 | }
50 | }
51 |
52 | export default Form;
53 |
--------------------------------------------------------------------------------
/src/components/Item.js:
--------------------------------------------------------------------------------
1 | import { h, Component } from 'preact';
2 |
3 | import './../styling/item.css';
4 |
5 | class Item extends Component {
6 | state = {
7 | loaded: false,
8 | };
9 |
10 | shouldComponentUpdate(props) {
11 | return props.src !== this.props.src;
12 | }
13 |
14 | onClick = () => {
15 | this.props.edit(this.props.item);
16 | };
17 |
18 | render() {
19 | const { item, src } = this.props;
20 | return (
21 |
22 |
35 |
36 | );
37 | }
38 | }
39 |
40 | export default Item;
41 |
--------------------------------------------------------------------------------
/src/components/List.js:
--------------------------------------------------------------------------------
1 | import { h, Component } from 'preact';
2 |
3 | import localStorage from './../utils/localStorage';
4 |
5 | import Item from './Item';
6 |
7 | import './../styling/list.css';
8 |
9 | class List extends Component {
10 | shouldComponentUpdate(props) {
11 | return props.added !== this.props.added;
12 | }
13 |
14 | render() {
15 | const { items, edit } = this.props;
16 | return (
17 |
18 |
19 | {items.map(item => )}
20 |
21 |
22 | );
23 | }
24 | }
25 |
26 | export default List;
27 |
--------------------------------------------------------------------------------
/src/components/Modal.js:
--------------------------------------------------------------------------------
1 | import { h, Component } from 'preact';
2 |
3 | import './../styling/modal.css';
4 |
5 | class Modal extends Component {
6 | componentDidMount() {
7 | window.addEventListener('keydown', e => {
8 | if (e.keyCode === 27) this.props.close();
9 | });
10 | }
11 |
12 | render() {
13 | const { modal, close, children } = this.props;
14 | return (
15 |
16 |
17 |
{children}
18 |
19 | );
20 | }
21 | }
22 |
23 | export default Modal;
24 |
--------------------------------------------------------------------------------
/src/components/Settings.js:
--------------------------------------------------------------------------------
1 | import { h, Component } from 'preact';
2 |
3 | import './../styling/settings.css';
4 |
5 | import localStorage from './../utils/localStorage';
6 |
7 | class Settings extends Component {
8 | state = {
9 | night: localStorage.find('night') || false,
10 | };
11 |
12 | componentDidMount() {
13 | if (this.state.night) this.setThemeAttributes(this.state.night);
14 | }
15 |
16 | setThemeAttributes(night, initial) {
17 | if (night) document.body.classList.add('night');
18 | else document.body.classList.remove('night');
19 |
20 | const addition = night ? '-night' : '';
21 | document
22 | .querySelector(`[rel="icon"]`)
23 | .setAttribute('href', `/assets/icons/favicon${addition}.ico`);
24 | document
25 | .querySelector('[rel="apple-touch-icon"]')
26 | .setAttribute('href', `/assets/icons/PG_IOS${addition}.png`);
27 | document.querySelector(`[rel="manifest"]`).setAttribute('href', `/manifest${addition}.json`);
28 | document
29 | .querySelector('[name="theme-color"]')
30 | .setAttribute('content', night ? '#263238' : '#673ab8');
31 | }
32 |
33 | render() {
34 | if (this.props.total !== 0) this.storage = true;
35 |
36 | const settings = [
37 | {
38 | title: 'Toggle Night Mode',
39 | subtitle: `Night mode is turned ${this.state.night ? 'on' : 'off'}`,
40 | handler: () => {
41 | this.setThemeAttributes(!this.state.night);
42 | localStorage.insert('night', !this.state.night);
43 | this.setState({ night: !this.state.night });
44 | },
45 | },
46 | {
47 | title: 'Clear all data',
48 | subtitle: this.storage
49 | ? `Total storage used: ${(this.props.total / 1000).toFixed(1)}kB`
50 | : 'All images will be removed',
51 | handler: this.props.clear,
52 | },
53 | ];
54 |
55 | return (
56 |
57 | Settings
58 | {settings.map(setting => (
59 | -
60 |
{setting.title}
61 | {setting.subtitle}
62 |
63 | ))}
64 |
65 | );
66 | }
67 | }
68 |
69 | export default Settings;
70 |
--------------------------------------------------------------------------------
/src/components/Toolbar.js:
--------------------------------------------------------------------------------
1 | import { h, Component } from 'preact';
2 |
3 | import './../styling/toolbar.css';
4 |
5 | class Toolbar extends Component {
6 | shouldComponentUpdate(props) {
7 | return props.open !== this.props.open;
8 | }
9 |
10 | render() {
11 | return (
12 |
13 |
14 |
24 |
25 | );
26 | }
27 | }
28 |
29 | export default Toolbar;
30 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import { h, render } from 'preact';
2 |
3 | import App from './components/App';
4 |
5 | render(, document.body, document.body.firstChild);
6 |
--------------------------------------------------------------------------------
/src/styling/app.css:
--------------------------------------------------------------------------------
1 | html,
2 | body,
3 | #app {
4 | width: 100%;
5 | height: 100%;
6 | overflow: hidden;
7 | position: fixed;
8 | top: 0;
9 | left: 0;
10 | }
11 |
12 | body {
13 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen,
14 | Ubuntu, Cantarell, "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
15 | letter-spacing: 0;
16 | font-style: normal;
17 | text-rendering: optimizeLegibility;
18 | -webkit-font-smoothing: antialiased;
19 | -moz-osx-font-smoothing: grayscale;
20 | color: rgba(0, 0, 0, 0.8);
21 | font-size: 18px;
22 | margin: 0;
23 | }
24 |
25 | * {
26 | -webkit-tap-highlight-color: transparent;
27 | }
28 |
29 | .layout-vertical {
30 | display: flex;
31 | flex-direction: column;
32 | }
33 |
34 | .layout-horizontal {
35 | display: flex;
36 | flex-direction: row;
37 | width: calc(100% + 16px);
38 | margin-left: -8px;
39 | }
40 |
41 | .float {
42 | background-color: #673ab8;
43 | box-shadow: 0 0 4px 0 rgba(0, 0, 0, 0.12), 0 4px 4px 0 rgba(0, 0, 0, 0.24);
44 | position: fixed;
45 | bottom: 20px;
46 | right: 20px;
47 | border: none;
48 | transition-property: transform;
49 | transition-duration: 0.4s;
50 | transition-timing-function: cubic-bezier(0.65, 0.05, 0.35, 1);
51 | z-index: 0;
52 | outline: none;
53 | cursor: pointer;
54 | padding: 12px 24px;
55 | font-size: 16px;
56 | font-weight: 500;
57 | color: white;
58 | text-transform: uppercase;
59 | }
60 |
61 | .float.hidden {
62 | transform: translate3d(0, 200px, 0);
63 | }
64 |
65 | .night .toolbar,
66 | .night .item-information,
67 | .night .float,
68 | .night .item-container,
69 | .night .button:hover, .night .button:focus {
70 | background-color: #263238;
71 | }
72 |
73 | .night input:focus {
74 | border-color: #263238;
75 | }
76 |
77 | .night .item-container:focus {
78 | box-shadow: inset 0 0 0 2px #263238;
79 | }
80 |
81 | .night .list-container,
82 | .night .modal {
83 | background-color: #37474f;
84 | }
85 |
86 | .night input {
87 | color: white;
88 | }
89 |
90 | .night ::-webkit-input-placeholder {
91 | color: white;
92 | }
93 |
94 | .night ::-moz-placeholder {
95 | color: white;
96 | }
97 |
98 | .night :-ms-input-placeholder {
99 | color: white;
100 | }
101 |
102 | .night :-moz-placeholder {
103 | color: white;
104 | }
105 |
106 | .night .settings-title, .night .settings-subtitle, .night .settings-label {
107 | color: white;
108 | }
109 |
110 | .night .settings-subtitle {
111 | color: #B0BEC5;
112 | }
113 |
114 | .night .button {
115 | color: white;
116 | border: 2px solid #263238;
117 | }
--------------------------------------------------------------------------------
/src/styling/form.css:
--------------------------------------------------------------------------------
1 | input {
2 | font-size: 16px;
3 | font-weight: 300;
4 | color: #202020;
5 | border: none;
6 | padding: 12px 8px;
7 | width: 100%;
8 | border-bottom: 1px solid #ccc;
9 | border-radius: 0;
10 | margin: 0 0 12px 0;
11 | background-color: transparent;
12 | }
13 |
14 | input:focus {
15 | border-bottom: 1px solid #673ab8;
16 | outline: none;
17 | }
18 |
19 | .button {
20 | flex: 5;
21 | margin: 16px 8px 0;
22 | -webkit-appearance: none;
23 | display: inline-block;
24 | box-sizing: border-box;
25 | border: 2px solid #673ab8;
26 | font-size: 16px;
27 | font-weight: 500;
28 | color: #673ab8;
29 | text-align: center;
30 | text-decoration: none;
31 | text-transform: uppercase;
32 | border-radius: 0;
33 | outline: none;
34 | padding: 8px 24px;
35 | }
36 |
37 | .button:focus,
38 | .button:hover {
39 | cursor: pointer;
40 | color: white;
41 | background-color: #673ab8;
42 | border: 2px solid #673ab8;
43 | outline: none;
44 | }
45 |
46 | .button.remove {
47 | border: 2px solid #f2777a;
48 | color: #f2777a;
49 | flex: 1;
50 | }
51 |
52 | .button.remove:focus,
53 | .button.remove:hover {
54 | background-color: #f2777a;
55 | color: white;
56 | }
57 |
58 | form {
59 | margin-bottom: 0;
60 | }
--------------------------------------------------------------------------------
/src/styling/item.css:
--------------------------------------------------------------------------------
1 | .item {
2 | position: relative;
3 | margin: 0 0px 20px 20px;
4 | width: calc(50% - 30px);
5 | }
6 |
7 | .item-container:focus {
8 | outline: 0;
9 | box-shadow: inset 0 0 0 2px #673ab8;
10 | }
11 |
12 | @media (min-width: 678px) {
13 | .item {
14 | width: calc((100% - 80px) / 3);
15 | }
16 | }
17 |
18 | @media (min-width: 1024px) {
19 | .item {
20 | width: calc((100% - 100px) / 4);
21 | }
22 | }
23 |
24 | .item-container {
25 | position: relative;
26 | width: 100%;
27 | padding-top: 75%;
28 | border: none;
29 | box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
30 | background-color: #efefef;
31 | }
32 |
33 | .item-information {
34 | position: absolute;
35 | padding: 6px 12px;
36 | right: 0;
37 | bottom: 0;
38 | background-color: #673ab8;
39 | color: white;
40 | font-size: 16px;
41 | }
--------------------------------------------------------------------------------
/src/styling/list.css:
--------------------------------------------------------------------------------
1 | .list-container {
2 | height: calc(100% - 56px);
3 | overflow-y: auto;
4 | position: relative;
5 | width: 100%;
6 | -webkit-overflow-scrolling: touch;
7 | }
8 |
9 | .list {
10 | list-style: none;
11 | margin: 0;
12 | padding: 0;
13 | display: flex;
14 | width: 100%;
15 | align-items: center;
16 | justify-content: flex-start;
17 | flex-wrap: wrap;
18 | margin: 0 auto;
19 | position: relative;
20 | padding-top: 20px;
21 | }
--------------------------------------------------------------------------------
/src/styling/modal.css:
--------------------------------------------------------------------------------
1 | .modal {
2 | background-color: #fff;
3 | background-color: #fff;
4 | bottom: 0;
5 | box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
6 | display: block;
7 | left: 50%;
8 | list-style: none;
9 | margin: 0;
10 | height: calc(100% - 56px);
11 | max-width: 768px;
12 | overflow-y: auto;
13 | position: fixed;
14 | transform: translate3d(-50%, 100%, 0);
15 | transition-duration: 0.4s;
16 | transition-property: visibility, transform;
17 | transition-timing-function: cubic-bezier(0.65, 0.05, 0.35, 1);
18 | visibility: hidden;
19 | width: calc(100% - 40px);
20 | padding: 20px;
21 | will-change: visibility, transform;
22 | height: auto;
23 | z-index: 2;
24 | }
25 |
26 | .modal.active {
27 | transform: translate3d(-50%, 0, 0);
28 | visibility: visible;
29 | }
30 |
31 | .background {
32 | position: fixed;
33 | background-color: black;
34 | top: 0;
35 | left: 0;
36 | width: 100%;
37 | height: 100%;
38 | z-index: 1;
39 | visibility: hidden;
40 | will-change: opacity;
41 | opacity: 0;
42 | transition-duration: 0.4s;
43 | transition-timing-function: cubic-bezier(0.65, 0.05, 0.36, 1);
44 | cursor: pointer;
45 | }
46 |
47 | .background.active {
48 | opacity: 0.6;
49 | visibility: visible;
50 | }
--------------------------------------------------------------------------------
/src/styling/settings.css:
--------------------------------------------------------------------------------
1 |
2 | .settings {
3 | margin: 0;
4 | padding: 0;
5 | }
6 |
7 | .settings-label {
8 | font-size: 16px;
9 | margin: 8px 0 12px;
10 | color: black;
11 | display: block;
12 | }
13 |
14 | .settings-content {
15 | height: 80px;
16 | list-style: none;
17 | display: flex;
18 | flex-direction: column;
19 | flex: 1;
20 | justify-content: center;
21 | cursor: pointer;
22 | }
23 |
24 | .settings-title {
25 | color: rgba(0,0,0,.87);
26 | font-size: 18px;
27 | line-height: 34px;
28 | }
29 |
30 | .settings-subtitle {
31 | color: rgba(0,0,0,.54);
32 | font-size: 16px;
33 | line-height: 24px;
34 | }
--------------------------------------------------------------------------------
/src/styling/toolbar.css:
--------------------------------------------------------------------------------
1 | .toolbar {
2 | color: white;
3 | height: 56px;
4 | line-height: 56px;
5 | box-shadow: rgba(0, 0, 0, 0.3) 0px 0px 4px;
6 | position: relative;
7 | top: 0;
8 | left: 0;
9 | width: calc(100% - 40px);
10 | background-color: #673ab8;
11 | padding: 0px 20px;
12 | z-index: 1;
13 | user-select: none;
14 | }
15 |
16 | .toolbar-title {
17 | color: white;
18 | line-height: 56px;
19 | font-size: 18px;
20 | font-weight: 400;
21 | margin: 0;
22 | display: inline-block;
23 | }
24 |
25 | .toolbar-settings {
26 | cursor: pointer;
27 | padding: 16px;
28 | position: absolute;
29 | right: 8px;
30 | line-height: 0;
31 | transition-duration: 0.4s;
32 | transition-timing-function: cubic-bezier(0.65, 0.05, 0.35, 1);
33 | }
34 |
35 | .toolbar-settings.active {
36 | transform: rotate(180deg);
37 | }
--------------------------------------------------------------------------------
/src/utils/localStorage.js:
--------------------------------------------------------------------------------
1 | function find(key) {
2 | const state = localStorage.getItem(`preact-gallery-${key}`);
3 | if (state === null) return null;
4 | return JSON.parse(state);
5 | }
6 |
7 | function insert(key, state) {
8 | localStorage.setItem(`preact-gallery-${key}`, JSON.stringify(state));
9 | }
10 |
11 | export default { find, insert };
12 |
--------------------------------------------------------------------------------
/src/utils/serviceWorker.js:
--------------------------------------------------------------------------------
1 | function message(message, successCallback, errorCallback) {
2 | try {
3 | const messageChannel = new MessageChannel();
4 | messageChannel.port1.onmessage = event => {
5 | if (event.data.error) {
6 | errorCallback && errorCallback(event.data.error);
7 | return;
8 | }
9 | successCallback && successCallback(event.data);
10 | };
11 | navigator.serviceWorker.controller.postMessage(message, [messageChannel.port2]);
12 | } catch (err) {
13 | errorCallback && errorCallback(err);
14 | }
15 | }
16 |
17 | export default { message };
18 |
--------------------------------------------------------------------------------