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,
|
image,
|
||||||
focalX,
|
focalX,
|
||||||
focalY,
|
focalY,
|
||||||
|
zoom,
|
||||||
onImageChange,
|
onImageChange,
|
||||||
onFocalChange,
|
onFocalChange,
|
||||||
|
onZoomChange,
|
||||||
}: {
|
}: {
|
||||||
image: string;
|
image: string;
|
||||||
focalX: number;
|
focalX: number;
|
||||||
focalY: number;
|
focalY: number;
|
||||||
|
zoom: number;
|
||||||
onImageChange: (path: string) => void;
|
onImageChange: (path: string) => void;
|
||||||
onFocalChange: (x: number, y: number) => void;
|
onFocalChange: (x: number, y: number) => void;
|
||||||
|
onZoomChange: (z: number) => void;
|
||||||
}) {
|
}) {
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
const [dragging, setDragging] = useState(false);
|
const [dragging, setDragging] = useState(false);
|
||||||
|
const dragStartRef = useRef({ x: 0, y: 0, startFocalX: 0, startFocalY: 0 });
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
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) {
|
function handlePointerDown(e: React.PointerEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
||||||
setDragging(true);
|
setDragging(true);
|
||||||
updateFocalFromEvent(e.clientX, e.clientY);
|
dragStartRef.current = {
|
||||||
|
x: e.clientX,
|
||||||
|
y: e.clientY,
|
||||||
|
startFocalX: focalX,
|
||||||
|
startFocalY: focalY,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function handlePointerMove(e: React.PointerEvent) {
|
function handlePointerMove(e: React.PointerEvent) {
|
||||||
if (!dragging) return;
|
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() {
|
function handlePointerUp() {
|
||||||
setDragging(false);
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm text-neutral-400 mb-1.5">
|
<label className="block text-sm text-neutral-400 mb-1.5">
|
||||||
Изображение <span className="text-neutral-600">(перетащите для кадрирования)</span>
|
Изображение <span className="text-neutral-600">(перетащите фото · колёсико для масштаба)</span>
|
||||||
</label>
|
</label>
|
||||||
{image ? (
|
{image ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{/* Crop area — drag to reposition */}
|
{/* Crop area — drag image to reposition */}
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className="relative w-full aspect-[21/9] overflow-hidden rounded-xl border border-white/10 cursor-grab active:cursor-grabbing select-none"
|
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}
|
onPointerMove={handlePointerMove}
|
||||||
onPointerUp={handlePointerUp}
|
onPointerUp={handlePointerUp}
|
||||||
onPointerCancel={handlePointerUp}
|
onPointerCancel={handlePointerUp}
|
||||||
|
onWheel={handleWheel}
|
||||||
>
|
>
|
||||||
<Image
|
<Image
|
||||||
src={image}
|
src={image}
|
||||||
alt="Превью"
|
alt="Превью"
|
||||||
fill
|
fill
|
||||||
className="object-cover pointer-events-none"
|
className="object-cover pointer-events-none"
|
||||||
style={{ objectPosition: `${focalX}% ${focalY}%` }}
|
style={{
|
||||||
|
objectPosition: `${focalX}% ${focalY}%`,
|
||||||
|
transform: `scale(${zoom})`,
|
||||||
|
}}
|
||||||
sizes="(max-width: 768px) 100vw, 600px"
|
sizes="(max-width: 768px) 100vw, 600px"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/* Actions */}
|
{/* Zoom slider + actions */}
|
||||||
<div className="flex items-center gap-2">
|
<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">
|
<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} />}
|
{uploading ? <Loader2 size={12} className="animate-spin" /> : <Upload size={12} />}
|
||||||
Заменить
|
Заменить
|
||||||
@@ -112,7 +155,7 @@ function CropPreview({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onImageChange("")}
|
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>
|
</button>
|
||||||
@@ -204,8 +247,10 @@ export default function NewsEditorPage() {
|
|||||||
image={item.image || ""}
|
image={item.image || ""}
|
||||||
focalX={item.imageFocalX ?? 50}
|
focalX={item.imageFocalX ?? 50}
|
||||||
focalY={item.imageFocalY ?? 50}
|
focalY={item.imageFocalY ?? 50}
|
||||||
|
zoom={item.imageZoom ?? 1}
|
||||||
onImageChange={(v) => updateItem({ ...item, image: v || undefined })}
|
onImageChange={(v) => updateItem({ ...item, image: v || undefined })}
|
||||||
onFocalChange={(x, y) => updateItem({ ...item, imageFocalX: x, imageFocalY: y })}
|
onFocalChange={(x, y) => updateItem({ ...item, imageFocalX: x, imageFocalY: y })}
|
||||||
|
onZoomChange={(z) => updateItem({ ...item, imageZoom: z })}
|
||||||
/>
|
/>
|
||||||
<InputField
|
<InputField
|
||||||
label="Ссылка (необязательно)"
|
label="Ссылка (необязательно)"
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import { FloatingContact } from "@/components/ui/FloatingContact";
|
|||||||
import { Header } from "@/components/layout/Header";
|
import { Header } from "@/components/layout/Header";
|
||||||
import { Footer } from "@/components/layout/Footer";
|
import { Footer } from "@/components/layout/Footer";
|
||||||
import { getContent } from "@/lib/content";
|
import { getContent } from "@/lib/content";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
import { OpenDay } from "@/components/sections/OpenDay";
|
import { OpenDay } from "@/components/sections/OpenDay";
|
||||||
import { getActiveOpenDay } from "@/lib/openDay";
|
import { getActiveOpenDay } from "@/lib/openDay";
|
||||||
import { getAllMcRegistrations } from "@/lib/db";
|
import { getAllMcRegistrations } from "@/lib/db";
|
||||||
|
|||||||
@@ -52,7 +52,10 @@ function FeaturedArticle({
|
|||||||
loading="lazy"
|
loading="lazy"
|
||||||
sizes="(min-width: 768px) 80vw, 100vw"
|
sizes="(min-width: 768px) 80vw, 100vw"
|
||||||
className="object-cover transition-transform duration-700 group-hover:scale-105"
|
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 className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/30 to-transparent" />
|
||||||
</div>
|
</div>
|
||||||
@@ -96,7 +99,10 @@ function CompactArticle({
|
|||||||
loading="lazy"
|
loading="lazy"
|
||||||
sizes="112px"
|
sizes="112px"
|
||||||
className="object-cover transition-transform duration-500 group-hover:scale-105"
|
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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -82,7 +82,10 @@ export function NewsModal({ item, onClose }: NewsModalProps) {
|
|||||||
fill
|
fill
|
||||||
sizes="(min-width: 768px) 672px, 100vw"
|
sizes="(min-width: 768px) 672px, 100vw"
|
||||||
className="object-cover"
|
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 className="absolute inset-0 bg-gradient-to-t from-[#0a0a0a] via-transparent to-transparent" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ export interface NewsItem {
|
|||||||
image?: string;
|
image?: string;
|
||||||
imageFocalX?: number; // 0-100, default 50
|
imageFocalX?: number; // 0-100, default 50
|
||||||
imageFocalY?: number; // 0-100, default 50
|
imageFocalY?: number; // 0-100, default 50
|
||||||
|
imageZoom?: number; // 1-3, default 1
|
||||||
link?: string;
|
link?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user