Offline Hooks — Полный код¶
Уровень: L3 (deep-dive) | ← Назад к Offline
1. useLocalItems / useLocalItem¶
Файл: frontend/src/offline/hooks/useLocalItems.ts
Чтение вещей из IndexedDB с фоновым обновлением с сервера.
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect } from 'react';
import { itemsRepository, localItemToApi } from '../repositories/itemsRepository';
import { itemsApi } from '../../api/items';
import { db } from '../db/dexie';
import { useNetworkStatus } from '../network/useNetworkStatus';
import type { Item, ItemListParams } from '../../types/api';
/**
* Список вещей из IndexedDB (мгновенно), background-refresh с сервера.
* Резолвит blob URL из таблицы blobs для offline-created вещей.
*/
export function useLocalItems(params?: ItemListParams) {
const queryClient = useQueryClient();
const online = useNetworkStatus();
const query = useQuery({
queryKey: ['items', params ?? {}],
networkMode: 'always', // Работает и offline
queryFn: async () => {
const localItems = await itemsRepository.getAll(params);
// Резолвить blob URL для вещей без server URL
const blobMap = new Map<string, string>();
const blobRefs = localItems
.filter((li) => !li.primary_media_url && li.local_media_ref)
.map((li) => li.local_media_ref!.replace('blob_', ''));
if (blobRefs.length > 0) {
const blobs = await db.blobs.where('id').anyOf(blobRefs).toArray();
for (const b of blobs) blobMap.set(b.id, b.data);
}
// Конвертировать в API-формат для UI
const items: Item[] = localItems.map((li) => {
const ref = li.local_media_ref?.replace('blob_', '');
return localItemToApi(li, ref ? blobMap.get(ref) : undefined);
});
return { items, total: items.length, limit: 500, offset: 0 };
},
staleTime: 5_000,
});
// Фоновое обновление с сервера (только когда online)
useEffect(() => {
if (!online) return;
let cancelled = false;
(async () => {
try {
const serverData = await itemsApi.list({ ...params, limit: 500 });
if (cancelled) return;
await itemsRepository.bulkUpsertFromServer(serverData.items);
queryClient.invalidateQueries({ queryKey: ['items'] });
} catch {
// Сеть недоступна — локальные данные валидны
}
})();
return () => { cancelled = true; };
}, [online, JSON.stringify(params)]);
return query;
}
/**
* Одна вещь по ID из IndexedDB, fallback на network.
*/
export function useLocalItem(id: string | undefined) {
const queryClient = useQueryClient();
const online = useNetworkStatus();
const query = useQuery({
queryKey: ['items', id],
networkMode: 'always',
queryFn: async () => {
const local = await itemsRepository.getById(id!);
if (local) {
let blobUrl: string | undefined;
if (!local.primary_media_url && local.local_media_ref) {
const ref = local.local_media_ref.replace('blob_', '');
const blob = await db.blobs.get(ref);
blobUrl = blob?.data;
}
return localItemToApi(local, blobUrl);
}
// Fallback: загрузить с сервера если нет в IndexedDB
if (online) {
const serverItem = await itemsApi.get(id!);
await itemsRepository.upsertFromServer(serverItem);
return serverItem;
}
return null;
},
enabled: Boolean(id),
});
// Фоновое обновление
useEffect(() => {
if (!id || !online) return;
let cancelled = false;
(async () => {
try {
const serverItem = await itemsApi.get(id);
if (cancelled) return;
await itemsRepository.upsertFromServer(serverItem);
queryClient.invalidateQueries({ queryKey: ['items', id] });
} catch { /* offline fallback */ }
})();
return () => { cancelled = true; };
}, [id, online, queryClient]);
return query;
}
2. useLocalOutfits / useLocalOutfit¶
Файл: frontend/src/offline/hooks/useLocalOutfits.ts
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect } from 'react';
import { outfitsRepository } from '../repositories/outfitsRepository';
import { outfitsApi } from '../../api/outfits';
import { useNetworkStatus } from '../network/useNetworkStatus';
import type { Outfit, OutfitListParams } from '../../types/api';
/** Список образов из IndexedDB + background refresh */
export function useLocalOutfits(params?: OutfitListParams) {
const queryClient = useQueryClient();
const online = useNetworkStatus();
const query = useQuery({
queryKey: ['outfits', params ?? {}],
networkMode: 'always',
queryFn: async () => {
const localOutfits = await outfitsRepository.getAll(params);
const outfits: Outfit[] = localOutfits.map((lo) => ({
id: lo.server_id ?? lo.id,
title: lo.title ?? undefined,
visibility: lo.visibility as Outfit['visibility'],
cover_media_id: lo.cover_media_id ?? undefined,
cover_media_url: lo.cover_media_url ?? undefined,
origin_type: lo.origin_type as Outfit['origin_type'],
is_external: lo.is_external,
is_saved: undefined,
outfit_items: [],
outfit_item_snapshots: [],
item_media_urls: lo.item_media_urls ?? [],
created_at: lo.created_at,
updated_at: lo.updated_at,
}));
return { outfits, total: outfits.length, limit: 500, offset: 0 };
},
staleTime: 5_000,
});
useEffect(() => {
if (!online) return;
let cancelled = false;
(async () => {
try {
const serverData = await outfitsApi.list({ ...params, limit: 500 });
if (cancelled) return;
await outfitsRepository.bulkUpsertFromServer(serverData.outfits);
queryClient.invalidateQueries({ queryKey: ['outfits'] });
} catch { /* offline — local data still valid */ }
})();
return () => { cancelled = true; };
}, [online, JSON.stringify(params)]);
return query;
}
/** Один образ по ID (с outfit_items) */
export function useLocalOutfit(id: string | undefined) {
const queryClient = useQueryClient();
const online = useNetworkStatus();
const query = useQuery({
queryKey: ['outfits', id],
networkMode: 'always',
queryFn: async () => {
const local = await outfitsRepository.getById(id!);
if (local) {
const outfitItems = await outfitsRepository.getOutfitItems(local.id);
return {
id: local.server_id ?? local.id,
title: local.title ?? undefined,
visibility: local.visibility as Outfit['visibility'],
cover_media_id: local.cover_media_id ?? undefined,
cover_media_url: local.cover_media_url ?? undefined,
origin_type: local.origin_type as Outfit['origin_type'],
is_external: local.is_external,
is_saved: undefined,
outfit_items: outfitItems.map((oi) => ({
item_id: oi.item_id,
slot_id: oi.slot_id,
layer_index: oi.layer_index,
})),
outfit_item_snapshots: [],
item_media_urls: local.item_media_urls ?? [],
created_at: local.created_at,
updated_at: local.updated_at,
} as Outfit;
}
if (online) {
const serverOutfit = await outfitsApi.get(id!);
await outfitsRepository.upsertFromServer(serverOutfit);
return serverOutfit;
}
return null;
},
enabled: Boolean(id),
});
useEffect(() => {
if (!id || !online) return;
let cancelled = false;
(async () => {
try {
const serverOutfit = await outfitsApi.get(id);
if (cancelled) return;
await outfitsRepository.upsertFromServer(serverOutfit);
queryClient.invalidateQueries({ queryKey: ['outfits', id] });
} catch { /* offline fallback */ }
})();
return () => { cancelled = true; };
}, [id, online, queryClient]);
return query;
}
3. useLocalReference¶
Файл: frontend/src/offline/hooks/useLocalReference.ts
Справочные данные с localStorage-кешем и offline fallback.
import { useQuery } from '@tanstack/react-query';
import { referenceApi } from '../../api/reference';
import { useNetworkStatus } from '../network/useNetworkStatus';
const STORAGE_PREFIX = 'plechiki_ref_';
function getFromStorage<T>(key: string): T | null {
try {
const raw = localStorage.getItem(STORAGE_PREFIX + key);
return raw ? JSON.parse(raw) : null;
} catch {
return null;
}
}
function saveToStorage(key: string, data: unknown) {
try {
localStorage.setItem(STORAGE_PREFIX + key, JSON.stringify(data));
} catch { /* quota exceeded */ }
}
/** Универсальный хук для справочников с offline fallback */
function useLocalReference<T>(key: string, fetchFn: () => Promise<T>) {
const online = useNetworkStatus();
const query = useQuery({
queryKey: ['reference', key],
queryFn: async () => {
const cached = getFromStorage<T>(key);
if (online) {
try {
const data = await fetchFn();
saveToStorage(key, data); // Обновить кеш
return data;
} catch {
if (cached) return cached; // Fallback на кеш при ошибке
throw new Error('No network and no cached data');
}
}
// Offline: вернуть кеш или пустой массив
if (cached) return cached;
return [] as unknown as T;
},
staleTime: 10 * 60 * 1000, // 10 минут
retry: (failureCount) => {
if (!online) return false;
if (getFromStorage(key) !== null) return false;
return failureCount < 3;
},
retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 5000),
});
const isOfflineEmpty = !online && !getFromStorage(key) && Array.isArray(query.data) && query.data.length === 0;
return { ...query, isOfflineEmpty };
}
// Экспортируемые хуки по типу справочника
export function useLocalColors() {
return useLocalReference('colors', referenceApi.colors);
}
export function useLocalCategories() {
return useLocalReference('categories', () => referenceApi.categories());
}
export function useLocalStyles() {
return useLocalReference('styles', () => referenceApi.styles());
}
export function useLocalSlots() {
return useLocalReference('slots', referenceApi.slots);
}
export function useLocalCollections() {
return useLocalReference('collections', referenceApi.collections);
}
4. usePendingCount¶
Файл: frontend/src/offline/hooks/usePendingCount.ts
Счётчик ожидающих операций для отображения badge в UI.
import { useEffect, useState } from 'react';
import { operationQueue } from '../sync/operationQueue';
import { syncEngine } from '../sync/syncEngine';
/** Возвращает количество pending операций (для badge) */
export function usePendingCount(): number {
const [count, setCount] = useState(0);
useEffect(() => {
const refresh = async () => {
const c = await operationQueue.getPendingCount();
setCount(c);
};
refresh();
// Подписка на изменения sync engine
const unsub = syncEngine.onChange(refresh);
return unsub;
}, []);
return count;
}
5. useLocalItemsByIds¶
Файл: frontend/src/offline/hooks/useLocalItemsByIds.ts
Batch-загрузка вещей по массиву ID (для SimilarItemsRail).
import { useQuery } from '@tanstack/react-query';
import { itemsRepository, localItemToApi } from '../repositories/itemsRepository';
import type { Item } from '../../types/api';
/**
* Загрузка вещей по массиву ID.
* Используется SimilarItemsRail для резолва результатов similarity search.
*/
export function useLocalItemsByIds(ids: string[]) {
return useQuery({
queryKey: ['items-by-ids', ids],
networkMode: 'always',
queryFn: async (): Promise<Item[]> => {
const items: Item[] = [];
for (const id of ids) {
const local = await itemsRepository.getById(id);
if (local && !local.deleted_locally) {
items.push(localItemToApi(local));
}
}
return items;
},
enabled: ids.length > 0,
staleTime: 30_000,
});
}
6. useLocalOutfitsByIds¶
Файл: frontend/src/offline/hooks/useLocalOutfitsByIds.ts
import { useQuery } from '@tanstack/react-query';
import { outfitsRepository } from '../repositories/outfitsRepository';
import type { Outfit } from '../../types/api';
/**
* Загрузка образов по массиву ID.
* Используется SimilarOutfitsGrid для резолва similarity results.
*/
export function useLocalOutfitsByIds(ids: string[]) {
return useQuery({
queryKey: ['outfits-by-ids', ids],
networkMode: 'always',
queryFn: async (): Promise<Outfit[]> => {
const outfits: Outfit[] = [];
for (const id of ids) {
const local = await outfitsRepository.getById(id);
if (local && !local.deleted_locally) {
outfits.push({
id: local.server_id ?? local.id,
title: local.title ?? undefined,
visibility: local.visibility as Outfit['visibility'],
cover_media_id: local.cover_media_id ?? undefined,
cover_media_url: local.cover_media_url ?? undefined,
origin_type: local.origin_type as Outfit['origin_type'],
is_external: local.is_external,
is_saved: undefined,
outfit_items: [],
outfit_item_snapshots: [],
item_media_urls: local.item_media_urls ?? [],
created_at: local.created_at,
updated_at: local.updated_at,
} as Outfit);
}
}
return outfits;
},
enabled: ids.length > 0,
staleTime: 30_000,
});
}
7. Network Status¶
Файл: frontend/src/offline/network/networkStatus.ts
type Listener = (online: boolean) => void;
class NetworkStatus {
private _online: boolean;
private _listeners = new Set<Listener>();
constructor() {
this._online = typeof navigator !== 'undefined' ? navigator.onLine : true;
if (typeof window !== 'undefined') {
window.addEventListener('online', () => this._update(true));
window.addEventListener('offline', () => this._update(false));
}
}
get online() { return this._online; }
subscribe(listener: Listener): () => void {
this._listeners.add(listener);
return () => this._listeners.delete(listener);
}
private _update(online: boolean) {
if (this._online === online) return;
this._online = online;
this._listeners.forEach((fn) => fn(online));
}
}
export const networkStatus = new NetworkStatus();
Файл: frontend/src/offline/network/useNetworkStatus.ts
import { useSyncExternalStore } from 'react';
import { networkStatus } from './networkStatus';
function subscribe(callback: () => void) {
return networkStatus.subscribe(() => callback());
}
function getSnapshot() {
return networkStatus.online;
}
/** React-хук: возвращает boolean online/offline */
export function useNetworkStatus(): boolean {
return useSyncExternalStore(subscribe, getSnapshot);
}