diff --git a/frontend/next.config.ts b/frontend/next.config.ts
index 4344975f..a9dac3b2 100644
--- a/frontend/next.config.ts
+++ b/frontend/next.config.ts
@@ -3,6 +3,14 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = {
reactStrictMode: true,
output: "standalone",
+ images: {
+ remotePatterns: [
+ { protocol: "https", hostname: "xn--80az8a.xn--d1aqf.xn--p1ai" },
+ { protocol: "https", hostname: "наш.дом.рф" },
+ ],
+ formats: ["image/webp"],
+ minimumCacheTTL: 60 * 60 * 24 * 7,
+ },
// In dev (no Caddy) Next.js itself proxies /api/* and /health to the backend
// so the browser can use same-origin relative URLs.
// In prod Caddy intercepts these paths before they reach Next.js,
diff --git a/frontend/src/app/admin/scrape/page.tsx b/frontend/src/app/admin/scrape/page.tsx
index 9f4ab0b8..045c537c 100644
--- a/frontend/src/app/admin/scrape/page.tsx
+++ b/frontend/src/app/admin/scrape/page.tsx
@@ -118,16 +118,47 @@ export default function ScrapeAdminPage() {
});
const revokeMutation = useMutation({
- mutationFn: (task_id: string) =>
- apiFetch<{ revoked: boolean }>("/api/v1/admin/scrape/revoke", {
- method: "POST",
- headers: { "X-Admin-Token": token },
- body: JSON.stringify({ task_id, terminate: false }),
- }),
- onSuccess: () =>
- queryClient.invalidateQueries({ queryKey: ["scrape-queue"] }),
+ mutationFn: ({
+ task_id,
+ terminate,
+ }: {
+ task_id: string;
+ terminate: boolean;
+ }) =>
+ apiFetch<{ revoked: boolean; terminate: boolean }>(
+ "/api/v1/admin/scrape/revoke",
+ {
+ method: "POST",
+ headers: { "X-Admin-Token": token },
+ body: JSON.stringify({ task_id, terminate }),
+ },
+ ),
+ onSuccess: (data) => {
+ console.log("[scrape] revoked:", data);
+ queryClient.invalidateQueries({ queryKey: ["scrape-queue"] });
+ queryClient.invalidateQueries({ queryKey: ["scrape-runs"] });
+ },
+ onError: (err) => {
+ console.error("[scrape] revoke failed:", err);
+ alert(`Не удалось отменить: ${String(err)}`);
+ },
});
+ const cancelActive = (taskId: string) => {
+ if (
+ !confirm(
+ "Прервать выполняющийся таск (SIGTERM)? Скрапер остановится мгновенно.",
+ )
+ ) {
+ return;
+ }
+ revokeMutation.mutate({ task_id: taskId, terminate: true });
+ };
+
+ const cancelReserved = (taskId: string) => {
+ revokeMutation.mutate({ task_id: taskId, terminate: false });
+ };
+
const failures = useQuery({
queryKey: ["scrape-failures", failuresRunId, token],
queryFn: () => {
@@ -420,10 +451,12 @@ export default function ScrapeAdminPage() {
|
@@ -487,10 +520,12 @@ export default function ScrapeAdminPage() {
|
diff --git a/frontend/src/components/analytics/ObjectInfraMap.tsx b/frontend/src/components/analytics/ObjectInfraMap.tsx
index dbb01bc7..7beca070 100644
--- a/frontend/src/components/analytics/ObjectInfraMap.tsx
+++ b/frontend/src/components/analytics/ObjectInfraMap.tsx
@@ -1,9 +1,11 @@
"use client";
-import { useMemo, useState } from "react";
+import { useEffect, useMemo, useState } from "react";
import { useObjectInfrastructure } from "@/lib/analytics-api";
+const PAGE_SIZE = 50;
+
const CATEGORY_COLORS: Record = {
Спорт: "#0a7a3a",
Продукты: "#1d4ed8",
@@ -23,11 +25,16 @@ interface Props {
export function ObjectInfraMap({ objId, centerLat, centerLon }: Props) {
const [maxDist, setMaxDist] = useState(2000);
const [activeCat, setActiveCat] = useState(null);
- const { data, isLoading } = useObjectInfrastructure(objId, {
+ const [shown, setShown] = useState(PAGE_SIZE);
+ const { data, isLoading, isFetching } = useObjectInfrastructure(objId, {
category: activeCat ?? undefined,
max_distance: maxDist,
});
+ useEffect(() => {
+ setShown(PAGE_SIZE);
+ }, [activeCat, maxDist]);
+
const stats = useMemo(() => {
const byCat: Record = {};
for (const p of data ?? []) {
@@ -87,6 +94,8 @@ export function ObjectInfraMap({ objId, centerLat, centerLon }: Props) {
borderRadius: 8,
overflow: "hidden",
position: "relative",
+ opacity: isFetching ? 0.7 : 1,
+ transition: "opacity 120ms",
}}
>
- {(data ?? []).slice(0, 50).map((p, i) => (
+ {(data ?? []).slice(0, shown).map((p, i) => (
+ {(data?.length ?? 0) > shown ? (
+
+
+
+ ) : null}
)}
diff --git a/frontend/src/components/analytics/ObjectPhotoGallery.tsx b/frontend/src/components/analytics/ObjectPhotoGallery.tsx
index 7d058349..82b7c8f9 100644
--- a/frontend/src/components/analytics/ObjectPhotoGallery.tsx
+++ b/frontend/src/components/analytics/ObjectPhotoGallery.tsx
@@ -1,23 +1,46 @@
"use client";
-import { useState } from "react";
+import Image from "next/image";
+import { useEffect, useState } from "react";
import { useObjectPhotos } from "@/lib/analytics-api";
+const INITIAL_LIMIT = 24;
+
export function ObjectPhotoGallery({ objId }: { objId: number | string }) {
- const { data, isLoading } = useObjectPhotos(objId, 60);
+ const { data, isLoading } = useObjectPhotos(objId, 200);
const [active, setActive] = useState(null);
+ const [showAll, setShowAll] = useState(false);
+
+ useEffect(() => {
+ if (!active) return;
+ const onKey = (e: KeyboardEvent) => {
+ if (e.key === "Escape") setActive(null);
+ };
+ window.addEventListener("keydown", onKey);
+ return () => window.removeEventListener("keydown", onKey);
+ }, [active]);
if (isLoading) {
return Загрузка фото…
;
}
- const photos = data ?? [];
+ const photos = (data ?? []).filter((p) => p.photo_url);
if (photos.length === 0) {
return Фото отсутствуют.
;
}
- const activePhoto = photos.find((p) => p.obj_file_id === active);
+ const visible = showAll ? photos : photos.slice(0, INITIAL_LIMIT);
+ const activeIdx = active
+ ? photos.findIndex((p) => p.obj_file_id === active)
+ : -1;
+ const activePhoto = activeIdx >= 0 ? photos[activeIdx] : null;
+
+ const move = (delta: number) => {
+ if (activeIdx < 0) return;
+ const next = (activeIdx + delta + photos.length) % photos.length;
+ setActive(photos[next].obj_file_id);
+ };
return (
<>
@@ -28,7 +51,7 @@ export function ObjectPhotoGallery({ objId }: { objId: number | string }) {
gap: 8,
}}
>
- {photos.map((p) => (
+ {visible.map((p) => (