feat(web): офлайн-режим с кешем эскизов и превью
Добавлен service worker: оболочка приложения, ответы API и варианты изображений (эскиз, превью, средний) сохраняются в офлайн-кеше браузера. Ключ кеша картинки — путь объекта без подписи, поэтому обновление подписи не плодит записи; оригиналы и видео не кешируются. Кеш ограничен по размеру и вытесняет старые записи, при выходе из аккаунта очищается, а в верхней панели появляется индикатор «Офлайн». Добавлен манифест PWA; манифест отдаётся с типом application/manifest+json.
This commit is contained in:
@@ -10,7 +10,8 @@ SvelteKit через `go:embed`, запуск через systemd.
|
|||||||
|
|
||||||
- **Backend:** Go, Gin, pgx (PostgreSQL на `pg.localnet`), AWS SDK v2 (Garage на
|
- **Backend:** Go, Gin, pgx (PostgreSQL на `pg.localnet`), AWS SDK v2 (Garage на
|
||||||
`s3.localnet`, path-style), нативный SSE, golang-migrate, slog.
|
`s3.localnet`, path-style), нативный SSE, golang-migrate, slog.
|
||||||
- **Frontend:** SvelteKit 2 + Svelte 5, adapter-static, TypeScript, TailwindCSS.
|
- **Frontend:** SvelteKit 2 + Svelte 5, adapter-static, TypeScript, TailwindCSS,
|
||||||
|
service worker с офлайн-кешем оболочки, списков и эскизов/превью фотографий.
|
||||||
- **Обработка изображений:** `disintegration/imaging` + `gen2brain/webp` +
|
- **Обработка изображений:** `disintegration/imaging` + `gen2brain/webp` +
|
||||||
`rwcarlsen/goexif` (всё чистый Go).
|
`rwcarlsen/goexif` (всё чистый Go).
|
||||||
|
|
||||||
|
|||||||
@@ -364,6 +364,11 @@ func (s *Server) registerStatic(r *gin.Engine) {
|
|||||||
if clean != "" {
|
if clean != "" {
|
||||||
if f, err := sub.Open(clean); err == nil {
|
if f, err := sub.Open(clean); err == nil {
|
||||||
f.Close()
|
f.Close()
|
||||||
|
// Манифест PWA отдаём с правильным типом: в системной таблице
|
||||||
|
// mime расширения .webmanifest нет.
|
||||||
|
if strings.HasSuffix(clean, ".webmanifest") {
|
||||||
|
c.Header("Content-Type", "application/manifest+json")
|
||||||
|
}
|
||||||
fileServer.ServeHTTP(c.Writer, c.Request)
|
fileServer.ServeHTTP(c.Writer, c.Request)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,10 @@
|
|||||||
<link rel="icon" href="%sveltekit.assets%/favicon.svg" type="image/svg+xml" />
|
<link rel="icon" href="%sveltekit.assets%/favicon.svg" type="image/svg+xml" />
|
||||||
<link rel="alternate icon" href="%sveltekit.assets%/favicon.png" type="image/png" />
|
<link rel="alternate icon" href="%sveltekit.assets%/favicon.png" type="image/png" />
|
||||||
<link rel="apple-touch-icon" href="%sveltekit.assets%/apple-touch-icon.png" />
|
<link rel="apple-touch-icon" href="%sveltekit.assets%/apple-touch-icon.png" />
|
||||||
|
<link rel="manifest" href="%sveltekit.assets%/manifest.webmanifest" />
|
||||||
|
<meta name="theme-color" content="#171717" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-title" content="Galo" />
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
import { density } from '$lib/stores/density.svelte';
|
import { density } from '$lib/stores/density.svelte';
|
||||||
import { panels } from '$lib/stores/panels.svelte';
|
import { panels } from '$lib/stores/panels.svelte';
|
||||||
import { viewer } from '$lib/stores/viewer.svelte';
|
import { viewer } from '$lib/stores/viewer.svelte';
|
||||||
|
import { clearOfflineCache } from '$lib/offline';
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -37,6 +38,7 @@
|
|||||||
let isFullscreen = $state(false);
|
let isFullscreen = $state(false);
|
||||||
let isWide = $state(false);
|
let isWide = $state(false);
|
||||||
let isExtraWide = $state(false);
|
let isExtraWide = $state(false);
|
||||||
|
let online = $state(true);
|
||||||
|
|
||||||
// Ширина, которую занимают панели в текущей раскладке: рейл и панель
|
// Ширина, которую занимают панели в текущей раскладке: рейл и панель
|
||||||
// встают рядом с содержимым на широких экранах, на узких — поверх него.
|
// встают рядом с содержимым на широких экранах, на узких — поверх него.
|
||||||
@@ -74,10 +76,18 @@
|
|||||||
mqLg.addEventListener('change', sync);
|
mqLg.addEventListener('change', sync);
|
||||||
mqXl.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 () => {
|
return () => {
|
||||||
document.removeEventListener('fullscreenchange', onFs);
|
document.removeEventListener('fullscreenchange', onFs);
|
||||||
mqLg.removeEventListener('change', sync);
|
mqLg.removeEventListener('change', sync);
|
||||||
mqXl.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(() => {});
|
await authApi.logout(auth.refresh).catch(() => {});
|
||||||
}
|
}
|
||||||
auth.clear();
|
auth.clear();
|
||||||
|
// Офлайн-кеш принадлежит прежнему пользователю — чистим его.
|
||||||
|
await clearOfflineCache().catch(() => {});
|
||||||
goto('/login');
|
goto('/login');
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -245,6 +257,16 @@
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-1 sm:gap-1.5">
|
<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}
|
{#if panel && panelVisible}
|
||||||
<button
|
<button
|
||||||
type="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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
/// <reference types="@sveltejs/kit" />
|
||||||
|
/// <reference types="vite/client" />
|
||||||
|
/// <reference lib="webworker" />
|
||||||
|
|
||||||
|
import { build, files, prerendered, version } from '$service-worker';
|
||||||
|
import {
|
||||||
|
DATA_CACHE,
|
||||||
|
DATA_CACHE_MAX_ENTRIES,
|
||||||
|
IMAGE_CACHE,
|
||||||
|
IMAGE_CACHE_MAX_BYTES,
|
||||||
|
IMAGE_CACHE_MAX_ENTRIES,
|
||||||
|
SHELL_CACHE_PREFIX,
|
||||||
|
isCacheableData,
|
||||||
|
isCacheableObject
|
||||||
|
} from '$lib/offline';
|
||||||
|
|
||||||
|
const sw = self as unknown as ServiceWorkerGlobalScope;
|
||||||
|
|
||||||
|
// В dev-режиме ничего не кешируем: файлы приложения часто меняются.
|
||||||
|
const DEV = import.meta.env.DEV;
|
||||||
|
|
||||||
|
const SHELL_CACHE = `${SHELL_CACHE_PREFIX}${version}`;
|
||||||
|
|
||||||
|
/** Оболочка приложения: собранный бандл, статика и запасная страница. */
|
||||||
|
const SHELL = [...build, ...files, ...prerendered, '/'];
|
||||||
|
|
||||||
|
sw.addEventListener('install', (event) => {
|
||||||
|
if (DEV) return;
|
||||||
|
event.waitUntil(
|
||||||
|
(async () => {
|
||||||
|
const cache = await caches.open(SHELL_CACHE);
|
||||||
|
await Promise.all(
|
||||||
|
SHELL.map(async (url) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, { cache: 'reload' });
|
||||||
|
if (res.ok) await cache.put(url, res);
|
||||||
|
} catch {
|
||||||
|
/* отдельный файл не загрузился — пропускаем */
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
})()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
sw.addEventListener('activate', (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
(async () => {
|
||||||
|
const keys = await caches.keys();
|
||||||
|
await Promise.all(
|
||||||
|
keys
|
||||||
|
.filter((key) => key.startsWith(SHELL_CACHE_PREFIX) && key !== SHELL_CACHE)
|
||||||
|
.map((key) => caches.delete(key))
|
||||||
|
);
|
||||||
|
await sw.clients.claim();
|
||||||
|
})()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
sw.addEventListener('message', (event) => {
|
||||||
|
if (event.data === 'clear-offline-cache') {
|
||||||
|
event.waitUntil(Promise.all([caches.delete(IMAGE_CACHE), caches.delete(DATA_CACHE)]));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
sw.addEventListener('fetch', (event) => {
|
||||||
|
if (DEV) return;
|
||||||
|
const request = event.request;
|
||||||
|
if (request.method !== 'GET') return;
|
||||||
|
|
||||||
|
const url = new URL(request.url);
|
||||||
|
if (url.origin !== sw.location.origin) return;
|
||||||
|
|
||||||
|
// Поток событий (SSE) не перехватываем.
|
||||||
|
if (url.pathname.startsWith('/api/sse/')) return;
|
||||||
|
|
||||||
|
if (url.pathname.startsWith('/api/objects/') || url.pathname.startsWith('/api/avatars/')) {
|
||||||
|
event.respondWith(handleImage(request, url));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.pathname.startsWith('/api/')) {
|
||||||
|
event.respondWith(handleData(request, url));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (request.mode === 'navigate') {
|
||||||
|
event.respondWith(handleNavigation(request));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.respondWith(handleAsset(request));
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Картинки: кеш по пути без подписи. Подпись объекта обновляется каждые
|
||||||
|
* десять минут, но содержимое объекта неизменно, поэтому ключ кеша не должен
|
||||||
|
* зависеть от строки запроса.
|
||||||
|
*/
|
||||||
|
async function handleImage(request: Request, url: URL): Promise<Response> {
|
||||||
|
// Частичные запросы (видео) не кешируем.
|
||||||
|
if (request.headers.has('range')) return fetch(request);
|
||||||
|
if (!isCacheableObject(url.pathname)) return fetch(request);
|
||||||
|
|
||||||
|
const cache = await caches.open(IMAGE_CACHE);
|
||||||
|
const key = url.origin + url.pathname;
|
||||||
|
const cached = await cache.match(key);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(request);
|
||||||
|
if (response.ok) {
|
||||||
|
await cache.put(key, response.clone());
|
||||||
|
scheduleTrim();
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
} catch {
|
||||||
|
return new Response('', { status: 504, statusText: 'Offline' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ответы API: сеть вперёд, при её отсутствии — последняя сохранённая копия. */
|
||||||
|
async function handleData(request: Request, url: URL): Promise<Response> {
|
||||||
|
if (!isCacheableData(url.pathname)) return fetch(request);
|
||||||
|
|
||||||
|
const cache = await caches.open(DATA_CACHE);
|
||||||
|
try {
|
||||||
|
const response = await fetch(request);
|
||||||
|
if (response.ok) {
|
||||||
|
await cache.put(request, response.clone());
|
||||||
|
scheduleTrim();
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
} catch {
|
||||||
|
const cached = await cache.match(request);
|
||||||
|
if (cached) return cached;
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: { code: 'offline', message: 'Нет соединения с сервером' } }),
|
||||||
|
{ status: 503, headers: { 'content-type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Переходы: сеть, а без неё — сохранённая оболочка приложения. */
|
||||||
|
async function handleNavigation(request: Request): Promise<Response> {
|
||||||
|
try {
|
||||||
|
return await fetch(request);
|
||||||
|
} catch {
|
||||||
|
const cache = await caches.open(SHELL_CACHE);
|
||||||
|
const cached = await cache.match('/');
|
||||||
|
if (cached) return cached;
|
||||||
|
return new Response('Офлайн', {
|
||||||
|
status: 503,
|
||||||
|
headers: { 'content-type': 'text/plain; charset=utf-8' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Статика приложения: сначала кеш, затем сеть с дозаписью. */
|
||||||
|
async function handleAsset(request: Request): Promise<Response> {
|
||||||
|
const cache = await caches.open(SHELL_CACHE);
|
||||||
|
const cached = await cache.match(request);
|
||||||
|
if (cached) return cached;
|
||||||
|
const response = await fetch(request);
|
||||||
|
const type = response.headers.get('content-type') ?? '';
|
||||||
|
// Запасная страница SPA не должна отдаваться вместо статики.
|
||||||
|
if (response.ok && type.includes('text/html')) {
|
||||||
|
return new Response('', { status: 404, statusText: 'Not Found' });
|
||||||
|
}
|
||||||
|
if (response.ok) await cache.put(request, response.clone());
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
let trimCounter = 0;
|
||||||
|
|
||||||
|
/** Периодически ужимаем кеш картинок, вытесняя самые старые записи. */
|
||||||
|
function scheduleTrim() {
|
||||||
|
trimCounter += 1;
|
||||||
|
if (trimCounter < 20) return;
|
||||||
|
trimCounter = 0;
|
||||||
|
void trimImageCache();
|
||||||
|
void trimDataCache();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function trimImageCache() {
|
||||||
|
const cache = await caches.open(IMAGE_CACHE);
|
||||||
|
const keys = await cache.keys();
|
||||||
|
if (!keys.length) return;
|
||||||
|
|
||||||
|
const sizes: number[] = [];
|
||||||
|
let total = 0;
|
||||||
|
for (const key of keys) {
|
||||||
|
const response = await cache.match(key);
|
||||||
|
const size = Number(response?.headers.get('content-length') ?? 0);
|
||||||
|
sizes.push(size);
|
||||||
|
total += size;
|
||||||
|
}
|
||||||
|
|
||||||
|
let index = 0;
|
||||||
|
while (
|
||||||
|
index < keys.length &&
|
||||||
|
(total > IMAGE_CACHE_MAX_BYTES || keys.length - index > IMAGE_CACHE_MAX_ENTRIES)
|
||||||
|
) {
|
||||||
|
await cache.delete(keys[index]);
|
||||||
|
total -= sizes[index];
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ужимаем кеш ответов API: остаются последние сохранённые списки. */
|
||||||
|
async function trimDataCache() {
|
||||||
|
const cache = await caches.open(DATA_CACHE);
|
||||||
|
const keys = await cache.keys();
|
||||||
|
let index = 0;
|
||||||
|
while (index < keys.length && keys.length - index > DATA_CACHE_MAX_ENTRIES) {
|
||||||
|
await cache.delete(keys[index]);
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"name": "Galo — фотоальбом",
|
||||||
|
"short_name": "Galo",
|
||||||
|
"description": "Самодостаточный фотоальбом: альбомы, сетка, просмотр и офлайн-режим.",
|
||||||
|
"lang": "ru",
|
||||||
|
"start_url": "/",
|
||||||
|
"scope": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"orientation": "any",
|
||||||
|
"background_color": "#171717",
|
||||||
|
"theme_color": "#171717",
|
||||||
|
"icons": [
|
||||||
|
{ "src": "/favicon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any" },
|
||||||
|
{ "src": "/favicon.png", "sizes": "32x32", "type": "image/png" },
|
||||||
|
{ "src": "/apple-touch-icon.png", "sizes": "180x180", "type": "image/png" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -746,3 +746,32 @@ SHALL открывать режим просмотра.
|
|||||||
#### Scenario: Удаление альбома
|
#### Scenario: Удаление альбома
|
||||||
- **WHEN** пользователь удаляет альбом с фото
|
- **WHEN** пользователь удаляет альбом с фото
|
||||||
- **THEN** его фото попадают в корзину, а число элементов корзины в рейле увеличивается на их количество
|
- **THEN** его фото попадают в корзину, а число элементов корзины в рейле увеличивается на их количество
|
||||||
|
|
||||||
|
### Requirement: Офлайн-режим веб-приложения
|
||||||
|
Веб-приложение SHALL хранить в офлайн-кеше браузера оболочку приложения, ответы API
|
||||||
|
(списки альбомов и фото) и варианты изображений: эскиз, превью и средний вариант.
|
||||||
|
Ключ кеша картинки SHALL определяться путём объекта без подписи, чтобы обновление подписи
|
||||||
|
не создавало новые записи. Оригиналы и видео SHALL не кешироваться. Размер кеша SHALL быть
|
||||||
|
ограничен, а более старые записи SHALL вытесняться первыми. При выходе из аккаунта кеш
|
||||||
|
SHALL очищаться. При отсутствии сети приложение SHALL открываться и показывать сохранённые
|
||||||
|
данные, а верхняя панель SHALL показывать индикатор «Офлайн».
|
||||||
|
|
||||||
|
#### Scenario: Просмотр без сети
|
||||||
|
- **WHEN** пользователь открывает ранее просмотренный альбом без подключения к сети
|
||||||
|
- **THEN** приложение открывается, показывает сохранённый список фото и их эскизы
|
||||||
|
|
||||||
|
#### Scenario: Смена подписи объекта
|
||||||
|
- **WHEN** подпись объекта обновляется, а содержимое остаётся прежним
|
||||||
|
- **THEN** используется та же запись кеша, а не новая
|
||||||
|
|
||||||
|
#### Scenario: Ограничение кеша
|
||||||
|
- **WHEN** кеш картинок превышает предел
|
||||||
|
- **THEN** самые старые записи вытесняются, а свежие остаются
|
||||||
|
|
||||||
|
#### Scenario: Выход из аккаунта
|
||||||
|
- **WHEN** пользователь выходит из аккаунта
|
||||||
|
- **THEN** офлайн-кеш картинок и данных очищается
|
||||||
|
|
||||||
|
#### Scenario: Индикатор офлайна
|
||||||
|
- **WHEN** браузер сообщает об отсутствии сети
|
||||||
|
- **THEN** верхняя панель показывает индикатор «Офлайн»
|
||||||
|
|||||||
Reference in New Issue
Block a user