feat(web): офлайн-режим с кешем эскизов и превью

Добавлен service worker: оболочка приложения, ответы API и варианты
изображений (эскиз, превью, средний) сохраняются в офлайн-кеше браузера.
Ключ кеша картинки — путь объекта без подписи, поэтому обновление подписи
не плодит записи; оригиналы и видео не кешируются. Кеш ограничен по размеру
и вытесняет старые записи, при выходе из аккаунта очищается, а в верхней
панели появляется индикатор «Офлайн».

Добавлен манифест PWA; манифест отдаётся с типом application/manifest+json.
This commit is contained in:
iu
2026-10-20 22:27:24 +03:00
parent 961dff15da
commit afaaaab5f0
9 changed files with 373 additions and 1 deletions
@@ -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"