fix photo

This commit is contained in:
lekss361 2026-04-27 20:06:18 +03:00
parent 5056438fcf
commit c079ac2a77
5 changed files with 224 additions and 44 deletions

View file

@ -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,

View file

@ -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() {
</td>
<td style={td}>
<button
onClick={() => revokeMutation.mutate(t.task_id)}
onClick={() => cancelActive(t.task_id)}
disabled={revokeMutation.isPending}
style={dangerBtn}
title="SIGTERM прерывает запущенный скрейп мгновенно"
>
Отменить
🛑 Прервать
</button>
</td>
</tr>
@ -487,10 +520,12 @@ export default function ScrapeAdminPage() {
</td>
<td style={td}>
<button
onClick={() => revokeMutation.mutate(t.task_id)}
onClick={() => cancelReserved(t.task_id)}
disabled={revokeMutation.isPending}
style={dangerBtn}
title="Удалить из очереди — worker не запустит этот таск"
>
Отменить
Удалить из очереди
</button>
</td>
</tr>

View file

@ -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<string, string> = {
Спорт: "#0a7a3a",
Продукты: "#1d4ed8",
@ -23,11 +25,16 @@ interface Props {
export function ObjectInfraMap({ objId, centerLat, centerLon }: Props) {
const [maxDist, setMaxDist] = useState(2000);
const [activeCat, setActiveCat] = useState<string | null>(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<string, number> = {};
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",
}}
>
<div
@ -101,6 +110,7 @@ export function ObjectInfraMap({ objId, centerLat, centerLon }: Props) {
{centerLat && centerLon
? `ЖК: ${centerLat.toFixed(4)}, ${centerLon.toFixed(4)}`
: "координаты ЖК не заданы"}
{isFetching ? " · обновление…" : ""}
</div>
<table
style={{ width: "100%", borderCollapse: "collapse", fontSize: 12 }}
@ -123,7 +133,7 @@ export function ObjectInfraMap({ objId, centerLat, centerLon }: Props) {
</tr>
</thead>
<tbody>
{(data ?? []).slice(0, 50).map((p, i) => (
{(data ?? []).slice(0, shown).map((p, i) => (
<tr
key={`${p.poi_name}-${p.lat}-${p.lon}`}
style={{
@ -163,6 +173,30 @@ export function ObjectInfraMap({ objId, centerLat, centerLon }: Props) {
))}
</tbody>
</table>
{(data?.length ?? 0) > shown ? (
<div
style={{
padding: 10,
textAlign: "center",
background: "#f9fafb",
borderTop: "1px solid #eef0f3",
}}
>
<button
onClick={() => setShown((n) => n + PAGE_SIZE)}
style={{
padding: "4px 12px",
fontSize: 12,
border: "1px solid #d1d5db",
borderRadius: 12,
background: "#fff",
cursor: "pointer",
}}
>
Показать ещё {Math.min(PAGE_SIZE, (data?.length ?? 0) - shown)}
</button>
</div>
) : null}
</div>
)}
</div>

View file

@ -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<string | null>(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 <div style={{ color: "#5b6066" }}>Загрузка фото</div>;
}
const photos = data ?? [];
const photos = (data ?? []).filter((p) => p.photo_url);
if (photos.length === 0) {
return <div style={{ color: "#9ca3af" }}>Фото отсутствуют.</div>;
}
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) => (
<button
key={p.obj_file_id}
onClick={() => setActive(p.obj_file_id)}
@ -37,21 +60,22 @@ export function ObjectPhotoGallery({ objId }: { objId: number | string }) {
border: "1px solid #e6e8ec",
borderRadius: 6,
overflow: "hidden",
cursor: "pointer",
cursor: "zoom-in",
background: "#f3f4f6",
aspectRatio: "4/3",
position: "relative",
}}
>
{p.photo_url ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={p.photo_url}
alt={p.photo_name ?? "фото прогресса"}
loading="lazy"
style={{ width: "100%", height: "100%", objectFit: "cover" }}
/>
) : null}
<Image
src={p.photo_url!}
alt={p.photo_name ?? "фото прогресса"}
fill
sizes="160px"
quality={60}
style={{ objectFit: "cover" }}
loading="lazy"
unoptimized={false}
/>
<div
style={{
position: "absolute",
@ -62,6 +86,7 @@ export function ObjectPhotoGallery({ objId }: { objId: number | string }) {
color: "#fff",
fontSize: 11,
padding: "3px 6px",
pointerEvents: "none",
}}
>
{p.period_dt?.slice(0, 7) ?? "—"}
@ -70,13 +95,31 @@ export function ObjectPhotoGallery({ objId }: { objId: number | string }) {
))}
</div>
{photos.length > INITIAL_LIMIT && !showAll ? (
<div style={{ textAlign: "center", marginTop: 12 }}>
<button
onClick={() => setShowAll(true)}
style={{
padding: "6px 14px",
fontSize: 13,
border: "1px solid #d1d5db",
borderRadius: 16,
background: "#fff",
cursor: "pointer",
}}
>
Показать ещё {photos.length - INITIAL_LIMIT}
</button>
</div>
) : null}
{activePhoto ? (
<div
onClick={() => setActive(null)}
style={{
position: "fixed",
inset: 0,
background: "rgba(0,0,0,0.85)",
background: "rgba(0,0,0,0.9)",
display: "flex",
alignItems: "center",
justifyContent: "center",
@ -84,18 +127,58 @@ export function ObjectPhotoGallery({ objId }: { objId: number | string }) {
cursor: "zoom-out",
}}
>
{activePhoto.photo_url ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={activePhoto.photo_url}
alt={activePhoto.photo_name ?? ""}
style={{
maxWidth: "92vw",
maxHeight: "92vh",
objectFit: "contain",
}}
/>
) : null}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={activePhoto.photo_url!}
alt={activePhoto.photo_name ?? ""}
style={{
maxWidth: "92vw",
maxHeight: "92vh",
objectFit: "contain",
}}
/>
<button
onClick={(e) => {
e.stopPropagation();
move(-1);
}}
aria-label="Предыдущее"
style={navBtnStyle("left")}
>
</button>
<button
onClick={(e) => {
e.stopPropagation();
move(1);
}}
aria-label="Следующее"
style={navBtnStyle("right")}
>
</button>
<button
onClick={(e) => {
e.stopPropagation();
setActive(null);
}}
aria-label="Закрыть"
style={{
position: "absolute",
top: 16,
right: 16,
width: 36,
height: 36,
borderRadius: 18,
border: "none",
background: "rgba(255,255,255,0.15)",
color: "#fff",
fontSize: 20,
cursor: "pointer",
}}
>
×
</button>
<div
style={{
position: "absolute",
@ -105,8 +188,10 @@ export function ObjectPhotoGallery({ objId }: { objId: number | string }) {
color: "#fff",
textAlign: "center",
fontSize: 13,
pointerEvents: "none",
}}
>
{activeIdx + 1} / {photos.length} ·{" "}
{activePhoto.period_dt?.slice(0, 10)} · {activePhoto.photo_name}
</div>
</div>
@ -114,3 +199,21 @@ export function ObjectPhotoGallery({ objId }: { objId: number | string }) {
</>
);
}
function navBtnStyle(side: "left" | "right"): React.CSSProperties {
return {
position: "absolute",
top: "50%",
[side]: 16,
transform: "translateY(-50%)",
width: 44,
height: 44,
borderRadius: 22,
border: "none",
background: "rgba(255,255,255,0.15)",
color: "#fff",
fontSize: 28,
lineHeight: 1,
cursor: "pointer",
};
}

File diff suppressed because one or more lines are too long