Skip to content

Commit 491e3e0

Browse files
Add offline mode (PWA): queue uploads offline, auto-sync on reconnect
- Service worker now caches the app shell + Next static assets so the app BOOTS offline after one online visit (still never caches API responses or file contents). - Auth keeps the last-known user when offline (cached) so the app loads instead of bouncing to /login; CSRF is restored on reconnect. - OfflineProvider tracks connectivity and an IndexedDB upload queue; on reconnect it refreshes auth, resolves the Fast-Upload folder, uploads each queued file (to its chosen folder) and clears the local copies. Failures stay queued and retry. - Offline, the Drive swaps to a minimal 'send files' view (destination picker from a cached folder list, drag/drop, queue list); a thin offline banner shows app-wide. - Docs updated. FR/EN parity (478).
1 parent 49773dc commit 491e3e0

11 files changed

Lines changed: 420 additions & 15 deletions

File tree

‎apps/web/public/sw.js‎

Lines changed: 41 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,70 @@
11
/*
2-
* OpenCoperLock service worker — deliberately minimal.
2+
* OpenCoperLock service worker — installable PWA + offline app shell.
33
*
4-
* It exists to make the app installable and to serve an offline fallback for page
5-
* navigations. It NEVER caches API responses or file contents: those are private and
6-
* potentially large, and stale copies would be both confusing and a data-leak risk. Only
7-
* same-origin GET navigations get a cached app-shell fallback when the network is down.
4+
* It NEVER caches API responses or file contents (private + potentially large; stale copies
5+
* would leak or mislead). It caches only the app shell and Next.js static assets so the app can
6+
* BOOT offline after it has been opened online at least once. Offline file uploads are queued in
7+
* IndexedDB by the app itself and flushed on reconnect — not handled here.
88
*/
9-
const CACHE = 'ocl-shell-v1';
10-
const SHELL = '/';
9+
const SHELL = 'ocl-shell-v2';
10+
const STATIC = 'ocl-static-v2';
1111

1212
self.addEventListener('install', (event) => {
13-
event.waitUntil(caches.open(CACHE).then((c) => c.add(SHELL)).catch(() => {}));
13+
event.waitUntil(caches.open(SHELL).then((c) => c.add('/')).catch(() => {}));
1414
self.skipWaiting();
1515
});
1616

1717
self.addEventListener('activate', (event) => {
1818
event.waitUntil(
19-
caches.keys().then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))),
19+
caches.keys().then((keys) => Promise.all(keys.filter((k) => k !== SHELL && k !== STATIC).map((k) => caches.delete(k)))),
2020
);
2121
self.clients.claim();
2222
});
2323

2424
self.addEventListener('fetch', (event) => {
2525
const req = event.request;
2626
if (req.method !== 'GET') return;
27-
2827
const url = new URL(req.url);
2928
if (url.origin !== self.location.origin) return; // never touch the cross-origin API
3029

31-
// App-shell strategy for page navigations only.
30+
const isStatic =
31+
url.pathname.startsWith('/_next/static/') ||
32+
url.pathname === '/icon.svg' ||
33+
url.pathname === '/manifest.webmanifest';
34+
35+
// Content-hashed static assets: cache-first (safe forever), so the shell boots offline.
36+
if (isStatic) {
37+
event.respondWith(
38+
caches.match(req).then(
39+
(cached) =>
40+
cached ||
41+
fetch(req)
42+
.then((res) => {
43+
const copy = res.clone();
44+
caches.open(STATIC).then((c) => c.put(req, copy)).catch(() => {});
45+
return res;
46+
})
47+
.catch(() => cached),
48+
),
49+
);
50+
return;
51+
}
52+
53+
// Page navigations: network-first, falling back to the cached page or the app shell.
3254
if (req.mode === 'navigate') {
3355
event.respondWith(
3456
fetch(req)
3557
.then((res) => {
36-
caches.open(CACHE).then((c) => c.put(SHELL, res.clone())).catch(() => {});
58+
const copy = res.clone();
59+
caches.open(SHELL).then((c) => c.put(req, copy)).catch(() => {});
3760
return res;
3861
})
39-
.catch(() => caches.match(SHELL).then((r) => r || Response.error())),
62+
.catch(() =>
63+
caches
64+
.match(req)
65+
.then((r) => r || caches.match('/'))
66+
.then((r) => r || Response.error()),
67+
),
4068
);
4169
}
4270
});

‎apps/web/src/app/(app)/drive/page.tsx‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@ import type { PublicFile, PublicFolder } from '@opencoperlock/shared/client';
5555
import { formatBytes } from '@opencoperlock/shared/client';
5656
import { api, API_URL, ApiError } from '@/lib/api';
5757
import { useAuth } from '@/lib/auth';
58+
import { useOffline } from '@/lib/offline';
59+
import { OfflineView } from '@/components/OfflineView';
5860
import { useT } from '@/lib/i18n';
5961
import {
6062
checkVerifier,
@@ -115,6 +117,7 @@ function passKey(spaceId: string) {
115117

116118
export default function EspacesPage() {
117119
const { refresh } = useAuth();
120+
const { online } = useOffline();
118121
const { t } = useT();
119122
const [allFolders, setAllFolders] = useState<PublicFolder[]>([]);
120123
const [activeSpaceId, setActiveSpaceId] = useState<string | null>(null);
@@ -239,6 +242,12 @@ export default function EspacesPage() {
239242
const loadFolders = useCallback(async () => {
240243
const res = await api.get<{ folders: PublicFolder[] }>('/folders');
241244
setAllFolders(res.folders);
245+
// Cache the folder list so the offline view can offer a destination picker.
246+
try {
247+
localStorage.setItem('ocl_folders', JSON.stringify(res.folders));
248+
} catch {
249+
/* storage unavailable */
250+
}
242251
return res.folders;
243252
}, []);
244253

@@ -1296,6 +1305,11 @@ export default function EspacesPage() {
12961305

12971306
// ── Render ─────────────────────────────────────────────────────────────────
12981307

1308+
// Offline: swap the whole Drive for the minimal "queue an upload" view.
1309+
if (!online) {
1310+
return <OfflineView />;
1311+
}
1312+
12991313
if (!activeSpaceId) {
13001314
return (
13011315
<div className="space-y-6">

‎apps/web/src/app/(app)/layout.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { useT } from '@/lib/i18n';
99
import { Wordmark } from '@/components/Wordmark';
1010
import { LanguageSwitcher } from '@/components/LanguageSwitcher';
1111
import { StatusBanner } from '@/components/StatusBanner';
12+
import { OfflineBanner } from '@/components/OfflineBanner';
1213
import { formatBytes } from '@opencoperlock/shared/client';
1314

1415
const NAV = [
@@ -166,6 +167,7 @@ export default function AppLayout({ children }: { children: React.ReactNode }) {
166167

167168
{/* Main */}
168169
<div className="md:ml-64">
170+
<OfflineBanner />
169171
<StatusBanner />
170172
<main className="mx-auto max-w-5xl px-4 py-6 sm:px-6 md:px-8 md:py-8">{children}</main>
171173
</div>

‎apps/web/src/app/docs/page.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -383,6 +383,7 @@ curl -H "Authorization: Bearer ocl_…" -F "file=@backup.tgz" \\
383383
ul: [
384384
L('Interface adaptée au mobile ; installable comme application (PWA).', 'Mobile-friendly UI; installable as an app (PWA).'),
385385
L('Appui long pour sélectionner, glisser-déposer, et toutes les actions du menu.', 'Long-press to select, drag-and-drop, and all menu actions.'),
386+
L('Mode hors-ligne : l’app s’ouvre sans réseau et propose d’envoyer des fichiers ; ils sont gardés sur l’appareil et synchronisés automatiquement au retour de la connexion.', 'Offline mode: the app opens with no network and lets you queue file uploads; they’re kept on the device and synced automatically when the connection returns.'),
386387
L('Le Quick-Upload est parfait pour envoyer depuis un téléphone sans se connecter.', 'Quick-Upload is perfect for sending from a phone without signing in.'),
387388
],
388389
},

‎apps/web/src/app/layout.tsx‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { Metadata, Viewport } from 'next';
22
import './globals.css';
33
import { AuthProvider } from '@/lib/auth';
4+
import { OfflineProvider } from '@/lib/offline';
45
import { I18nProvider } from '@/lib/i18n';
56
import { Overlays } from '@/components/ui/overlays';
67
import { PwaRegister } from '@/components/PwaRegister';
@@ -53,7 +54,9 @@ export default function RootLayout({ children }: { children: React.ReactNode })
5354
<html lang="fr" className="dark">
5455
<body className="min-h-screen font-sans">
5556
<I18nProvider>
56-
<AuthProvider>{children}</AuthProvider>
57+
<AuthProvider>
58+
<OfflineProvider>{children}</OfflineProvider>
59+
</AuthProvider>
5760
<Overlays />
5861
</I18nProvider>
5962
<PwaRegister />
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
'use client';
2+
3+
import { CloudOff } from 'lucide-react';
4+
import { useOffline } from '@/lib/offline';
5+
import { useT } from '@/lib/i18n';
6+
7+
/** Thin persistent banner shown across the app whenever the device is offline. */
8+
export function OfflineBanner() {
9+
const { online, items } = useOffline();
10+
const { t } = useT();
11+
if (online) return null;
12+
return (
13+
<div className="flex items-center justify-center gap-2 bg-amber-500/15 px-4 py-1.5 text-center text-xs font-medium text-amber-200">
14+
<CloudOff size={14} />
15+
{t('offline.banner')}
16+
{items.length > 0 && <span className="text-amber-300/80">· {t('offline.queued', { n: items.length })}</span>}
17+
</div>
18+
);
19+
}
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
'use client';
2+
3+
/**
4+
* Minimal offline screen shown on the Drive when there's no network. You can still pick files —
5+
* they're stored locally (IndexedDB) and uploaded automatically when the connection returns.
6+
*/
7+
import { useMemo, useRef, useState } from 'react';
8+
import { CloudOff, UploadCloud, X, Loader2 } from 'lucide-react';
9+
import type { PublicFolder } from '@opencoperlock/shared/client';
10+
import { FAST_UPLOAD_FOLDER_NAME, formatBytes } from '@opencoperlock/shared/client';
11+
import { useT } from '@/lib/i18n';
12+
import { useOffline } from '@/lib/offline';
13+
import { Select } from '@/components/ui/Select';
14+
15+
function cachedFolders(): PublicFolder[] {
16+
try {
17+
const raw = localStorage.getItem('ocl_folders');
18+
return raw ? (JSON.parse(raw) as PublicFolder[]) : [];
19+
} catch {
20+
return [];
21+
}
22+
}
23+
24+
export function OfflineView() {
25+
const { t } = useT();
26+
const { items, enqueueFiles, removeItem } = useOffline();
27+
const fileInput = useRef<HTMLInputElement>(null);
28+
const [dragging, setDragging] = useState(false);
29+
30+
const folders = useMemo(() => cachedFolders().filter((f) => !f.isZeroKnowledge), []);
31+
const options = [
32+
{ value: '', label: FAST_UPLOAD_FOLDER_NAME },
33+
...folders.map((f) => ({ value: f.id, label: f.name })),
34+
];
35+
const defaultId = folders.find((f) => f.name === FAST_UPLOAD_FOLDER_NAME)?.id ?? '';
36+
const [folderId, setFolderId] = useState(defaultId);
37+
38+
async function add(list: FileList | File[] | null) {
39+
if (!list) return;
40+
const name = options.find((o) => o.value === folderId)?.label ?? FAST_UPLOAD_FOLDER_NAME;
41+
await enqueueFiles(list, folderId, name);
42+
}
43+
44+
return (
45+
<div className="mx-auto max-w-xl space-y-5 py-6">
46+
<div className="flex items-center gap-3">
47+
<span className="grid h-11 w-11 place-items-center rounded-xl bg-amber-500/10 text-amber-300">
48+
<CloudOff size={22} />
49+
</span>
50+
<div>
51+
<h1 className="text-xl font-semibold text-white">{t('offline.title')}</h1>
52+
<p className="text-sm text-zinc-500">{t('offline.subtitle')}</p>
53+
</div>
54+
</div>
55+
56+
<div className="card space-y-3">
57+
<label className="block text-sm text-zinc-400">{t('offline.destination')}</label>
58+
<Select className="w-full" value={folderId} onChange={setFolderId} options={options} />
59+
60+
<div
61+
onDragOver={(e) => {
62+
e.preventDefault();
63+
setDragging(true);
64+
}}
65+
onDragLeave={() => setDragging(false)}
66+
onDrop={(e) => {
67+
e.preventDefault();
68+
setDragging(false);
69+
void add(e.dataTransfer.files);
70+
}}
71+
onClick={() => fileInput.current?.click()}
72+
className={`grid cursor-pointer place-items-center gap-2 rounded-xl border-2 border-dashed px-6 py-10 text-center transition ${
73+
dragging ? 'border-accent/70 bg-accent/[0.06]' : 'border-white/15 hover:border-white/25'
74+
}`}
75+
>
76+
<UploadCloud size={28} className="text-zinc-400" />
77+
<p className="text-sm font-medium text-zinc-200">{t('offline.pick')}</p>
78+
<p className="text-xs text-zinc-500">{t('offline.pickHint')}</p>
79+
</div>
80+
<input ref={fileInput} type="file" multiple className="hidden" onChange={(e) => void add(e.target.files)} />
81+
</div>
82+
83+
{items.length > 0 && (
84+
<div className="card space-y-2">
85+
<p className="text-sm font-medium text-zinc-200">{t('offline.queued', { n: items.length })}</p>
86+
<div className="divide-y divide-white/[0.06]">
87+
{items.map((it) => (
88+
<div key={it.id} className="flex items-center justify-between gap-2 py-2 text-sm">
89+
<div className="flex min-w-0 items-center gap-2">
90+
<Loader2 size={14} className="shrink-0 animate-spin text-amber-300" />
91+
<span className="truncate text-zinc-200">{it.name}</span>
92+
<span className="shrink-0 text-xs text-zinc-500">{formatBytes(it.blob.size)} · {it.folderName}</span>
93+
</div>
94+
<button
95+
className="rounded-lg p-1 text-zinc-500 hover:bg-white/5 hover:text-zinc-200"
96+
onClick={() => it.id != null && void removeItem(it.id)}
97+
title={t('offline.remove')}
98+
>
99+
<X size={15} />
100+
</button>
101+
</div>
102+
))}
103+
</div>
104+
<p className="text-xs text-zinc-500">{t('offline.willSync')}</p>
105+
</div>
106+
)}
107+
</div>
108+
);
109+
}

‎apps/web/src/lib/auth.tsx‎

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,23 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
2626
try {
2727
const res = await me();
2828
setUser(res.user);
29+
try {
30+
localStorage.setItem('ocl_user', JSON.stringify(res.user));
31+
} catch {
32+
/* storage unavailable */
33+
}
2934
} catch {
30-
setUser(null);
35+
// Offline: keep the last-known user so the app still loads (read-only / queue uploads).
36+
let cached: PublicUser | null = null;
37+
if (typeof navigator !== 'undefined' && !navigator.onLine) {
38+
try {
39+
const raw = localStorage.getItem('ocl_user');
40+
if (raw) cached = JSON.parse(raw) as PublicUser;
41+
} catch {
42+
/* ignore */
43+
}
44+
}
45+
setUser(cached);
3146
} finally {
3247
setLoading(false);
3348
}
@@ -40,11 +55,21 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
4055
const login = useCallback(async (email: string, password: string, totp?: string) => {
4156
const res = await apiLogin(email, password, totp);
4257
setUser(res.user);
58+
try {
59+
localStorage.setItem('ocl_user', JSON.stringify(res.user));
60+
} catch {
61+
/* ignore */
62+
}
4363
}, []);
4464

4565
const logout = useCallback(async () => {
4666
await apiLogout();
4767
setUser(null);
68+
try {
69+
localStorage.removeItem('ocl_user');
70+
} catch {
71+
/* ignore */
72+
}
4873
}, []);
4974

5075
return (

‎apps/web/src/lib/i18n.tsx‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,17 @@ const fr: Dict = {
4646
'error.home': 'Accueil',
4747
// redirect
4848
'redirect.loading': 'Redirection…',
49+
// offline / PWA
50+
'offline.banner': 'Hors ligne — vos envois seront synchronisés au retour du réseau.',
51+
'offline.title': 'Vous êtes hors ligne',
52+
'offline.subtitle': 'Sélectionnez des fichiers : ils seront envoyés automatiquement à la reconnexion.',
53+
'offline.destination': 'Destination',
54+
'offline.pick': 'Choisir des fichiers',
55+
'offline.pickHint': 'ou glissez-les ici — ils sont gardés sur cet appareil en attendant.',
56+
'offline.queued': '{n} fichier(s) en attente',
57+
'offline.remove': 'Retirer',
58+
'offline.willSync': 'Ces fichiers seront envoyés et effacés de cet appareil dès le retour du réseau.',
59+
'offline.synced': '{n} fichier(s) synchronisé(s)',
4960
// file viewer / editor
5061
'viewer.preview': 'Aperçu',
5162
'viewer.source': 'Source',
@@ -555,6 +566,17 @@ const en: Dict = {
555566
'error.retry': 'Try again',
556567
'error.home': 'Home',
557568
'redirect.loading': 'Redirecting…',
569+
// offline / PWA
570+
'offline.banner': 'Offline — your uploads will sync when the network returns.',
571+
'offline.title': 'You are offline',
572+
'offline.subtitle': 'Pick files: they’ll be sent automatically once you reconnect.',
573+
'offline.destination': 'Destination',
574+
'offline.pick': 'Choose files',
575+
'offline.pickHint': 'or drop them here — they’re kept on this device meanwhile.',
576+
'offline.queued': '{n} file(s) waiting',
577+
'offline.remove': 'Remove',
578+
'offline.willSync': 'These files will be uploaded and removed from this device as soon as the network returns.',
579+
'offline.synced': '{n} file(s) synced',
558580
'viewer.preview': 'Preview',
559581
'viewer.source': 'Source',
560582
'viewer.runJs': 'JS',

0 commit comments

Comments
 (0)