feat(web): офлайн-режим с кешем эскизов и превью
Добавлен service worker: оболочка приложения, ответы API и варианты изображений (эскиз, превью, средний) сохраняются в офлайн-кеше браузера. Ключ кеша картинки — путь объекта без подписи, поэтому обновление подписи не плодит записи; оригиналы и видео не кешируются. Кеш ограничен по размеру и вытесняет старые записи, при выходе из аккаунта очищается, а в верхней панели появляется индикатор «Офлайн». Добавлен манифест PWA; манифест отдаётся с типом application/manifest+json.
This commit is contained in:
@@ -10,6 +10,7 @@
|
||||
import { density } from '$lib/stores/density.svelte';
|
||||
import { panels } from '$lib/stores/panels.svelte';
|
||||
import { viewer } from '$lib/stores/viewer.svelte';
|
||||
import { clearOfflineCache } from '$lib/offline';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
@@ -37,6 +38,7 @@
|
||||
let isFullscreen = $state(false);
|
||||
let isWide = $state(false);
|
||||
let isExtraWide = $state(false);
|
||||
let online = $state(true);
|
||||
|
||||
// Ширина, которую занимают панели в текущей раскладке: рейл и панель
|
||||
// встают рядом с содержимым на широких экранах, на узких — поверх него.
|
||||
@@ -74,10 +76,18 @@
|
||||
mqLg.addEventListener('change', sync);
|
||||
mqXl.addEventListener('change', sync);
|
||||
|
||||
const onOnline = () => (online = true);
|
||||
const onOffline = () => (online = false);
|
||||
online = navigator.onLine;
|
||||
window.addEventListener('online', onOnline);
|
||||
window.addEventListener('offline', onOffline);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('fullscreenchange', onFs);
|
||||
mqLg.removeEventListener('change', sync);
|
||||
mqXl.removeEventListener('change', sync);
|
||||
window.removeEventListener('online', onOnline);
|
||||
window.removeEventListener('offline', onOffline);
|
||||
};
|
||||
});
|
||||
|
||||
@@ -219,6 +229,8 @@
|
||||
await authApi.logout(auth.refresh).catch(() => {});
|
||||
}
|
||||
auth.clear();
|
||||
// Офлайн-кеш принадлежит прежнему пользователю — чистим его.
|
||||
await clearOfflineCache().catch(() => {});
|
||||
goto('/login');
|
||||
}
|
||||
</script>
|
||||
@@ -245,6 +257,16 @@
|
||||
</a>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 sm:gap-1.5">
|
||||
{#if !online}
|
||||
<span
|
||||
class="bg-bg-muted text-text-muted hidden items-center gap-1 rounded px-1.5 py-0.5 text-xs sm:inline-flex"
|
||||
role="status"
|
||||
title="Нет соединения: показываются сохранённые данные"
|
||||
>
|
||||
<span class="bg-danger size-1.5 rounded-full" aria-hidden="true"></span>
|
||||
Офлайн
|
||||
</span>
|
||||
{/if}
|
||||
{#if panel && panelVisible}
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { isCacheableData, isCacheableObject } from './offline';
|
||||
|
||||
describe('офлайн-кеш', () => {
|
||||
it('хранит эскизы, превью и средние варианты', () => {
|
||||
expect(isCacheableObject('/api/objects/objects/abc/thumb.webp')).toBe(true);
|
||||
expect(isCacheableObject('/api/objects/objects/abc/preview.webp')).toBe(true);
|
||||
expect(isCacheableObject('/api/objects/objects/abc/medium.webp')).toBe(true);
|
||||
expect(isCacheableObject('/api/avatars/u1/3.jpg')).toBe(true);
|
||||
});
|
||||
|
||||
it('не хранит оригиналы', () => {
|
||||
expect(isCacheableObject('/api/objects/objects/abc/original.jpg')).toBe(false);
|
||||
expect(isCacheableObject('/api/objects/objects/abc/original.mp4')).toBe(false);
|
||||
});
|
||||
|
||||
it('не кеширует авторизацию, события и скачивание', () => {
|
||||
expect(isCacheableData('/api/auth/me')).toBe(false);
|
||||
expect(isCacheableData('/api/sse/albums/a1')).toBe(false);
|
||||
expect(isCacheableData('/api/health')).toBe(false);
|
||||
expect(isCacheableData('/api/photos/p1/download')).toBe(false);
|
||||
});
|
||||
|
||||
it('кеширует списки альбомов и фото', () => {
|
||||
expect(isCacheableData('/api/albums')).toBe(true);
|
||||
expect(isCacheableData('/api/albums/a1/photos')).toBe(true);
|
||||
expect(isCacheableData('/api/photos/p1')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Офлайн-кеш веб-приложения.
|
||||
*
|
||||
* Service worker и страница используют одни и те же имена кешей: страница
|
||||
* чистит кеш при выходе из аккаунта, worker — при активации новой версии.
|
||||
*/
|
||||
|
||||
/** Префикс кеша оболочки приложения; версия добавляется в конце. */
|
||||
export const SHELL_CACHE_PREFIX = 'galo-shell-';
|
||||
|
||||
/** Кеш картинок: эскизы, превью и средние варианты. */
|
||||
export const IMAGE_CACHE = 'galo-images-v1';
|
||||
|
||||
/** Кеш ответов API: списки альбомов и фото для работы без сети. */
|
||||
export const DATA_CACHE = 'galo-data-v1';
|
||||
|
||||
/** Пределы кеша картинок: более старые записи вытесняются первыми. */
|
||||
export const IMAGE_CACHE_MAX_BYTES = 256 * 1024 * 1024;
|
||||
export const IMAGE_CACHE_MAX_ENTRIES = 3000;
|
||||
|
||||
/** Предел кеша ответов API (списки альбомов и фото). */
|
||||
export const DATA_CACHE_MAX_ENTRIES = 120;
|
||||
|
||||
/** Сбрасывает кеш картинок и данных (например, при выходе из аккаунта). */
|
||||
export async function clearOfflineCache(): Promise<void> {
|
||||
if (typeof caches === 'undefined') return;
|
||||
await Promise.all([caches.delete(IMAGE_CACHE), caches.delete(DATA_CACHE)]);
|
||||
}
|
||||
|
||||
/** Роли объектов, которые имеет смысл хранить офлайн. */
|
||||
const CACHEABLE_ROLES = ['thumb', 'preview', 'medium'];
|
||||
|
||||
/**
|
||||
* Следует ли хранить офлайн объект по такому пути. Оригиналы и видео не
|
||||
* кешируем: они слишком велики.
|
||||
*/
|
||||
export function isCacheableObject(pathname: string): boolean {
|
||||
if (pathname.startsWith('/api/avatars/')) return true;
|
||||
const name = pathname.split('/').pop() ?? '';
|
||||
return CACHEABLE_ROLES.some((role) => name.startsWith(role));
|
||||
}
|
||||
|
||||
/** Следует ли хранить офлайн ответ API по такому пути. */
|
||||
export function isCacheableData(pathname: string): boolean {
|
||||
if (pathname.startsWith('/api/auth/') || pathname.startsWith('/api/sse/')) return false;
|
||||
if (pathname.startsWith('/api/health')) return false;
|
||||
if (pathname.endsWith('/download')) return false;
|
||||
return true;
|
||||
}
|
||||
Reference in New Issue
Block a user