feat: news crop editor — natural drag, zoom slider, force-dynamic page

- Rewrite crop preview: drag moves image naturally (inverted focal)
- Add zoom slider (1x-3x) + mouse wheel zoom
- Apply imageZoom on user side (featured, compact, modal)
- Force-dynamic on main page to prevent stale cache
This commit is contained in:
2026-03-26 11:11:39 +03:00
parent 4b6443c867
commit 4c8c6eb0d2
7 changed files with 77 additions and 20 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

View File

@@ -18,17 +18,22 @@ function CropPreview({
image,
focalX,
focalY,
zoom,
onImageChange,
onFocalChange,
onZoomChange,
}: {
image: string;
focalX: number;
focalY: number;
zoom: number;
onImageChange: (path: string) => void;
onFocalChange: (x: number, y: number) => void;
onZoomChange: (z: number) => void;
}) {
const [uploading, setUploading] = useState(false);
const [dragging, setDragging] = useState(false);
const dragStartRef = useRef({ x: 0, y: 0, startFocalX: 0, startFocalY: 0 });
const containerRef = useRef<HTMLDivElement>(null);
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
@@ -52,39 +57,51 @@ function CropPreview({
}
}
function updateFocalFromEvent(clientX: number, clientY: number) {
const el = containerRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const x = Math.max(0, Math.min(100, ((clientX - rect.left) / rect.width) * 100));
const y = Math.max(0, Math.min(100, ((clientY - rect.top) / rect.height) * 100));
onFocalChange(Math.round(x), Math.round(y));
}
function handlePointerDown(e: React.PointerEvent) {
e.preventDefault();
(e.target as HTMLElement).setPointerCapture(e.pointerId);
setDragging(true);
updateFocalFromEvent(e.clientX, e.clientY);
dragStartRef.current = {
x: e.clientX,
y: e.clientY,
startFocalX: focalX,
startFocalY: focalY,
};
}
function handlePointerMove(e: React.PointerEvent) {
if (!dragging) return;
updateFocalFromEvent(e.clientX, e.clientY);
const el = containerRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const { x: startX, y: startY, startFocalX, startFocalY } = dragStartRef.current;
// Invert: dragging right moves focal left (image slides right)
const dx = ((e.clientX - startX) / rect.width) * 100;
const dy = ((e.clientY - startY) / rect.height) * 100;
const newX = Math.max(0, Math.min(100, startFocalX - dx));
const newY = Math.max(0, Math.min(100, startFocalY - dy));
onFocalChange(Math.round(newX), Math.round(newY));
}
function handlePointerUp() {
setDragging(false);
}
function handleWheel(e: React.WheelEvent) {
e.preventDefault();
const delta = e.deltaY > 0 ? -0.1 : 0.1;
const newZoom = Math.max(1, Math.min(3, zoom + delta));
onZoomChange(Math.round(newZoom * 10) / 10);
}
return (
<div>
<label className="block text-sm text-neutral-400 mb-1.5">
Изображение <span className="text-neutral-600">(перетащите для кадрирования)</span>
Изображение <span className="text-neutral-600">(перетащите фото · колёсико для масштаба)</span>
</label>
{image ? (
<div className="space-y-2">
{/* Crop area — drag to reposition */}
{/* Crop area — drag image to reposition */}
<div
ref={containerRef}
className="relative w-full aspect-[21/9] overflow-hidden rounded-xl border border-white/10 cursor-grab active:cursor-grabbing select-none"
@@ -92,18 +109,44 @@ function CropPreview({
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
onWheel={handleWheel}
>
<Image
src={image}
alt="Превью"
fill
className="object-cover pointer-events-none"
style={{ objectPosition: `${focalX}% ${focalY}%` }}
style={{
objectPosition: `${focalX}% ${focalY}%`,
transform: `scale(${zoom})`,
}}
sizes="(max-width: 768px) 100vw, 600px"
/>
</div>
{/* Actions */}
<div className="flex items-center gap-2">
{/* Zoom slider + actions */}
<div className="flex items-center gap-3">
<div className="flex items-center gap-2 flex-1">
<span className="text-[10px] text-neutral-500"></span>
<input
type="range"
min="1"
max="3"
step="0.1"
value={zoom}
onChange={(e) => onZoomChange(parseFloat(e.target.value))}
className="flex-1 h-1 accent-[#c9a96e] cursor-pointer"
/>
<span className="text-[10px] text-neutral-500">+</span>
{zoom > 1 && (
<button
type="button"
onClick={() => { onZoomChange(1); onFocalChange(50, 50); }}
className="text-[10px] text-neutral-500 hover:text-white transition-colors"
>
Сбросить
</button>
)}
</div>
<label className="flex cursor-pointer items-center gap-1.5 rounded-lg border border-white/10 px-3 py-1.5 text-xs text-neutral-400 hover:text-white hover:border-white/25 transition-colors">
{uploading ? <Loader2 size={12} className="animate-spin" /> : <Upload size={12} />}
Заменить
@@ -112,7 +155,7 @@ function CropPreview({
<button
type="button"
onClick={() => onImageChange("")}
className="rounded-lg px-3 py-1.5 text-xs text-neutral-500 hover:text-red-400 transition-colors ml-auto"
className="rounded-lg px-3 py-1.5 text-xs text-neutral-500 hover:text-red-400 transition-colors"
>
Удалить
</button>
@@ -204,8 +247,10 @@ export default function NewsEditorPage() {
image={item.image || ""}
focalX={item.imageFocalX ?? 50}
focalY={item.imageFocalY ?? 50}
zoom={item.imageZoom ?? 1}
onImageChange={(v) => updateItem({ ...item, image: v || undefined })}
onFocalChange={(x, y) => updateItem({ ...item, imageFocalX: x, imageFocalY: y })}
onZoomChange={(z) => updateItem({ ...item, imageZoom: z })}
/>
<InputField
label="Ссылка (необязательно)"

View File

@@ -13,6 +13,8 @@ import { FloatingContact } from "@/components/ui/FloatingContact";
import { Header } from "@/components/layout/Header";
import { Footer } from "@/components/layout/Footer";
import { getContent } from "@/lib/content";
export const dynamic = "force-dynamic";
import { OpenDay } from "@/components/sections/OpenDay";
import { getActiveOpenDay } from "@/lib/openDay";
import { getAllMcRegistrations } from "@/lib/db";

View File

@@ -52,7 +52,10 @@ function FeaturedArticle({
loading="lazy"
sizes="(min-width: 768px) 80vw, 100vw"
className="object-cover transition-transform duration-700 group-hover:scale-105"
style={{ objectPosition: `${item.imageFocalX ?? 50}% ${item.imageFocalY ?? 50}%` }}
style={{
objectPosition: `${item.imageFocalX ?? 50}% ${item.imageFocalY ?? 50}%`,
transform: `scale(${item.imageZoom ?? 1})`,
}}
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/30 to-transparent" />
</div>
@@ -96,7 +99,10 @@ function CompactArticle({
loading="lazy"
sizes="112px"
className="object-cover transition-transform duration-500 group-hover:scale-105"
style={{ objectPosition: `${item.imageFocalX ?? 50}% ${item.imageFocalY ?? 50}%` }}
style={{
objectPosition: `${item.imageFocalX ?? 50}% ${item.imageFocalY ?? 50}%`,
transform: `scale(${item.imageZoom ?? 1})`,
}}
/>
</div>
)}

View File

@@ -82,7 +82,10 @@ export function NewsModal({ item, onClose }: NewsModalProps) {
fill
sizes="(min-width: 768px) 672px, 100vw"
className="object-cover"
style={{ objectPosition: `${item.imageFocalX ?? 50}% ${item.imageFocalY ?? 50}%` }}
style={{
objectPosition: `${item.imageFocalX ?? 50}% ${item.imageFocalY ?? 50}%`,
transform: `scale(${item.imageZoom ?? 1})`,
}}
/>
<div className="absolute inset-0 bg-gradient-to-t from-[#0a0a0a] via-transparent to-transparent" />
</div>

View File

@@ -86,6 +86,7 @@ export interface NewsItem {
image?: string;
imageFocalX?: number; // 0-100, default 50
imageFocalY?: number; // 0-100, default 50
imageZoom?: number; // 1-3, default 1
link?: string;
}