fix photo
This commit is contained in:
parent
5056438fcf
commit
c079ac2a77
5 changed files with 224 additions and 44 deletions
|
|
@ -3,6 +3,14 @@ import type { NextConfig } from "next";
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
output: "standalone",
|
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
|
// In dev (no Caddy) Next.js itself proxies /api/* and /health to the backend
|
||||||
// so the browser can use same-origin relative URLs.
|
// so the browser can use same-origin relative URLs.
|
||||||
// In prod Caddy intercepts these paths before they reach Next.js,
|
// In prod Caddy intercepts these paths before they reach Next.js,
|
||||||
|
|
|
||||||
|
|
@ -118,16 +118,47 @@ export default function ScrapeAdminPage() {
|
||||||
});
|
});
|
||||||
|
|
||||||
const revokeMutation = useMutation({
|
const revokeMutation = useMutation({
|
||||||
mutationFn: (task_id: string) =>
|
mutationFn: ({
|
||||||
apiFetch<{ revoked: boolean }>("/api/v1/admin/scrape/revoke", {
|
task_id,
|
||||||
method: "POST",
|
terminate,
|
||||||
headers: { "X-Admin-Token": token },
|
}: {
|
||||||
body: JSON.stringify({ task_id, terminate: false }),
|
task_id: string;
|
||||||
}),
|
terminate: boolean;
|
||||||
onSuccess: () =>
|
}) =>
|
||||||
queryClient.invalidateQueries({ queryKey: ["scrape-queue"] }),
|
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({
|
const failures = useQuery({
|
||||||
queryKey: ["scrape-failures", failuresRunId, token],
|
queryKey: ["scrape-failures", failuresRunId, token],
|
||||||
queryFn: () => {
|
queryFn: () => {
|
||||||
|
|
@ -420,10 +451,12 @@ export default function ScrapeAdminPage() {
|
||||||
</td>
|
</td>
|
||||||
<td style={td}>
|
<td style={td}>
|
||||||
<button
|
<button
|
||||||
onClick={() => revokeMutation.mutate(t.task_id)}
|
onClick={() => cancelActive(t.task_id)}
|
||||||
|
disabled={revokeMutation.isPending}
|
||||||
style={dangerBtn}
|
style={dangerBtn}
|
||||||
|
title="SIGTERM прерывает запущенный скрейп мгновенно"
|
||||||
>
|
>
|
||||||
Отменить
|
🛑 Прервать
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -487,10 +520,12 @@ export default function ScrapeAdminPage() {
|
||||||
</td>
|
</td>
|
||||||
<td style={td}>
|
<td style={td}>
|
||||||
<button
|
<button
|
||||||
onClick={() => revokeMutation.mutate(t.task_id)}
|
onClick={() => cancelReserved(t.task_id)}
|
||||||
|
disabled={revokeMutation.isPending}
|
||||||
style={dangerBtn}
|
style={dangerBtn}
|
||||||
|
title="Удалить из очереди — worker не запустит этот таск"
|
||||||
>
|
>
|
||||||
Отменить
|
⊘ Удалить из очереди
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
import { useObjectInfrastructure } from "@/lib/analytics-api";
|
import { useObjectInfrastructure } from "@/lib/analytics-api";
|
||||||
|
|
||||||
|
const PAGE_SIZE = 50;
|
||||||
|
|
||||||
const CATEGORY_COLORS: Record<string, string> = {
|
const CATEGORY_COLORS: Record<string, string> = {
|
||||||
Спорт: "#0a7a3a",
|
Спорт: "#0a7a3a",
|
||||||
Продукты: "#1d4ed8",
|
Продукты: "#1d4ed8",
|
||||||
|
|
@ -23,11 +25,16 @@ interface Props {
|
||||||
export function ObjectInfraMap({ objId, centerLat, centerLon }: Props) {
|
export function ObjectInfraMap({ objId, centerLat, centerLon }: Props) {
|
||||||
const [maxDist, setMaxDist] = useState(2000);
|
const [maxDist, setMaxDist] = useState(2000);
|
||||||
const [activeCat, setActiveCat] = useState<string | null>(null);
|
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,
|
category: activeCat ?? undefined,
|
||||||
max_distance: maxDist,
|
max_distance: maxDist,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setShown(PAGE_SIZE);
|
||||||
|
}, [activeCat, maxDist]);
|
||||||
|
|
||||||
const stats = useMemo(() => {
|
const stats = useMemo(() => {
|
||||||
const byCat: Record<string, number> = {};
|
const byCat: Record<string, number> = {};
|
||||||
for (const p of data ?? []) {
|
for (const p of data ?? []) {
|
||||||
|
|
@ -87,6 +94,8 @@ export function ObjectInfraMap({ objId, centerLat, centerLon }: Props) {
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
position: "relative",
|
position: "relative",
|
||||||
|
opacity: isFetching ? 0.7 : 1,
|
||||||
|
transition: "opacity 120ms",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
|
@ -101,6 +110,7 @@ export function ObjectInfraMap({ objId, centerLat, centerLon }: Props) {
|
||||||
{centerLat && centerLon
|
{centerLat && centerLon
|
||||||
? `ЖК: ${centerLat.toFixed(4)}, ${centerLon.toFixed(4)}`
|
? `ЖК: ${centerLat.toFixed(4)}, ${centerLon.toFixed(4)}`
|
||||||
: "координаты ЖК не заданы"}
|
: "координаты ЖК не заданы"}
|
||||||
|
{isFetching ? " · обновление…" : ""}
|
||||||
</div>
|
</div>
|
||||||
<table
|
<table
|
||||||
style={{ width: "100%", borderCollapse: "collapse", fontSize: 12 }}
|
style={{ width: "100%", borderCollapse: "collapse", fontSize: 12 }}
|
||||||
|
|
@ -123,7 +133,7 @@ export function ObjectInfraMap({ objId, centerLat, centerLon }: Props) {
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{(data ?? []).slice(0, 50).map((p, i) => (
|
{(data ?? []).slice(0, shown).map((p, i) => (
|
||||||
<tr
|
<tr
|
||||||
key={`${p.poi_name}-${p.lat}-${p.lon}`}
|
key={`${p.poi_name}-${p.lat}-${p.lon}`}
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -163,6 +173,30 @@ export function ObjectInfraMap({ objId, centerLat, centerLon }: Props) {
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,46 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import Image from "next/image";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
import { useObjectPhotos } from "@/lib/analytics-api";
|
import { useObjectPhotos } from "@/lib/analytics-api";
|
||||||
|
|
||||||
|
const INITIAL_LIMIT = 24;
|
||||||
|
|
||||||
export function ObjectPhotoGallery({ objId }: { objId: number | string }) {
|
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 [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) {
|
if (isLoading) {
|
||||||
return <div style={{ color: "#5b6066" }}>Загрузка фото…</div>;
|
return <div style={{ color: "#5b6066" }}>Загрузка фото…</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const photos = data ?? [];
|
const photos = (data ?? []).filter((p) => p.photo_url);
|
||||||
if (photos.length === 0) {
|
if (photos.length === 0) {
|
||||||
return <div style={{ color: "#9ca3af" }}>Фото отсутствуют.</div>;
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
@ -28,7 +51,7 @@ export function ObjectPhotoGallery({ objId }: { objId: number | string }) {
|
||||||
gap: 8,
|
gap: 8,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{photos.map((p) => (
|
{visible.map((p) => (
|
||||||
<button
|
<button
|
||||||
key={p.obj_file_id}
|
key={p.obj_file_id}
|
||||||
onClick={() => setActive(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",
|
border: "1px solid #e6e8ec",
|
||||||
borderRadius: 6,
|
borderRadius: 6,
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
cursor: "pointer",
|
cursor: "zoom-in",
|
||||||
background: "#f3f4f6",
|
background: "#f3f4f6",
|
||||||
aspectRatio: "4/3",
|
aspectRatio: "4/3",
|
||||||
position: "relative",
|
position: "relative",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{p.photo_url ? (
|
<Image
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
src={p.photo_url!}
|
||||||
<img
|
alt={p.photo_name ?? "фото прогресса"}
|
||||||
src={p.photo_url}
|
fill
|
||||||
alt={p.photo_name ?? "фото прогресса"}
|
sizes="160px"
|
||||||
loading="lazy"
|
quality={60}
|
||||||
style={{ width: "100%", height: "100%", objectFit: "cover" }}
|
style={{ objectFit: "cover" }}
|
||||||
/>
|
loading="lazy"
|
||||||
) : null}
|
unoptimized={false}
|
||||||
|
/>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
|
|
@ -62,6 +86,7 @@ export function ObjectPhotoGallery({ objId }: { objId: number | string }) {
|
||||||
color: "#fff",
|
color: "#fff",
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
padding: "3px 6px",
|
padding: "3px 6px",
|
||||||
|
pointerEvents: "none",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{p.period_dt?.slice(0, 7) ?? "—"}
|
{p.period_dt?.slice(0, 7) ?? "—"}
|
||||||
|
|
@ -70,13 +95,31 @@ export function ObjectPhotoGallery({ objId }: { objId: number | string }) {
|
||||||
))}
|
))}
|
||||||
</div>
|
</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 ? (
|
{activePhoto ? (
|
||||||
<div
|
<div
|
||||||
onClick={() => setActive(null)}
|
onClick={() => setActive(null)}
|
||||||
style={{
|
style={{
|
||||||
position: "fixed",
|
position: "fixed",
|
||||||
inset: 0,
|
inset: 0,
|
||||||
background: "rgba(0,0,0,0.85)",
|
background: "rgba(0,0,0,0.9)",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
|
|
@ -84,18 +127,58 @@ export function ObjectPhotoGallery({ objId }: { objId: number | string }) {
|
||||||
cursor: "zoom-out",
|
cursor: "zoom-out",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{activePhoto.photo_url ? (
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
<img
|
||||||
<img
|
src={activePhoto.photo_url!}
|
||||||
src={activePhoto.photo_url}
|
alt={activePhoto.photo_name ?? ""}
|
||||||
alt={activePhoto.photo_name ?? ""}
|
style={{
|
||||||
style={{
|
maxWidth: "92vw",
|
||||||
maxWidth: "92vw",
|
maxHeight: "92vh",
|
||||||
maxHeight: "92vh",
|
objectFit: "contain",
|
||||||
objectFit: "contain",
|
}}
|
||||||
}}
|
/>
|
||||||
/>
|
<button
|
||||||
) : null}
|
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
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
|
|
@ -105,8 +188,10 @@ export function ObjectPhotoGallery({ objId }: { objId: number | string }) {
|
||||||
color: "#fff",
|
color: "#fff",
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
|
pointerEvents: "none",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{activeIdx + 1} / {photos.length} ·{" "}
|
||||||
{activePhoto.period_dt?.slice(0, 10)} · {activePhoto.photo_name}
|
{activePhoto.period_dt?.slice(0, 10)} · {activePhoto.photo_name}
|
||||||
</div>
|
</div>
|
||||||
</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
Loading…
Add table
Reference in a new issue