Follow my journey developing an offline-first recipe book Progressive Web App
Offline first
True Serverless
const recipe = writable({
id: 'JdjtOympO6yuhGytoNBHv',
image: '/images/JdjtOympO6yuhGytoNBHv',
title: 'Pancakes',
description: 'With bananas and honey',
prepTime: 25,
portions: 2,
ingredients: ['3 eggs', '60gr flour'...],
instructions: ['Mix wet ingredients'...],
})Read
Create
Update
Delete
const persist = debounce(() => {
localStorage.setItem(
'recipes',
JSON.stringify(get(recipes).map((item) => get(item))),
)
}, 500)
export const recipes = writable([])
let suscriptions = []
// Listens for changes in the top level recipes array
recipes.subscribe(() => {
// Unsubscribe from every previous individual recipe subscription
suscriptions.forEach((unsubscribe) => {
unsubscribe()
})
// Create new subscriptions for every recipe object in the recipes list
suscriptions = get(recipes).map((store) => store.subscribe(persist))
persist()
})let items = []
export const restore = () => {
try {
items = JSON.parse(localStorage.getItem('recipes'))
if (!Array.isArray(items)) {
items = []
}
} catch {
items = []
}
recipes.set(items.map((item) => writable(item)))
}<input
class="add-photo-input"
type="file"
accept="image/*"
on:change={(event) => {
let file = event.target.files[0]
$recipe.image = await saveImage(file, $recipe.id)
}}
/>const imageDB = new Promise((resolve) => {
let db
let dbReady = false
let dbVersion = 2
const request = indexedDB.open('files', dbVersion)
request.onsuccess = (event) => {
db = event.target.result
}
request.onupgradeneeded = (event) => {
let db = event.target.result
db.createObjectStore('images', { keyPath: 'id', autoIncrement: true })
dbReady = true
resolve(db)
}
})export const saveImage = async (file) => {
const reader = new FileReader()
reader.readAsBinaryString(file)
return new Promise((resolve, reject) => {
reader.onload = (event) => {
const bits = event.target.result
const ob = {
created: new Date(),
data: bits,
}
const trans = (await imageDB).transaction(['images'], 'readwrite')
const addReq = trans.objectStore('images').add(ob)
addReq.onerror = (event) => {
reject(new Error('Error storing data'))
}
addReq.onsuccess = (event) => {
resolve(event.target.result)
}
}
})
}export const saveImage = async (file, id = nanoid()) => {
let newCache = await caches.open('images-cache')
const request = `/images/${id}`
await newCache.put(request, new Response(file))
return request
}export const getImage = async (request) => {
let newCache = await caches.open('images-cache')
const response = await newCache.match(request)
if (!response) return null
return URL.createObjectURL(await response.blob())
}new File(bits, name, options)new Blob(array, options)Name prop
Streams or other blobs
Type, last modified...
export const pack = (files) => {
const dir = []
let start = 0
for (const item of files) {
dir.push([item.name, start, item.size, item.type])
start += item.size
}
const dirJSON = JSON.stringify(dir)
const dirSize = new Uint32Array([dirJSON.length])
return new File([...files, dirJSON, dirSize], 'myfiles.archive')
}export const compress = async (file) => {
const readableStream = file.stream()
const compressedStream = readableStream.pipeThrough(
new CompressionStream('gzip'),
)
return streamToBlob(compressedStream)
}const streamToBlob = async (stream) => {
const res = new Response(stream)
return await res.blob()
}
đ
export const download = (blob, name) => {
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = name
link.click()
URL.revokeObjectURL(url)
}
const handleDownload = async () => {
download(
new File([exportBlob], 'monchi.export.gz', { type: 'application/gzip' }),
'monchi.export.gz',
)
}export const decompress = async (file) => {
const readableStream = file.stream()
const decompressedStream = readableStream.pipeThrough(
new DecompressionStream('gzip'),
)
return streamToBlob(decompressedStream)
}export const readCentralDir = async (blob) => {
const last4 = await blob.slice(-4).arrayBuffer()
const size = new DataView(last4).getUint32(0,true)
const jsonPart = blob.slice(blob.size - size - 4, blob.size - 4)
return new Response(jsonPart).json()
}export const unpack = (file, centralDir) =>
centralDir.map((item) => {
const [name, start, size, type] = item
return new File([file.slice(start, start + size)], name, {
type: type,
})
})const importRecipes = async (files) => {
const file = files[0]
const blob = await decompress(file)
const centralDir = await readCentralDir(blob)
const fileList = unpack(blob, centralDir)
for (const file of fileList) {
if (file.name === 'recipes.json') {
const text = await file.text()
localStorage.setItem('recipes', text)
} else {
await saveImage(file, file.name)
}
}
restore()
}Image Compression
Synch between devices