Add offline editing: local persistence, PWA shell, offline resolution (#38)
Some checks failed
CD / Build and push images (push) Successful in 2m59s
CI / Lint, typecheck, test (push) Successful in 2m3s
CI / Auth e2e pack (push) Failing after 2m18s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m32s
CD / Promote to Int (push) Successful in 12s
Some checks failed
CD / Build and push images (push) Successful in 2m59s
CI / Lint, typecheck, test (push) Successful in 2m3s
CI / Auth e2e pack (push) Failing after 2m18s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m32s
CD / Promote to Int (push) Successful in 12s
Editing continues without a connection and merges conflict-free on reconnect (ADR 0003, realtime-collaboration.md §Offline). - y-indexeddb mirrors every opened page's Y.Doc to IndexedDB, sharing the document with the collab provider. The local copy is discarded when the page is left after a successful server sync (bounding IndexedDB growth) and kept otherwise so offline edits survive to the next visit. - vite-plugin-pwa service worker precaches the app shell (build assets only) with a navigation fallback; `/api` and `/collab` are denylisted and there is no runtime caching, so API responses are never cached or poisoned. - Offline page resolution WITHOUT caching API responses: the app itself persists the small metadata it needs to reopen a visited page (page/pond ids + slugs, bounded LRU in localStorage) and the last signed-in user, so after an offline tab reload the app stays signed in, resolves the page, and restores its content from IndexedDB. Both are revalidated when the network returns (a 401 clears the cached user). - Local-only UI: a banner when there are edits held only on this device (provider `onUnsyncedChanges`), de + en. Tests: `page-cache` unit test (remember/recall + bounded eviction); a new `offline` e2e pack (validated locally against the full stack and wired into CI): edit, reload while offline (shell from the SW, content from IndexedDB), assert an API call fails offline (no SW API caching), then reconnect and a second client converges. The e2e static server serves `.webmanifest`. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PGdhRiwU1WRL4XxJfZYipY
This commit is contained in:
parent
63fe6af6b0
commit
af81b50fa6
@ -154,6 +154,16 @@ jobs:
|
||||
E2E_BASE_URL=http://localhost:5173 \
|
||||
pnpm --filter @dorfteich/web exec playwright test e2e/collab.spec.ts
|
||||
|
||||
- name: Reset login rate limit before offline pack
|
||||
run: |
|
||||
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \
|
||||
pnpm --filter @dorfteich/api exec prisma db execute --stdin --url "$DATABASE_URL"
|
||||
|
||||
- name: Run offline pack
|
||||
run: |
|
||||
E2E_BASE_URL=http://localhost:5173 \
|
||||
pnpm --filter @dorfteich/web exec playwright test e2e/offline.spec.ts
|
||||
|
||||
- name: Dump server logs on failure
|
||||
if: failure()
|
||||
run: tail -50 /tmp/api.log /tmp/collab.log /tmp/web.log || true
|
||||
|
||||
82
apps/web/e2e/offline.spec.ts
Normal file
82
apps/web/e2e/offline.spec.ts
Normal file
@ -0,0 +1,82 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import type { BrowserContext } from '@playwright/test';
|
||||
|
||||
import { contextForUser } from './helpers';
|
||||
|
||||
const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173';
|
||||
|
||||
async function personalPond(context: BrowserContext): Promise<{ id: string; slug: string }> {
|
||||
const ponds = await context.request.get('/api/v1/ponds');
|
||||
const pond = (await ponds.json()).find((p: { type: string }) => p.type === 'personal');
|
||||
return { id: pond.id, slug: pond.slug };
|
||||
}
|
||||
|
||||
test('offline: edit, reload while offline, then reconnect converges (#38)', async ({ browser }) => {
|
||||
const owner = await contextForUser(browser, BASE_URL, 'fixture-user');
|
||||
const pond = await personalPond(owner);
|
||||
const created = await owner.request.post(`/api/v1/ponds/${pond.id}/pages`, {
|
||||
data: { title: `Offline ${Date.now()}` },
|
||||
});
|
||||
const { slug } = await created.json();
|
||||
|
||||
const page = await owner.newPage();
|
||||
await page.goto(`/p/${pond.slug}/${slug}`);
|
||||
await page.getByRole('button', { name: /edit|bearbeiten/i }).click();
|
||||
await expect(page.locator('.editor-connection')).toHaveAttribute('data-status', 'connected', {
|
||||
timeout: 15000,
|
||||
});
|
||||
// The service worker must control the page before an offline reload can be
|
||||
// served from the cached app shell.
|
||||
await page.waitForFunction(() => navigator.serviceWorker?.controller != null, null, {
|
||||
timeout: 20000,
|
||||
});
|
||||
|
||||
const editor = page.locator('.ProseMirror');
|
||||
await editor.click();
|
||||
await page.keyboard.type('online base ');
|
||||
await expect(editor).toContainText('online base');
|
||||
|
||||
// Go offline and keep editing — the connection indicator flips and edits
|
||||
// continue locally.
|
||||
await owner.setOffline(true);
|
||||
await expect(page.locator('.editor-connection')).toHaveAttribute('data-status', 'offline', {
|
||||
timeout: 10000,
|
||||
});
|
||||
await editor.type('offline extra ');
|
||||
await expect(editor).toContainText('offline extra');
|
||||
|
||||
// No service-worker caching of API responses: an API call offline fails
|
||||
// (network error) rather than being served a stale cached 200.
|
||||
const apiResult = await page.evaluate(async () => {
|
||||
try {
|
||||
await fetch('/api/v1/ponds', { cache: 'no-store' });
|
||||
return 'ok';
|
||||
} catch {
|
||||
return 'network-error';
|
||||
}
|
||||
});
|
||||
expect(apiResult).toBe('network-error');
|
||||
|
||||
// Give y-indexeddb a moment to flush the offline edit before the reload.
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Reload the tab while offline: the app shell loads from the service worker,
|
||||
// the page resolves from the offline metadata cache, and its content comes
|
||||
// back from IndexedDB.
|
||||
await page.reload();
|
||||
const reloaded = page.locator('.ProseMirror');
|
||||
await expect(reloaded).toContainText('offline extra', { timeout: 20000 });
|
||||
await expect(reloaded).toContainText('online base');
|
||||
|
||||
// Back online: a second participant converges on the merged content.
|
||||
await owner.setOffline(false);
|
||||
const admin = await contextForUser(browser, BASE_URL, 'fixture-admin');
|
||||
const adminPage = await admin.newPage();
|
||||
await adminPage.goto(`/p/${pond.slug}/${slug}`);
|
||||
await expect(adminPage.locator('.ProseMirror')).toContainText('offline extra', {
|
||||
timeout: 25000,
|
||||
});
|
||||
|
||||
await owner.close();
|
||||
await admin.close();
|
||||
});
|
||||
@ -34,6 +34,7 @@
|
||||
"react-hook-form": "^7.80.0",
|
||||
"react-i18next": "^17.0.8",
|
||||
"react-router-dom": "^7.1.0",
|
||||
"y-indexeddb": "^9.0.12",
|
||||
"yjs": "^13.6.31",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
@ -45,6 +46,7 @@
|
||||
"jsdom": "^26.0.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vite": "^6.1.0",
|
||||
"vite-plugin-pwa": "^1.3.0",
|
||||
"vitest": "^3.0.0",
|
||||
"y-prosemirror": "^1.3.7"
|
||||
}
|
||||
|
||||
@ -15,11 +15,40 @@ interface AuthContextValue {
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
|
||||
// The last known signed-in user, cached so the app stays usable offline after a
|
||||
// reload (issue #38). The session cookie is still the real authority — this is
|
||||
// only a fallback while `/auth/me` is unreachable, and is revalidated (or
|
||||
// cleared on a 401) as soon as the network returns.
|
||||
const CURRENT_USER_KEY = 'dorfteich:current-user';
|
||||
|
||||
function readCachedUser(): CurrentUser | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(CURRENT_USER_KEY);
|
||||
return raw ? (JSON.parse(raw) as CurrentUser) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeCachedUser(user: CurrentUser | null): void {
|
||||
try {
|
||||
if (user) localStorage.setItem(CURRENT_USER_KEY, JSON.stringify(user));
|
||||
else localStorage.removeItem(CURRENT_USER_KEY);
|
||||
} catch {
|
||||
// Best-effort: ignore quota / disabled storage.
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchCurrentUser(): Promise<CurrentUser | null> {
|
||||
try {
|
||||
return await apiGet<CurrentUser>('/auth/me');
|
||||
const user = await apiGet<CurrentUser>('/auth/me');
|
||||
writeCachedUser(user);
|
||||
return user;
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status === 401) return null;
|
||||
if (error instanceof ApiError && error.status === 401) {
|
||||
writeCachedUser(null);
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@ -29,8 +58,13 @@ export function AuthProvider({ children }: { children: React.ReactNode }): React
|
||||
const { i18n } = useTranslation();
|
||||
const query = useQuery({ queryKey: ['auth', 'me'], queryFn: fetchCurrentUser, retry: false });
|
||||
|
||||
// Offline (e.g. after a reload), `/auth/me` can't resolve; fall back to the
|
||||
// cached user so the app stays signed in until the network returns (#38).
|
||||
const cachedUser = query.data === undefined && !navigator.onLine ? readCachedUser() : null;
|
||||
const user = query.data ?? cachedUser;
|
||||
|
||||
// The profile locale wins over browser detection (issue #17).
|
||||
const userLocale = query.data?.locale;
|
||||
const userLocale = user?.locale;
|
||||
useEffect(() => {
|
||||
if (userLocale && i18n.language !== userLocale) {
|
||||
void i18n.changeLanguage(userLocale);
|
||||
@ -38,11 +72,12 @@ export function AuthProvider({ children }: { children: React.ReactNode }): React
|
||||
}, [userLocale, i18n]);
|
||||
|
||||
const value: AuthContextValue = {
|
||||
user: query.data ?? null,
|
||||
isLoading: query.isPending,
|
||||
user,
|
||||
isLoading: query.isPending && !cachedUser,
|
||||
refresh: () => queryClient.invalidateQueries({ queryKey: ['auth', 'me'] }),
|
||||
logout: async () => {
|
||||
await apiPost('/auth/logout');
|
||||
writeCachedUser(null);
|
||||
queryClient.setQueryData(['auth', 'me'], null);
|
||||
queryClient.clear();
|
||||
},
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import type { CollabTokenResponse } from '@dorfteich/shared';
|
||||
import { HocuspocusProvider } from '@hocuspocus/provider';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { IndexeddbPersistence } from 'y-indexeddb';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
import { apiGet } from '../lib/api';
|
||||
@ -15,6 +16,13 @@ export interface CollabState {
|
||||
mode: 'rw' | 'ro' | null;
|
||||
/** Set once the server rejects an update for exceeding the size ceiling (#35). */
|
||||
tooLarge: boolean;
|
||||
/** True when there are edits held only on this device (offline, #38). */
|
||||
localOnly: boolean;
|
||||
}
|
||||
|
||||
/** IndexedDB database name for a page's local Yjs persistence (issue #38). */
|
||||
function localDbName(pageId: string): string {
|
||||
return `dorfteich-page-${pageId}`;
|
||||
}
|
||||
|
||||
/** WebSocket endpoint of the collab server, behind the same origin as the app
|
||||
@ -41,10 +49,19 @@ export function useCollabProvider(ydoc: Y.Doc | null, pageId: string): CollabSta
|
||||
const [online, setOnline] = useState(() => navigator.onLine);
|
||||
const [mode, setMode] = useState<'rw' | 'ro' | null>(null);
|
||||
const [tooLarge, setTooLarge] = useState(false);
|
||||
const [hasUnsynced, setHasUnsynced] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ydoc) return;
|
||||
let disposed = false;
|
||||
// Whether the server has ever confirmed our state this session; decides
|
||||
// whether the local copy is discarded on leave (below).
|
||||
let serverSynced = false;
|
||||
|
||||
// Local-first persistence: every opened page is mirrored to IndexedDB so
|
||||
// edits survive a reload and offline work (ADR 0003, #38). The provider
|
||||
// and IndexedDB share the same Y.Doc and merge conflict-free.
|
||||
const localPersistence = new IndexeddbPersistence(localDbName(pageId), ydoc);
|
||||
|
||||
const instance = new HocuspocusProvider({
|
||||
url: collabWsUrl(),
|
||||
@ -61,11 +78,15 @@ export function useCollabProvider(ydoc: Y.Doc | null, pageId: string): CollabSta
|
||||
if (status === 'connected') setEverConnected(true);
|
||||
},
|
||||
onSynced: () => {
|
||||
serverSynced = true;
|
||||
if (!disposed) setSynced(true);
|
||||
},
|
||||
onDisconnect: () => {
|
||||
if (!disposed) setSynced(false);
|
||||
},
|
||||
onUnsyncedChanges: ({ number }) => {
|
||||
if (!disposed) setHasUnsynced(number > 0);
|
||||
},
|
||||
onStateless: ({ payload }) => {
|
||||
if (disposed) return;
|
||||
try {
|
||||
@ -83,8 +104,17 @@ export function useCollabProvider(ydoc: Y.Doc | null, pageId: string): CollabSta
|
||||
return () => {
|
||||
disposed = true;
|
||||
instance.destroy();
|
||||
// Discard the local copy once the server has our changes (bounds
|
||||
// IndexedDB growth); otherwise keep it so offline edits survive to the
|
||||
// next visit (realtime-collaboration.md §Offline).
|
||||
if (serverSynced) {
|
||||
void localPersistence.clearData();
|
||||
} else {
|
||||
void localPersistence.destroy();
|
||||
}
|
||||
setProvider(null);
|
||||
setSynced(false);
|
||||
setHasUnsynced(false);
|
||||
};
|
||||
}, [ydoc, pageId]);
|
||||
|
||||
@ -110,5 +140,9 @@ export function useCollabProvider(ydoc: Y.Doc | null, pageId: string): CollabSta
|
||||
status = 'connecting';
|
||||
}
|
||||
|
||||
return { provider, status, mode, tooLarge };
|
||||
// Local-only = we hold edits the server has not acknowledged and we are not
|
||||
// currently in sync (offline or reconnecting).
|
||||
const localOnly = hasUnsynced && status !== 'connected';
|
||||
|
||||
return { provider, status, mode, tooLarge, localOnly };
|
||||
}
|
||||
|
||||
62
apps/web/src/offline/page-cache.test.ts
Normal file
62
apps/web/src/offline/page-cache.test.ts
Normal file
@ -0,0 +1,62 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { recallPage, rememberPage } from './page-cache';
|
||||
|
||||
// A simple in-memory localStorage stub, independent of the test environment's
|
||||
// own (jsdom's is unavailable for opaque origins; Node's needs a flag).
|
||||
beforeEach(() => {
|
||||
const store = new Map<string, string>();
|
||||
vi.stubGlobal('localStorage', {
|
||||
getItem: (key: string) => store.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => void store.set(key, value),
|
||||
removeItem: (key: string) => void store.delete(key),
|
||||
clear: () => store.clear(),
|
||||
});
|
||||
});
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
describe('offline page cache', () => {
|
||||
it('remembers and recalls a page by its slugs', () => {
|
||||
rememberPage({
|
||||
pondSlug: 'my-pond',
|
||||
pondId: 'pond-1',
|
||||
pageSlug: 'my-page',
|
||||
pageId: 'page-1',
|
||||
title: 'My Page',
|
||||
});
|
||||
const recalled = recallPage('my-pond', 'my-page');
|
||||
expect(recalled).toMatchObject({ pondId: 'pond-1', pageId: 'page-1', title: 'My Page' });
|
||||
});
|
||||
|
||||
it('returns null for an unknown page', () => {
|
||||
expect(recallPage('nope', 'nope')).toBeNull();
|
||||
});
|
||||
|
||||
it('overwrites an existing entry rather than duplicating it', () => {
|
||||
const base = { pondSlug: 'p', pondId: 'pond-1', pageSlug: 'a', pageId: 'page-1' };
|
||||
rememberPage({ ...base, title: 'Old' });
|
||||
rememberPage({ ...base, title: 'New' });
|
||||
expect(recallPage('p', 'a')?.title).toBe('New');
|
||||
expect(localStorage.getItem('dorfteich:offline-pages')).toContain('New');
|
||||
});
|
||||
|
||||
it('evicts the oldest entries beyond the cap', () => {
|
||||
for (let i = 0; i < 60; i += 1) {
|
||||
rememberPage({
|
||||
pondSlug: 'p',
|
||||
pondId: 'pond',
|
||||
pageSlug: `page-${i}`,
|
||||
pageId: `id-${i}`,
|
||||
title: `T${i}`,
|
||||
});
|
||||
}
|
||||
// The earliest entries are gone; the most recent survive.
|
||||
expect(recallPage('p', 'page-0')).toBeNull();
|
||||
expect(recallPage('p', 'page-59')).not.toBeNull();
|
||||
const store = JSON.parse(localStorage.getItem('dorfteich:offline-pages')!) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(Object.keys(store).length).toBeLessThanOrEqual(50);
|
||||
});
|
||||
});
|
||||
68
apps/web/src/offline/page-cache.ts
Normal file
68
apps/web/src/offline/page-cache.ts
Normal file
@ -0,0 +1,68 @@
|
||||
/**
|
||||
* A tiny app-level cache of the metadata needed to open a previously-visited
|
||||
* page while offline (issue #38). It maps a page URL (pond slug + page slug) to
|
||||
* the ids the editor and collab layer need. This is persisted by the app in
|
||||
* localStorage — deliberately NOT by the service worker — so API responses are
|
||||
* never cached, yet a visited page can still be resolved with no network.
|
||||
*
|
||||
* The store is bounded to the most recently visited pages so it cannot grow
|
||||
* without limit; each entry is a few hundred bytes.
|
||||
*/
|
||||
|
||||
const STORAGE_KEY = 'dorfteich:offline-pages';
|
||||
const MAX_ENTRIES = 50;
|
||||
|
||||
export interface CachedPage {
|
||||
pondSlug: string;
|
||||
pondId: string;
|
||||
pageSlug: string;
|
||||
pageId: string;
|
||||
title: string;
|
||||
/** Last write time, for bounded recency-based eviction. */
|
||||
savedAt: number;
|
||||
}
|
||||
|
||||
type Store = Record<string, CachedPage>;
|
||||
|
||||
function keyOf(pondSlug: string, pageSlug: string): string {
|
||||
return `${pondSlug}/${pageSlug}`;
|
||||
}
|
||||
|
||||
function readStore(): Store {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
return raw ? (JSON.parse(raw) as Store) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function writeStore(store: Store): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(store));
|
||||
} catch {
|
||||
// Best-effort: ignore quota errors or disabled storage.
|
||||
}
|
||||
}
|
||||
|
||||
/** Record the metadata of a page loaded online, for later offline resolution. */
|
||||
export function rememberPage(entry: Omit<CachedPage, 'savedAt'>): void {
|
||||
const store = readStore();
|
||||
store[keyOf(entry.pondSlug, entry.pageSlug)] = { ...entry, savedAt: Date.now() };
|
||||
|
||||
const keys = Object.keys(store);
|
||||
if (keys.length > MAX_ENTRIES) {
|
||||
keys
|
||||
.sort((a, b) => (store[a]?.savedAt ?? 0) - (store[b]?.savedAt ?? 0))
|
||||
.slice(0, keys.length - MAX_ENTRIES)
|
||||
.forEach((key) => delete store[key]);
|
||||
writeStore(store);
|
||||
} else {
|
||||
writeStore(store);
|
||||
}
|
||||
}
|
||||
|
||||
/** Look up a previously-visited page's metadata by its URL slugs. */
|
||||
export function recallPage(pondSlug: string, pageSlug: string): CachedPage | null {
|
||||
return readStore()[keyOf(pondSlug, pageSlug)] ?? null;
|
||||
}
|
||||
@ -17,10 +17,20 @@ import { Toolbar } from '../editor/Toolbar';
|
||||
import { useCollabProvider } from '../editor/use-collab-provider';
|
||||
import { useForceSidebarHidden } from '../layout/sidebar-chrome';
|
||||
import { ApiError, apiDelete, apiGet, apiGetText, apiPatch } from '../lib/api';
|
||||
import { recallPage, rememberPage } from '../offline/page-cache';
|
||||
|
||||
type Mode = 'view' | 'edit';
|
||||
|
||||
function PageEditor({ page, mode }: { page: PageStateView; mode: Mode }): React.JSX.Element {
|
||||
/** The minimal page identity the editor needs — available from the API online
|
||||
* or from the offline page cache after a reload without a connection (#38). */
|
||||
interface ResolvedPage {
|
||||
id: string;
|
||||
pondId: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
function PageEditor({ page, mode }: { page: ResolvedPage; mode: Mode }): React.JSX.Element {
|
||||
const { t } = useTranslation('editor');
|
||||
const { user } = useAuth();
|
||||
|
||||
@ -81,6 +91,11 @@ function PageEditor({ page, mode }: { page: PageStateView; mode: Mode }): React.
|
||||
{t(`connection.${collab.status}`)}
|
||||
</div>
|
||||
<PresenceStrip provider={collab.provider} />
|
||||
{collab.localOnly && (
|
||||
<div className="editor-banner editor-banner--info" role="note">
|
||||
{t('offline.localOnly')}
|
||||
</div>
|
||||
)}
|
||||
{mode === 'edit' && readOnly && (
|
||||
<div className="editor-banner editor-banner--info" role="note">
|
||||
{t('readOnly.notice')}
|
||||
@ -170,16 +185,46 @@ export function PageEditorPage(): React.JSX.Element {
|
||||
enabled: Boolean(pond.data),
|
||||
});
|
||||
|
||||
// Remember this page's metadata while online so it can be opened offline (#38).
|
||||
useEffect(() => {
|
||||
if (page.data) setTitle(page.data.title);
|
||||
}, [page.data?.id, page.data?.title]);
|
||||
if (pond.data && page.data) {
|
||||
rememberPage({
|
||||
pondSlug,
|
||||
pondId: pond.data.id,
|
||||
pageSlug,
|
||||
pageId: page.data.id,
|
||||
title: page.data.title,
|
||||
});
|
||||
}
|
||||
}, [pondSlug, pageSlug, pond.data?.id, page.data?.id, page.data?.title]);
|
||||
|
||||
// Prefer the live API result; when offline and it is unavailable, fall back to
|
||||
// the locally cached metadata so the editor still mounts and shows the
|
||||
// IndexedDB copy of a previously-visited page (#38). Errors while online
|
||||
// (e.g. a trashed page) still surface below.
|
||||
const offlineCached = !navigator.onLine && !page.data ? recallPage(pondSlug, pageSlug) : null;
|
||||
|
||||
const resolved: ResolvedPage | null = page.data
|
||||
? { id: page.data.id, pondId: page.data.pondId, slug: page.data.slug, title: page.data.title }
|
||||
: offlineCached
|
||||
? {
|
||||
id: offlineCached.pageId,
|
||||
pondId: offlineCached.pondId,
|
||||
slug: offlineCached.pageSlug,
|
||||
title: offlineCached.title,
|
||||
}
|
||||
: null;
|
||||
|
||||
useEffect(() => {
|
||||
if (resolved) setTitle(resolved.title);
|
||||
}, [resolved?.id, resolved?.title]);
|
||||
|
||||
async function saveTitle(): Promise<void> {
|
||||
if (!page.data || title === page.data.title) return;
|
||||
await apiPatch(`/pages/${page.data.id}`, { title });
|
||||
if (!resolved || title === resolved.title) return;
|
||||
await apiPatch(`/pages/${resolved.id}`, { title });
|
||||
}
|
||||
|
||||
if (pond.error || page.error) {
|
||||
if ((pond.error || page.error) && !resolved) {
|
||||
// Editors get a distinguishable hint (and a way out) instead of a dead
|
||||
// end when the page they followed a link to is in the trash (#31).
|
||||
const trashed = page.error instanceof ApiError && page.error.body.code === 'page_trashed';
|
||||
@ -190,7 +235,7 @@ export function PageEditorPage(): React.JSX.Element {
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (!page.data) {
|
||||
if (!resolved) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
@ -213,9 +258,9 @@ export function PageEditorPage(): React.JSX.Element {
|
||||
>
|
||||
{mode === 'edit' ? t('mode.view') : t('mode.edit')}
|
||||
</button>
|
||||
<PageMenu pageId={page.data.id} slug={page.data.slug} pondSlug={pondSlug} />
|
||||
<PageMenu pageId={resolved.id} slug={resolved.slug} pondSlug={pondSlug} />
|
||||
</div>
|
||||
<PageEditor page={page.data} mode={mode} />
|
||||
<PageEditor page={resolved} mode={mode} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,8 +1,36 @@
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { defineConfig } from 'vite';
|
||||
import { VitePWA } from 'vite-plugin-pwa';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
plugins: [
|
||||
react(),
|
||||
// PWA app-shell caching for offline editing (issue #38). Only build assets
|
||||
// are precached; the service worker never caches `/api` or `/collab`
|
||||
// (no runtime caching + navigation-fallback denylist), so API responses are
|
||||
// always fresh from the network and never poisoned by a stale cache.
|
||||
VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
injectRegister: 'auto',
|
||||
workbox: {
|
||||
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'],
|
||||
navigateFallback: '/index.html',
|
||||
navigateFallbackDenylist: [/^\/api/, /^\/collab/],
|
||||
},
|
||||
// No dev service worker — the SW is exercised against the production
|
||||
// build (dist) only, which keeps native `vite` dev free of SW caching.
|
||||
devOptions: { enabled: false },
|
||||
manifest: {
|
||||
name: 'Dorfteich',
|
||||
short_name: 'Dorfteich',
|
||||
description: 'Collaborative wiki',
|
||||
theme_color: '#2f6f4f',
|
||||
background_color: '#ffffff',
|
||||
display: 'standalone',
|
||||
start_url: '/',
|
||||
},
|
||||
}),
|
||||
],
|
||||
server: {
|
||||
// Native dev: api on localhost:3001 (3000 may be occupied by other
|
||||
// projects). Containerized dev overrides this via VITE_API_PROXY_TARGET.
|
||||
|
||||
@ -24,6 +24,9 @@
|
||||
"label": "{{count}} Personen auf dieser Seite",
|
||||
"viewer": "{{name}} (nur Lesen)"
|
||||
},
|
||||
"offline": {
|
||||
"localOnly": "Diese Seite hat Änderungen, die nur auf diesem Gerät gespeichert sind. Sie werden automatisch synchronisiert, sobald du wieder online bist."
|
||||
},
|
||||
"toolbar": {
|
||||
"paragraph": "Absatz",
|
||||
"heading1": "Überschrift 1",
|
||||
|
||||
@ -24,6 +24,9 @@
|
||||
"label": "{{count}} people on this page",
|
||||
"viewer": "{{name}} (read-only)"
|
||||
},
|
||||
"offline": {
|
||||
"localOnly": "This page has changes saved only on this device. They'll sync automatically when you're back online."
|
||||
},
|
||||
"toolbar": {
|
||||
"paragraph": "Paragraph",
|
||||
"heading1": "Heading 1",
|
||||
|
||||
2497
pnpm-lock.yaml
generated
2497
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -28,6 +28,7 @@ const MIME = {
|
||||
'.png': 'image/png',
|
||||
'.ico': 'image/x-icon',
|
||||
'.woff2': 'font/woff2',
|
||||
'.webmanifest': 'application/manifest+json',
|
||||
};
|
||||
|
||||
function proxyApi(req, res) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user