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:
Binary file not shown.
|
After Width: | Height: | Size: 105 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 105 KiB |
@@ -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="Ссылка (необязательно)"
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user