Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions app/(protected)/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string | string[] | undefined>;

Expand Down Expand Up @@ -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 (
<div className="stack">
<DashboardAutoRefresh hasActiveRequests={hasActiveRequests} />
<section className="card stack">
<h1>ШОПОНОВОСТЯМ</h1>
{newsApiKeyStatus.hasNewsApiKey ? (
Expand Down
24 changes: 24 additions & 0 deletions components/dashboard/dashboard-auto-refresh.tsx
Original file line number Diff line number Diff line change
@@ -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;
}
55 changes: 46 additions & 9 deletions components/dashboard/search-request-form.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -26,23 +27,48 @@ type ApiError = {

type ApiResponse<T> = ApiSuccess<T> | 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<number | null>(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<HTMLFormElement>) {
event.preventDefault();

setIsSubmitting(true);
setError("");
setSuccess("");

const normalizedKeyword = keyword.trim();

if (!normalizedKeyword) {
setError("Keyword is required");
setIsSubmitting(false);
Expand Down Expand Up @@ -73,16 +99,20 @@ export function SearchRequestForm() {
}),
});

const payload = (await response.json().catch(() => null)) as ApiResponse<unknown> | null;
const payload = (await response.json().catch(() => null)) as ApiResponse<SearchRequest> | null;

if (!response.ok || !payload || payload.ok === false) {
const message = payload && payload.ok === false ? payload.error.message : "Failed to create search request";
setError(message);
return;
}

const createdRequest = payload.data;

setKeyword("");
setSuccess("Search request queued");
setSuccess("Запрос создан. Следим за статусом...");
setTrackingRequestId(createdRequest.id);

router.refresh();
} catch {
setError("Network error. Please try again.");
Expand All @@ -93,7 +123,7 @@ export function SearchRequestForm() {

return (
<form className="card stack" onSubmit={onSubmit}>
<h2>Create search request</h2>
<h2>Создать запрос</h2>

<div className="field">
<label htmlFor="keyword-input">Ключевое слово</label>
Expand All @@ -108,7 +138,7 @@ export function SearchRequestForm() {
</div>

<div className="field">
<label htmlFor="language-input">Язык </label>
<label htmlFor="language-input">Язык</label>
<input
id="language-input"
maxLength={2}
Expand All @@ -134,15 +164,15 @@ export function SearchRequestForm() {
</div>

<div className="field">
<label htmlFor="page-size-input">Размер новостей на страницу (при плохом интернете лучше 20)</label>
<label htmlFor="page-size-input">Размер новостей на страницу, при плохом интернете лучше 20</label>
<input
id="page-size-input"
max={PAGE_SIZE_MAX}
min={PAGE_SIZE_MIN}
onChange={(event) => setPageSize(Number(event.target.value))}
required
type="number"
value={20}
value={pageSize}
/>
</div>

Expand All @@ -154,6 +184,13 @@ export function SearchRequestForm() {

{error ? <p className="alert">{error}</p> : null}
{success ? <p className="success-message">{success}</p> : null}

{trackingRequestId ? (
<SearchRequestStatusTracker
searchRequestId={trackingRequestId}
onDone={handleTrackingDone}
/>
) : null}
</form>
);
}
}
115 changes: 115 additions & 0 deletions components/dashboard/search-request-status-tracker.tsx
Original file line number Diff line number Diff line change
@@ -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<T> = {
ok: true;
data: T;
requestId: string;
};

type ApiError = {
ok: false;
error: {
code: string;
message: string;
details?: unknown;
};
requestId: string;
};

type ApiResponse<T> = ApiSuccess<T> | 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<number | null>(null);

useEffect(() => {
let isCancelled = false;
let timeoutId: ReturnType<typeof setTimeout> | 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<SearchRequestDetails>
| 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 (
<p className="success-message">
Готово! Новости загружены{loadedRows !== null ? `: ${loadedRows}` : ""}.
</p>
);
}

if (status === "failed") {
return <p className="alert">Запрос завершился ошибкой{error ? `: ${error}` : "."}</p>;
}

return (
<p className="success-message">
Запрос обрабатывается. Текущий статус: {status}
{error ? ` (${error})` : ""}
</p>
);
}
Loading