feat: v6.0.0 PWA offline support
- manifest + icones, service worker (precache, network-first, Background Sync)
- module client FlowOffline (IndexedDB, queue, delta, flush) + hook editeur
- endpoints /api/v2/sync/{delta,batch,status} + moteur de sync (conflits LWW/orpheline/copie offline)
- migrations offline_sync_queue + sync_version (triggers)
- UI offline (banner, badge sync, toasts, icone dirty) + doc /help
- tests pytest (sync, migrations, SW, offline) + E2E Playwright; bump 6.0.0
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 999 B |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 624 B |
|
After Width: | Height: | Size: 767 B |
@@ -0,0 +1,466 @@
|
||||
/* ═══════════════════════════════════════════════════════════
|
||||
FlowDeck — Offline client module (v6.0.0 PWA)
|
||||
|
||||
Companion to static/sw.js and app/routers/sync.py:
|
||||
- IndexedDB `flowdeck-offline` : pages_offline / collections_offline /
|
||||
sync_queue / sync_meta
|
||||
- Optimistic local writes when offline
|
||||
- Mutation queue replayed to POST /api/v2/sync/batch on reconnect
|
||||
- Pull server changes via GET /api/v2/sync/delta
|
||||
|
||||
Exposed as `window.FlowOffline`. No bundler: plain global IIFE.
|
||||
═══════════════════════════════════════════════════════════ */
|
||||
'use strict';
|
||||
|
||||
(function () {
|
||||
var DB_NAME = 'flowdeck-offline';
|
||||
var DB_VERSION = 1;
|
||||
var STORES = {
|
||||
PAGES: 'pages_offline',
|
||||
COLLECTIONS: 'collections_offline',
|
||||
QUEUE: 'sync_queue',
|
||||
META: 'sync_meta',
|
||||
};
|
||||
var DEVICE_KEY = 'fd_device_id';
|
||||
var LAST_SYNC_KEY = 'last_server_sync';
|
||||
var MAX_BATCH = 100;
|
||||
var QUEUE_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
var FETCH_TIMEOUT_MS = 30000;
|
||||
|
||||
var _dbPromise = null;
|
||||
var _listeners = new Set();
|
||||
var state = {
|
||||
online: (typeof navigator !== 'undefined') ? navigator.onLine : true,
|
||||
pendingSyncCount: 0,
|
||||
isSyncing: false,
|
||||
lastSyncAt: null,
|
||||
lastError: null,
|
||||
};
|
||||
|
||||
// ── state / events ────────────────────────────────────────────────────
|
||||
function snapshot() {
|
||||
return {
|
||||
online: state.online,
|
||||
pendingSyncCount: state.pendingSyncCount,
|
||||
isSyncing: state.isSyncing,
|
||||
lastSyncAt: state.lastSyncAt,
|
||||
lastError: state.lastError,
|
||||
};
|
||||
}
|
||||
|
||||
function emit() {
|
||||
var snap = snapshot();
|
||||
_listeners.forEach(function (cb) {
|
||||
try { cb(snap); } catch { /* listener errors must not break sync */ }
|
||||
});
|
||||
}
|
||||
|
||||
function onChange(cb) {
|
||||
_listeners.add(cb);
|
||||
try { cb(snapshot()); } catch { /* ignore */ }
|
||||
return function () { _listeners.delete(cb); };
|
||||
}
|
||||
|
||||
function getState() { return snapshot(); }
|
||||
|
||||
function deviceId() {
|
||||
try {
|
||||
var v = localStorage.getItem(DEVICE_KEY);
|
||||
if (!v) {
|
||||
v = 'dev-' + Math.random().toString(36).slice(2) + Date.now().toString(36);
|
||||
localStorage.setItem(DEVICE_KEY, v);
|
||||
}
|
||||
return v;
|
||||
} catch {
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
// ── IndexedDB plumbing ────────────────────────────────────────────────
|
||||
function openDB() {
|
||||
if (_dbPromise) return _dbPromise;
|
||||
_dbPromise = new Promise(function (resolve, reject) {
|
||||
if (typeof window.indexedDB === 'undefined') {
|
||||
reject(new Error('IndexedDB unavailable'));
|
||||
return;
|
||||
}
|
||||
var req = window.indexedDB.open(DB_NAME, DB_VERSION);
|
||||
req.onupgradeneeded = function () {
|
||||
var db = req.result;
|
||||
if (!db.objectStoreNames.contains(STORES.PAGES)) {
|
||||
db.createObjectStore(STORES.PAGES, { keyPath: 'id' });
|
||||
}
|
||||
if (!db.objectStoreNames.contains(STORES.COLLECTIONS)) {
|
||||
db.createObjectStore(STORES.COLLECTIONS, { keyPath: 'id' });
|
||||
}
|
||||
if (!db.objectStoreNames.contains(STORES.QUEUE)) {
|
||||
var q = db.createObjectStore(STORES.QUEUE, { keyPath: 'id', autoIncrement: true });
|
||||
q.createIndex('status', 'status', { unique: false });
|
||||
}
|
||||
if (!db.objectStoreNames.contains(STORES.META)) {
|
||||
db.createObjectStore(STORES.META, { keyPath: 'key' });
|
||||
}
|
||||
};
|
||||
req.onsuccess = function () { resolve(req.result); };
|
||||
req.onerror = function () { reject(req.error); };
|
||||
});
|
||||
return _dbPromise;
|
||||
}
|
||||
|
||||
function idbRequest(store, mode, build) {
|
||||
return openDB().then(function (db) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
var t = db.transaction(store, mode);
|
||||
var req = build(t.objectStore(store));
|
||||
req.onsuccess = function () { resolve(req.result); };
|
||||
req.onerror = function () { reject(req.error); };
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function idbGet(store, key) { return idbRequest(store, 'readonly', function (s) { return s.get(key); }); }
|
||||
function idbAll(store) { return idbRequest(store, 'readonly', function (s) { return s.getAll(); }); }
|
||||
function idbPut(store, val) { return idbRequest(store, 'readwrite', function (s) { return s.put(val); }); }
|
||||
function idbAdd(store, val) { return idbRequest(store, 'readwrite', function (s) { return s.add(val); }); }
|
||||
function idbDelete(store, key) { return idbRequest(store, 'readwrite', function (s) { return s.delete(key); }); }
|
||||
|
||||
// Fetch with a hard timeout (design §12: sync batch ≤ 30 s).
|
||||
function fetchWithTimeout(url, opts) {
|
||||
opts = opts || {};
|
||||
if (typeof window.AbortController === 'undefined') return fetch(url, opts);
|
||||
var ctrl = new window.AbortController();
|
||||
var timer = setTimeout(function () { ctrl.abort(); }, FETCH_TIMEOUT_MS);
|
||||
opts.signal = ctrl.signal;
|
||||
return fetch(url, opts).then(
|
||||
function (res) { clearTimeout(timer); return res; },
|
||||
function (err) { clearTimeout(timer); throw err; }
|
||||
);
|
||||
}
|
||||
|
||||
function getMeta(key) {
|
||||
return idbGet(STORES.META, key).then(function (row) { return row ? row.value : null; })
|
||||
.catch(function () { return null; });
|
||||
}
|
||||
function setMeta(key, value) {
|
||||
return idbPut(STORES.META, { key: key, value: value }).catch(function () {});
|
||||
}
|
||||
|
||||
// ── queue ─────────────────────────────────────────────────────────────
|
||||
function toMutation(item) {
|
||||
return {
|
||||
id: item.mutation_id,
|
||||
type: item.type,
|
||||
payload: item.payload || {},
|
||||
client_timestamp: item.timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
function enqueue(type, payload, opts) {
|
||||
opts = opts || {};
|
||||
var item = {
|
||||
mutation_id: opts.mutation_id || ('m-' + Date.now() + '-' + Math.random().toString(36).slice(2, 8)),
|
||||
type: type,
|
||||
payload: payload || {},
|
||||
timestamp: opts.timestamp || Date.now(),
|
||||
retries: 0,
|
||||
status: 'pending',
|
||||
};
|
||||
return idbAdd(STORES.QUEUE, item).then(function (id) {
|
||||
item.id = id;
|
||||
return refreshPending();
|
||||
}).then(function () {
|
||||
if (state.online) flush();
|
||||
return item;
|
||||
});
|
||||
}
|
||||
|
||||
function getQueue() {
|
||||
return idbAll(STORES.QUEUE).catch(function () { return []; });
|
||||
}
|
||||
|
||||
function pendingCount() {
|
||||
return getQueue().then(function (all) {
|
||||
return all.filter(function (i) { return i.status !== 'synced'; }).length;
|
||||
});
|
||||
}
|
||||
|
||||
function refreshPending() {
|
||||
return getQueue().then(function (all) {
|
||||
state.pendingSyncCount = all.filter(function (i) { return i.status !== 'synced'; }).length;
|
||||
emit();
|
||||
return state.pendingSyncCount;
|
||||
}).then(function (n) {
|
||||
markDirtySidebar();
|
||||
return n;
|
||||
});
|
||||
}
|
||||
|
||||
// Auto-purge of stale queue entries (design §12: 30-day retention).
|
||||
function purgeOld() {
|
||||
var cutoff = Date.now() - QUEUE_RETENTION_MS;
|
||||
return getQueue().then(function (all) {
|
||||
var stale = all.filter(function (i) {
|
||||
return i.status !== 'pending' && i.timestamp < cutoff;
|
||||
});
|
||||
return Promise.all(stale.map(function (i) { return idbDelete(STORES.QUEUE, i.id); }));
|
||||
}).catch(function () { return []; });
|
||||
}
|
||||
|
||||
// ── flush: replay queue → server ──────────────────────────────────────
|
||||
function flush() {
|
||||
if (state.isSyncing || !state.online) return Promise.resolve(false);
|
||||
return purgeOld().then(getQueue).then(function (all) {
|
||||
var pending = all.filter(function (i) { return i.status === 'pending' || i.status === 'failed'; });
|
||||
if (!pending.length) {
|
||||
state.pendingSyncCount = 0;
|
||||
emit();
|
||||
return false;
|
||||
}
|
||||
state.isSyncing = true;
|
||||
emit();
|
||||
var summary = { synced: 0, conflicts: 0, failed: 0, batches: 0 };
|
||||
var chain = Promise.resolve();
|
||||
for (var i = 0; i < pending.length; i += MAX_BATCH) {
|
||||
(function (chunk) {
|
||||
chain = chain.then(function () { return flushChunk(chunk, summary); });
|
||||
})(pending.slice(i, i + MAX_BATCH));
|
||||
}
|
||||
return chain.then(function () {
|
||||
state.lastSyncAt = Date.now();
|
||||
state.lastError = summary.failed ? (summary.failed + ' mutation(s) en échec') : null;
|
||||
return setMeta(LAST_SYNC_KEY, Date.now()).then(function () { return summary; });
|
||||
}).catch(function (e) {
|
||||
state.lastError = String((e && e.message) || e);
|
||||
return summary;
|
||||
}).then(function (result) {
|
||||
state.isSyncing = false;
|
||||
return refreshPending().then(function () {
|
||||
emit();
|
||||
notifySyncDone(result);
|
||||
return result;
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function flushChunk(chunk, summary) {
|
||||
summary.batches++;
|
||||
return fetchWithTimeout('/api/v2/sync/batch', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ device_id: deviceId(), mutations: chunk.map(toMutation) }),
|
||||
}).then(function (res) {
|
||||
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||||
return res.json();
|
||||
}).then(function (data) {
|
||||
var byId = {};
|
||||
(data.results || []).forEach(function (r) { byId[r.mutation_id] = r; });
|
||||
var work = Promise.resolve();
|
||||
chunk.forEach(function (item) {
|
||||
var r = byId[item.mutation_id];
|
||||
if (!r) return;
|
||||
if (r.status === 'synced') {
|
||||
summary.synced++;
|
||||
work = work.then(function () { return idbDelete(STORES.QUEUE, item.id); });
|
||||
} else if (r.status === 'conflict') {
|
||||
summary.conflicts++;
|
||||
work = work.then(function () { return idbDelete(STORES.QUEUE, item.id); });
|
||||
} else {
|
||||
summary.failed++;
|
||||
item.retries = (item.retries || 0) + 1;
|
||||
item.status = 'failed';
|
||||
item.error = r.error || '';
|
||||
work = work.then(function () { return idbPut(STORES.QUEUE, item); });
|
||||
}
|
||||
});
|
||||
return work;
|
||||
});
|
||||
}
|
||||
|
||||
function notifySyncDone(summary) {
|
||||
if (!summary || (!summary.synced && !summary.conflicts && !summary.failed)) return;
|
||||
try {
|
||||
window.dispatchEvent(new CustomEvent('flowdeck:sync-done', { detail: summary }));
|
||||
} catch { /* CustomEvent unavailable */ }
|
||||
}
|
||||
|
||||
// ── delta: pull server changes ────────────────────────────────────────
|
||||
function delta(workspaceId) {
|
||||
return getMeta(LAST_SYNC_KEY).then(function (since) {
|
||||
var url = '/api/v2/sync/delta?since=' + encodeURIComponent(since || 0) +
|
||||
'&workspace_id=' + encodeURIComponent(workspaceId);
|
||||
return fetchWithTimeout(url, { credentials: 'same-origin' });
|
||||
}).then(function (res) {
|
||||
if (!res.ok) throw new Error('delta HTTP ' + res.status);
|
||||
return res.json();
|
||||
}).then(function (data) {
|
||||
var work = Promise.resolve();
|
||||
(data.changes || []).forEach(function (ch) {
|
||||
var d = ch.data || {};
|
||||
if (d.id == null) return;
|
||||
var ctype = ch.change_type || '';
|
||||
if (ctype.indexOf('page') === 0) {
|
||||
work = work.then(function () {
|
||||
return idbGet(STORES.PAGES, d.id).then(function (local) {
|
||||
if (local && local._dirty) return null; // keep unsynced local edits
|
||||
return idbPut(STORES.PAGES, {
|
||||
id: d.id,
|
||||
workspace_id: d.workspace_id,
|
||||
title: d.title,
|
||||
content: d.content,
|
||||
content_format: d.content_format,
|
||||
parent_id: d.parent_id,
|
||||
deleted_at: d.deleted_at || null,
|
||||
sync_version: d.sync_version || 1,
|
||||
_server_version: d.sync_version || 1,
|
||||
_modified_at: Date.now(),
|
||||
_dirty: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
} else if (ctype.indexOf('collection') === 0) {
|
||||
work = work.then(function () {
|
||||
return idbPut(STORES.COLLECTIONS, {
|
||||
id: d.id,
|
||||
workspace_id: d.workspace_id,
|
||||
name: d.name,
|
||||
schema_json: d.schema_json,
|
||||
_modified_at: Date.now(),
|
||||
_dirty: false,
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
return work.then(function () {
|
||||
return setMeta(LAST_SYNC_KEY, data.server_time || Date.now());
|
||||
}).then(function () { return data; });
|
||||
});
|
||||
}
|
||||
|
||||
// ── optimistic page save ──────────────────────────────────────────────
|
||||
function savePageOffline(page) {
|
||||
if (!page || page.id == null) return Promise.resolve(null);
|
||||
return idbGet(STORES.PAGES, page.id).then(function (local) {
|
||||
local = local || {};
|
||||
var baseVersion = (local._server_version != null) ? local._server_version : page.base_version;
|
||||
var merged = {
|
||||
id: page.id,
|
||||
workspace_id: (page.workspace_id != null) ? page.workspace_id : local.workspace_id,
|
||||
title: (page.title != null) ? page.title : local.title,
|
||||
content: (page.content != null) ? page.content : local.content,
|
||||
content_format: page.content_format || local.content_format || 'blocks',
|
||||
parent_id: (page.parent_id != null) ? page.parent_id : local.parent_id,
|
||||
_server_version: local._server_version || 1,
|
||||
_modified_at: Date.now(),
|
||||
_dirty: true,
|
||||
};
|
||||
return idbPut(STORES.PAGES, merged).then(function () {
|
||||
var payload = { page_id: page.id };
|
||||
if (page.title != null) payload.title = page.title;
|
||||
if (page.content != null) payload.content = page.content;
|
||||
if (page.content_format != null) payload.content_format = page.content_format;
|
||||
if (baseVersion != null) payload.base_version = baseVersion;
|
||||
return enqueue('page_update', payload).then(function () {
|
||||
markDirtySidebar();
|
||||
return merged;
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function status(workspaceId) {
|
||||
var url = '/api/v2/sync/status' + (workspaceId != null ? '?workspace_id=' + encodeURIComponent(workspaceId) : '');
|
||||
return fetchWithTimeout(url, { credentials: 'same-origin' }).then(function (res) {
|
||||
if (!res.ok) throw new Error('status HTTP ' + res.status);
|
||||
return res.json();
|
||||
});
|
||||
}
|
||||
|
||||
// ── dirty-page UI marker (⟳ in the sidebar) ───────────────────────────
|
||||
function getDirtyPageIds() {
|
||||
return idbAll(STORES.PAGES).then(function (pages) {
|
||||
return pages.filter(function (p) { return p._dirty; }).map(function (p) { return String(p.id); });
|
||||
}).catch(function () { return []; });
|
||||
}
|
||||
|
||||
function markDirtySidebar() {
|
||||
if (typeof document === 'undefined') return Promise.resolve([]);
|
||||
return getDirtyPageIds().then(function (ids) {
|
||||
var set = {};
|
||||
ids.forEach(function (id) { set[id] = true; });
|
||||
document.querySelectorAll('.sidebar-item[data-id]').forEach(function (el) {
|
||||
var id = el.getAttribute('data-id');
|
||||
if (set[id]) el.classList.add('fd-dirty'); else el.classList.remove('fd-dirty');
|
||||
});
|
||||
document.querySelectorAll('.sidebar-item[data-page-id]').forEach(function (el) {
|
||||
var pid = (el.getAttribute('data-page-id') || '').replace('page/', '');
|
||||
if (set[pid]) el.classList.add('fd-dirty'); else el.classList.remove('fd-dirty');
|
||||
});
|
||||
return ids;
|
||||
});
|
||||
}
|
||||
|
||||
// ── background sync + wiring ──────────────────────────────────────────
|
||||
function registerBackgroundSync() {
|
||||
if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) return;
|
||||
navigator.serviceWorker.ready.then(function (reg) {
|
||||
if ('sync' in reg && reg.sync) {
|
||||
reg.sync.register('sync-flowdeck').catch(function () {});
|
||||
}
|
||||
}).catch(function () {});
|
||||
}
|
||||
|
||||
function init() {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
window.addEventListener('online', function () {
|
||||
state.online = true;
|
||||
emit();
|
||||
flush();
|
||||
});
|
||||
window.addEventListener('offline', function () {
|
||||
state.online = false;
|
||||
emit();
|
||||
});
|
||||
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.addEventListener('message', function (ev) {
|
||||
if (ev.data && ev.data.type === 'flowdeck-sync') flush();
|
||||
});
|
||||
}
|
||||
|
||||
refreshPending();
|
||||
registerBackgroundSync();
|
||||
setInterval(function () {
|
||||
if (state.online) refreshPending();
|
||||
}, 30000);
|
||||
}
|
||||
|
||||
window.FlowOffline = {
|
||||
STORES: STORES,
|
||||
openDB: openDB,
|
||||
deviceId: deviceId,
|
||||
onChange: onChange,
|
||||
getState: getState,
|
||||
enqueue: enqueue,
|
||||
getQueue: getQueue,
|
||||
pendingCount: pendingCount,
|
||||
flush: flush,
|
||||
delta: delta,
|
||||
status: status,
|
||||
savePageOffline: savePageOffline,
|
||||
getDirtyPageIds: getDirtyPageIds,
|
||||
markDirtySidebar: markDirtySidebar,
|
||||
purgeOld: purgeOld,
|
||||
registerBackgroundSync: registerBackgroundSync,
|
||||
init: init,
|
||||
MAX_BATCH: MAX_BATCH,
|
||||
};
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "FlowDeck",
|
||||
"short_name": "FlowDeck",
|
||||
"description": "Clone Notion intégré à Gitea/GitHub",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#191919",
|
||||
"theme_color": "#191919",
|
||||
"orientation": "any",
|
||||
"lang": "fr",
|
||||
"dir": "ltr",
|
||||
"categories": ["productivity", "business"],
|
||||
"prefer_related_applications": false,
|
||||
"icons": [
|
||||
{ "src": "/static/icons/icon-72x72.png", "sizes": "72x72", "type": "image/png" },
|
||||
{ "src": "/static/icons/icon-96x96.png", "sizes": "96x96", "type": "image/png" },
|
||||
{ "src": "/static/icons/icon-128x128.png", "sizes": "128x128", "type": "image/png" },
|
||||
{ "src": "/static/icons/icon-144x144.png", "sizes": "144x144", "type": "image/png" },
|
||||
{ "src": "/static/icons/icon-152x152.png", "sizes": "152x152", "type": "image/png" },
|
||||
{ "src": "/static/icons/icon-192x192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" },
|
||||
{ "src": "/static/icons/icon-384x384.png", "sizes": "384x384", "type": "image/png" },
|
||||
{ "src": "/static/icons/icon-512x512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
/* ═══════════════════════════════════════════════════════════
|
||||
FlowDeck Service Worker — v6.0.0 PWA
|
||||
Stratégies :
|
||||
- Shell (CSS/JS/fonts/icons) → cache-first
|
||||
- Pages HTML → network-first, fallback cache
|
||||
- API GET → network-first, fallback cache
|
||||
- API mutations (POST/...) → network-only (queue côté client)
|
||||
═══════════════════════════════════════════════════════════ */
|
||||
'use strict';
|
||||
|
||||
const CACHE_NAME = 'flowdeck-v6';
|
||||
const DATA_CACHE = 'flowdeck-data-v6';
|
||||
|
||||
// App shell (assets versionnés comme référencés dans les templates).
|
||||
const PRECACHE_URLS = [
|
||||
// Pages
|
||||
'/', '/workspaces', '/library', '/settings',
|
||||
// CSS
|
||||
'/static/css/app.css?v=5.1.1',
|
||||
'/static/css/design-tokens.css?v=5.2.0',
|
||||
'/static/css/components.css?v=5.2.0',
|
||||
'/static/css/katex.min.css?v=0.16.11',
|
||||
'/static/css/prism.css',
|
||||
// JS
|
||||
'/static/js/htmx.min.js',
|
||||
'/static/js/alpine.min.js',
|
||||
'/static/js/sortable.min.js',
|
||||
'/static/js/app.js?v=2.4.8',
|
||||
'/static/js/offline.js?v=6.0.0',
|
||||
'/static/js/katex.min.js?v=0.16.11',
|
||||
'/static/js/prism.min.js',
|
||||
// Icônes & métadonnées
|
||||
'/static/favicon.svg',
|
||||
'/static/manifest.json',
|
||||
'/static/icons/icon-192x192.png',
|
||||
'/static/icons/icon-512x512.png',
|
||||
];
|
||||
|
||||
const SHELL_ASSET = /\.(css|js|png|jpg|jpeg|svg|gif|webp|ico|woff2?|ttf|eot|json)(\?.*)?$/;
|
||||
|
||||
const OFFLINE_PAGE = [
|
||||
'<!DOCTYPE html>',
|
||||
'<html lang="fr">',
|
||||
'<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">',
|
||||
'<title>FlowDeck — Hors ligne</title>',
|
||||
'<style>',
|
||||
'body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;background:#191919;color:#fff;',
|
||||
'display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0;text-align:center;}',
|
||||
'.box{padding:32px;background:#262626;border-radius:8px;max-width:340px;}',
|
||||
'h1{font-size:20px;margin:0 0 8px;}',
|
||||
'p{color:#b8b8b8;font-size:14px;margin:0;line-height:1.5;}',
|
||||
'.sync{margin-top:12px;font-size:12px;color:#2383E2;animation:blink 1.4s infinite;}',
|
||||
'@keyframes blink{50%{opacity:.35}}',
|
||||
'</style>',
|
||||
'</head>',
|
||||
'<body>',
|
||||
'<div class="box"><h1>Vous êtes hors ligne</h1>',
|
||||
'<p>Les pages consultées récemment restent disponibles. Votre contenu sera synchronisé dès le retour du réseau.</p>',
|
||||
'<div class="sync">⟳ Synchronisation en attente…</div>',
|
||||
'</div>',
|
||||
'</body></html>',
|
||||
].join('\n');
|
||||
|
||||
// ── Install : precache du shell (ignore redirections /login) ──
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME)
|
||||
.then(async (cache) => {
|
||||
await Promise.all(
|
||||
PRECACHE_URLS.map(async (url) => {
|
||||
try {
|
||||
const res = await fetch(url, { redirect: 'manual', credentials: 'same-origin' });
|
||||
// status 0 = opaqueredirect (vers /login par ex.) → on ne cache pas
|
||||
if (res.status === 0 || !res.ok) return;
|
||||
await cache.put(url, res);
|
||||
} catch {
|
||||
// réseau indisponible pendant install : on continue
|
||||
}
|
||||
})
|
||||
);
|
||||
// Page de secours offline
|
||||
await cache.put('/offline.html', new Response(OFFLINE_PAGE, {
|
||||
headers: { 'Content-Type': 'text/html; charset=utf-8' },
|
||||
}));
|
||||
})
|
||||
.then(() => self.skipWaiting())
|
||||
);
|
||||
});
|
||||
|
||||
// ── Activate : purge des anciens caches ──
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(
|
||||
caches.keys()
|
||||
.then((keys) => Promise.all(
|
||||
keys.filter((k) => k !== CACHE_NAME && k !== DATA_CACHE).map((k) => caches.delete(k))
|
||||
))
|
||||
.then(() => self.clients.claim())
|
||||
);
|
||||
});
|
||||
|
||||
// ── Fetch : routing par stratégie ──
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const request = event.request;
|
||||
|
||||
if (request.method !== 'GET') {
|
||||
// Mutations : always network (queue côté client en mode offline)
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
if (url.origin !== self.location.origin) {
|
||||
return; // cross-origin (fonts Google, CDN…) non intercepté
|
||||
}
|
||||
|
||||
const isNavigation = request.mode === 'navigate';
|
||||
const isApi = url.pathname.startsWith('/api/') ||
|
||||
url.pathname.startsWith('/db/') ||
|
||||
url.pathname.startsWith('/board/') ||
|
||||
url.pathname.startsWith('/p/');
|
||||
|
||||
// HTML / navigation : network-first, fallback cache, offline page
|
||||
if (isNavigation || request.headers.get('accept')?.includes('text/html')) {
|
||||
event.respondWith(networkFirst(request, { fallbackToShell: true }));
|
||||
return;
|
||||
}
|
||||
|
||||
// API GET : réseau d'abord, cache en repli
|
||||
if (isApi) {
|
||||
event.respondWith(networkFirst(request, { cacheName: DATA_CACHE }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Assets statiques : cache-first avec mise en cache à la volée
|
||||
if (SHELL_ASSET.test(url.pathname) || url.pathname === '/manifest.json') {
|
||||
event.respondWith(cacheFirst(request, { cacheName: CACHE_NAME }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Tout le reste : network-first
|
||||
event.respondWith(networkFirst(request));
|
||||
});
|
||||
|
||||
async function cacheFirst(request, { cacheName = CACHE_NAME } = {}) {
|
||||
const cache = await caches.open(cacheName);
|
||||
const cached = await cache.match(request, { ignoreSearch: false });
|
||||
if (cached) return cached;
|
||||
try {
|
||||
const res = await fetch(request);
|
||||
if (res && res.ok) cache.put(request, res.clone());
|
||||
return res;
|
||||
} catch {
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
|
||||
async function networkFirst(request, { cacheName = CACHE_NAME, fallbackToShell = false, timeoutMs = 4000 } = {}) {
|
||||
const cache = await caches.open(cacheName);
|
||||
try {
|
||||
const res = await timeoutFetch(request, timeoutMs);
|
||||
if (res && res.ok) {
|
||||
cache.put(request, res.clone());
|
||||
return res;
|
||||
}
|
||||
throw new Error('bad status');
|
||||
} catch {
|
||||
const cached = await cache.match(request, { ignoreSearch: false });
|
||||
if (cached) return cached;
|
||||
if (fallbackToShell) {
|
||||
const shell = await cache.match('/');
|
||||
if (shell) return shell;
|
||||
return caches.match('/offline.html');
|
||||
}
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
|
||||
function timeoutFetch(request, ms) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
reject(new Error('timeout'));
|
||||
}, ms);
|
||||
fetch(request).then(
|
||||
(res) => { clearTimeout(timer); resolve(res); },
|
||||
(err) => { clearTimeout(timer); reject(err); }
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Background Sync : notifier les clients pour rejouer la queue ──
|
||||
self.addEventListener('sync', (event) => {
|
||||
if (event.tag === 'sync-flowdeck') {
|
||||
event.waitUntil(
|
||||
self.clients.matchAll({ type: 'window' }).then((clients) => {
|
||||
clients.forEach((client) => client.postMessage({ type: 'flowdeck-sync' }));
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Messages : mise à jour immédiate ──
|
||||
self.addEventListener('message', (event) => {
|
||||
if (event.data && event.data.type === 'SKIP_WAITING') {
|
||||
self.skipWaiting();
|
||||
}
|
||||
});
|
||||