274 lines
8.5 KiB
TypeScript
274 lines
8.5 KiB
TypeScript
import { useState, useCallback, useRef, useEffect } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import pb from '../lib/pocketbase';
|
|
import { apiUrl, authHeaders } from '../lib/api';
|
|
import { trackEvent } from '../lib/analytics';
|
|
|
|
export interface SavedSearch {
|
|
id: string;
|
|
name: string;
|
|
params: string;
|
|
screenshotUrl: string;
|
|
notes: string;
|
|
created: string;
|
|
}
|
|
|
|
// Exponential backoff: 2s, 3s, 4s, 6s, 8s, 12s, ... capped at 15s.
|
|
// Caps total wait under a minute while staying responsive for fast jobs.
|
|
const POLL_BASE_MS = 2000;
|
|
const POLL_MAX_MS = 15000;
|
|
const POLL_BACKOFF = 1.5;
|
|
const MAX_POLL_ATTEMPTS = 8;
|
|
|
|
function nextPollDelay(attempt: number): number {
|
|
return Math.min(POLL_MAX_MS, Math.round(POLL_BASE_MS * Math.pow(POLL_BACKOFF, attempt)));
|
|
}
|
|
|
|
export function useSavedSearches(userId: string | null) {
|
|
const { t } = useTranslation();
|
|
const [searches, setSearches] = useState<SavedSearch[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [saving, setSaving] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const pollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const pollAttemptsRef = useRef(0);
|
|
const pollInFlightRef = useRef(false);
|
|
const isMountedRef = useRef(true);
|
|
const userIdRef = useRef(userId);
|
|
userIdRef.current = userId;
|
|
|
|
const stopPolling = useCallback(() => {
|
|
if (pollTimerRef.current) {
|
|
clearTimeout(pollTimerRef.current);
|
|
pollTimerRef.current = null;
|
|
}
|
|
pollAttemptsRef.current = 0;
|
|
}, []);
|
|
|
|
// Clean up polling on unmount or userId change
|
|
useEffect(() => stopPolling, [userId, stopPolling]);
|
|
|
|
// Mark the hook as unmounted so late-arriving async work doesn't touch state
|
|
useEffect(() => {
|
|
isMountedRef.current = true;
|
|
return () => {
|
|
isMountedRef.current = false;
|
|
stopPolling();
|
|
};
|
|
}, [stopPolling]);
|
|
|
|
const fetchRecords = useCallback(async (uid: string): Promise<SavedSearch[]> => {
|
|
const records = await pb.collection('saved_searches').getFullList({
|
|
sort: '-created',
|
|
filter: `user = "${uid}"`,
|
|
});
|
|
return records.map((r) => ({
|
|
id: r.id,
|
|
name: (r as Record<string, unknown>).name as string,
|
|
params: (r as Record<string, unknown>).params as string,
|
|
screenshotUrl: (r as Record<string, unknown>).screenshot
|
|
? pb.files.getURL(r, (r as Record<string, unknown>).screenshot as string)
|
|
: '',
|
|
notes: ((r as Record<string, unknown>).notes as string) || '',
|
|
created: r.created,
|
|
}));
|
|
}, []);
|
|
|
|
const startPolling = useCallback(() => {
|
|
if (pollTimerRef.current) return;
|
|
pollAttemptsRef.current = 0;
|
|
pollInFlightRef.current = false;
|
|
|
|
const scheduleNext = () => {
|
|
if (!isMountedRef.current) return;
|
|
const delay = nextPollDelay(pollAttemptsRef.current);
|
|
pollTimerRef.current = setTimeout(tick, delay);
|
|
};
|
|
|
|
const tick = async () => {
|
|
pollTimerRef.current = null;
|
|
if (pollInFlightRef.current) {
|
|
scheduleNext();
|
|
return;
|
|
}
|
|
const uid = userIdRef.current;
|
|
if (!uid) return;
|
|
pollAttemptsRef.current++;
|
|
if (pollAttemptsRef.current > MAX_POLL_ATTEMPTS) return;
|
|
pollInFlightRef.current = true;
|
|
try {
|
|
const mapped = await fetchRecords(uid);
|
|
if (!isMountedRef.current) return;
|
|
setSearches(mapped);
|
|
if (!mapped.some((s) => !s.screenshotUrl)) return;
|
|
scheduleNext();
|
|
} catch {
|
|
// Silent — background poll errors don't surface to UI; keep trying.
|
|
if (isMountedRef.current) scheduleNext();
|
|
} finally {
|
|
pollInFlightRef.current = false;
|
|
}
|
|
};
|
|
|
|
scheduleNext();
|
|
}, [fetchRecords]);
|
|
|
|
const fetchSearches = useCallback(async () => {
|
|
if (!userId) return;
|
|
setLoading(true);
|
|
setError(null);
|
|
try {
|
|
const mapped = await fetchRecords(userId);
|
|
setSearches(mapped);
|
|
|
|
// Poll for missing screenshots so they appear without a page refresh
|
|
if (mapped.some((s) => !s.screenshotUrl)) {
|
|
startPolling();
|
|
} else {
|
|
stopPolling();
|
|
}
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : t('savedPage.loadFailed'));
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [userId, fetchRecords, startPolling, stopPolling, t]);
|
|
|
|
const saveSearch = useCallback(
|
|
async (name: string, paramsOverride?: string) => {
|
|
if (!userId) return;
|
|
setSaving(true);
|
|
setError(null);
|
|
try {
|
|
const params = paramsOverride ?? window.location.search.replace(/^\?/, '');
|
|
|
|
// Create record immediately without screenshot
|
|
const formData = new FormData();
|
|
formData.append('user', userId);
|
|
formData.append('name', name);
|
|
formData.append('params', params);
|
|
|
|
const record = await pb.collection('saved_searches').create(formData);
|
|
trackEvent('Search Save');
|
|
await fetchSearches();
|
|
|
|
// Capture screenshot in background and attach it to the record
|
|
const screenshotParams = new URLSearchParams(params);
|
|
const screenshotUrl = apiUrl('screenshot', screenshotParams);
|
|
fetch(screenshotUrl, authHeaders())
|
|
.then((res) => {
|
|
if (!res.ok) throw new Error(`Screenshot ${res.status}`);
|
|
return res.blob();
|
|
})
|
|
.then((blob) => {
|
|
const patch = new FormData();
|
|
patch.append('screenshot', blob, 'screenshot.jpg');
|
|
return pb.collection('saved_searches').update(record.id, patch);
|
|
})
|
|
.then(() => fetchSearches())
|
|
.catch((err) => {
|
|
console.warn('Background screenshot failed:', err);
|
|
});
|
|
} catch (err) {
|
|
const msg = err instanceof Error ? err.message : t('savedPage.saveFailed');
|
|
setError(msg);
|
|
throw err;
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
},
|
|
[userId, fetchSearches, t]
|
|
);
|
|
|
|
const deleteSearch = useCallback(
|
|
async (id: string) => {
|
|
setError(null);
|
|
try {
|
|
await pb.collection('saved_searches').delete(id);
|
|
trackEvent('Search Delete');
|
|
setSearches((prev) => prev.filter((s) => s.id !== id));
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : t('savedPage.deleteFailed'));
|
|
}
|
|
},
|
|
[t]
|
|
);
|
|
|
|
const updateSearchNotes = useCallback(
|
|
async (id: string, notes: string) => {
|
|
try {
|
|
await pb.collection('saved_searches').update(id, { notes });
|
|
setSearches((prev) => prev.map((s) => (s.id === id ? { ...s, notes } : s)));
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : t('savedPage.updateNotesFailed'));
|
|
}
|
|
},
|
|
[t]
|
|
);
|
|
|
|
const updateSearchName = useCallback(
|
|
async (id: string, name: string) => {
|
|
try {
|
|
await pb.collection('saved_searches').update(id, { name });
|
|
setSearches((prev) => prev.map((s) => (s.id === id ? { ...s, name } : s)));
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : t('savedPage.updateNameFailed'));
|
|
}
|
|
},
|
|
[t]
|
|
);
|
|
|
|
const updateSearchParams = useCallback(
|
|
async (id: string, params: string) => {
|
|
if (!userId) return;
|
|
setSaving(true);
|
|
setError(null);
|
|
try {
|
|
const record = await pb.collection('saved_searches').update(id, { params });
|
|
trackEvent('Search Update');
|
|
setSearches((prev) =>
|
|
prev.map((s) => (s.id === id ? { ...s, params, screenshotUrl: '' } : s))
|
|
);
|
|
|
|
// Refresh screenshot in the background
|
|
const screenshotParams = new URLSearchParams(params);
|
|
const screenshotUrl = apiUrl('screenshot', screenshotParams);
|
|
fetch(screenshotUrl, authHeaders())
|
|
.then((res) => {
|
|
if (!res.ok) throw new Error(`Screenshot ${res.status}`);
|
|
return res.blob();
|
|
})
|
|
.then((blob) => {
|
|
const patch = new FormData();
|
|
patch.append('screenshot', blob, 'screenshot.jpg');
|
|
return pb.collection('saved_searches').update(record.id, patch);
|
|
})
|
|
.then(() => fetchSearches())
|
|
.catch((err) => {
|
|
console.warn('Background screenshot failed:', err);
|
|
});
|
|
} catch (err) {
|
|
const msg = err instanceof Error ? err.message : t('savedPage.updateFailed');
|
|
setError(msg);
|
|
throw err;
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
},
|
|
[userId, fetchSearches, t]
|
|
);
|
|
|
|
return {
|
|
searches,
|
|
loading,
|
|
saving,
|
|
error,
|
|
fetchSearches,
|
|
saveSearch,
|
|
deleteSearch,
|
|
updateSearchNotes,
|
|
updateSearchName,
|
|
updateSearchParams,
|
|
};
|
|
}
|