diff --git a/app/(protected)/dashboard/page.tsx b/app/(protected)/dashboard/page.tsx index cbaec7e..a03a4dc 100644 --- a/app/(protected)/dashboard/page.tsx +++ b/app/(protected)/dashboard/page.tsx @@ -8,6 +8,7 @@ import { getUserNewsFilterOptions, listNewsForUser } from "@/lib/news"; import { parseNewsFiltersFromSearchParams, type ParsedNewsFilters } from "@/lib/news-filters"; import { listSearchRequestsForUser } from "@/lib/searches"; import { getCurrentAppUserId } from "@/lib/users"; +import { DashboardAutoRefresh } from "@/components/dashboard/dashboard-auto-refresh"; type DashboardSearchParams = Record; @@ -42,12 +43,15 @@ export default async function DashboardPage({ getUserNewsFilterOptions(appUserId), getNewsApiKeyStatusForUser(appUserId) ]); + const hasActiveRequests = searches.some( + (item) => item.status === "queued" || item.status === "running",); const hasPrevPage = filters.page > 1; const hasNextPage = filters.page < newsData.totalPages; return (
+

ШОПОНОВОСТЯМ

{newsApiKeyStatus.hasNewsApiKey ? ( diff --git a/components/dashboard/dashboard-auto-refresh.tsx b/components/dashboard/dashboard-auto-refresh.tsx new file mode 100644 index 0000000..09b94cb --- /dev/null +++ b/components/dashboard/dashboard-auto-refresh.tsx @@ -0,0 +1,24 @@ +"use client"; + +import { useEffect } from "react"; +import { useRouter } from "next/navigation"; + +type Props = { + hasActiveRequests: boolean; +}; + +export function DashboardAutoRefresh({ hasActiveRequests }: Props) { + const router = useRouter(); + + useEffect(() => { + if (!hasActiveRequests) return; + + const intervalId = setInterval(() => { + router.refresh(); + }, 3000); + + return () => clearInterval(intervalId); + }, [hasActiveRequests, router]); + + return null; +} \ No newline at end of file diff --git a/components/dashboard/search-request-form.tsx b/components/dashboard/search-request-form.tsx index 0da88d7..93b50d3 100644 --- a/components/dashboard/search-request-form.tsx +++ b/components/dashboard/search-request-form.tsx @@ -1,7 +1,8 @@ "use client"; -import { type FormEvent, useState } from "react"; +import { useCallback, type FormEvent, useState } from "react"; import { useRouter } from "next/navigation"; +import { SearchRequestStatusTracker } from "@/components/dashboard/search-request-status-tracker"; const LIMIT_COUNT_MIN = 1; const LIMIT_COUNT_MAX = 500; @@ -26,23 +27,48 @@ type ApiError = { type ApiResponse = ApiSuccess | ApiError; +type SearchRequest = { + id: number; + user_id: number; + keyword: string; + language: string; + limit_count: number; + page_size: number; + status: string; + error_text: string | null; + created_at: string; + started_at: string | null; + finished_at: string | null; +}; + export function SearchRequestForm() { const router = useRouter(); + const [keyword, setKeyword] = useState(""); const [language, setLanguage] = useState("ru"); const [limitCount, setLimitCount] = useState(20); - const [pageSize, setPageSize] = useState(50); + const [pageSize, setPageSize] = useState(20); + + const [trackingRequestId, setTrackingRequestId] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); const [error, setError] = useState(""); const [success, setSuccess] = useState(""); + const handleTrackingDone = useCallback(() => { + setTrackingRequestId(null); + router.refresh(); + }, [router]); + async function onSubmit(event: FormEvent) { event.preventDefault(); + setIsSubmitting(true); setError(""); setSuccess(""); const normalizedKeyword = keyword.trim(); + if (!normalizedKeyword) { setError("Keyword is required"); setIsSubmitting(false); @@ -73,7 +99,7 @@ export function SearchRequestForm() { }), }); - const payload = (await response.json().catch(() => null)) as ApiResponse | null; + const payload = (await response.json().catch(() => null)) as ApiResponse | null; if (!response.ok || !payload || payload.ok === false) { const message = payload && payload.ok === false ? payload.error.message : "Failed to create search request"; @@ -81,8 +107,12 @@ export function SearchRequestForm() { return; } + const createdRequest = payload.data; + setKeyword(""); - setSuccess("Search request queued"); + setSuccess("Запрос создан. Следим за статусом..."); + setTrackingRequestId(createdRequest.id); + router.refresh(); } catch { setError("Network error. Please try again."); @@ -93,7 +123,7 @@ export function SearchRequestForm() { return (
-

Create search request

+

Создать запрос

@@ -108,7 +138,7 @@ export function SearchRequestForm() {
- +
- + setPageSize(Number(event.target.value))} required type="number" - value={20} + value={pageSize} />
@@ -154,6 +184,13 @@ export function SearchRequestForm() { {error ?

{error}

: null} {success ?

{success}

: null} + + {trackingRequestId ? ( + + ) : null} ); -} +} \ No newline at end of file diff --git a/components/dashboard/search-request-status-tracker.tsx b/components/dashboard/search-request-status-tracker.tsx new file mode 100644 index 0000000..960813f --- /dev/null +++ b/components/dashboard/search-request-status-tracker.tsx @@ -0,0 +1,115 @@ +"use client"; + +import { useEffect, useState } from "react"; + +type SearchRequestDetails = { + id: number; + status: "queued" | "running" | "success" | "failed"; + keyword: string; + error_text: string | null; + loaded_rows?: number; +}; + +type ApiSuccess = { + ok: true; + data: T; + requestId: string; +}; + +type ApiError = { + ok: false; + error: { + code: string; + message: string; + details?: unknown; + }; + requestId: string; +}; + +type ApiResponse = ApiSuccess | ApiError; + +type Props = { + searchRequestId: number; + onDone: () => void; +}; + +export function SearchRequestStatusTracker({ searchRequestId, onDone }: Props) { + const [status, setStatus] = useState<"queued" | "running" | "success" | "failed">("queued"); + const [error, setError] = useState(""); + const [loadedRows, setLoadedRows] = useState(null); + + useEffect(() => { + let isCancelled = false; + let timeoutId: ReturnType | null = null; + + async function poll() { + try { + const response = await fetch(`/api/searches/${searchRequestId}`, { + method: "GET", + cache: "no-store", + }); + + const payload = (await response.json().catch(() => null)) as + | ApiResponse + | null; + + if (isCancelled) return; + + if (!response.ok || !payload || payload.ok === false) { + const message = + payload && payload.ok === false + ? payload.error.message + : "Не удалось получить статус запроса"; + + setError(message); + timeoutId = setTimeout(poll, 3000); + return; + } + + const request = payload.data; + + setStatus(request.status); + setError(request.error_text ?? ""); + setLoadedRows(typeof request.loaded_rows === "number" ? request.loaded_rows : null); + + if (request.status === "success" || request.status === "failed") { + onDone(); + return; + } + + timeoutId = setTimeout(poll, 2000); + } catch { + if (!isCancelled) { + setError("Ошибка сети при проверке статуса"); + timeoutId = setTimeout(poll, 3000); + } + } + } + + poll(); + + return () => { + isCancelled = true; + if (timeoutId) clearTimeout(timeoutId); + }; + }, [searchRequestId, onDone]); + + if (status === "success") { + return ( +

+ Готово! Новости загружены{loadedRows !== null ? `: ${loadedRows}` : ""}. +

+ ); + } + + if (status === "failed") { + return

Запрос завершился ошибкой{error ? `: ${error}` : "."}

; + } + + return ( +

+ Запрос обрабатывается. Текущий статус: {status} + {error ? ` (${error})` : ""} +

+ ); +} \ No newline at end of file