Перейти к содержанию

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);
}