├── .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 |
47 |
48 |

49 | Preact Gallery 50 |

51 |
52 |
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 | 35 | (this.source = c)} 40 | /> 41 |
42 | 43 | {selected !== null && ( 44 | 45 | )} 46 |
47 | 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 | 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 | 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 |

    Preact Gallery

    14 | 22 | {[6, 12, 18].map(y => )} 23 | 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 | --------------------------------------------------------------------------------