feat: news improvements — crop preview, auto-date, validation, add-to-top

- Draggable focal point crop preview for news images (admin + user + modal)
- Auto-set date+time on creation, remove date picker
- Draft validation: title, text, image required — "Черновик" badge if missing
- Empty/draft news filtered from user side
- ArrayEditor: addPosition="top" option, fix new item expand + index shift
- News sorted newest first, "Показать ещё" pagination
This commit is contained in:
2026-03-26 01:34:31 +03:00
parent bc0f23df34
commit 4b6443c867
5 changed files with 179 additions and 69 deletions

View File

@@ -15,6 +15,7 @@ interface ArrayEditorProps<T> {
getItemTitle?: (item: T, index: number) => string;
getItemBadge?: (item: T, index: number) => React.ReactNode;
hiddenItems?: Set<number>;
addPosition?: "top" | "bottom";
}
export function ArrayEditor<T>({
@@ -28,6 +29,7 @@ export function ArrayEditor<T>({
getItemTitle,
getItemBadge,
hiddenItems,
addPosition = "bottom",
}: ArrayEditorProps<T>) {
const [dragIndex, setDragIndex] = useState<number | null>(null);
const [insertAt, setInsertAt] = useState<number | null>(null);
@@ -295,18 +297,44 @@ export function ArrayEditor<T>({
<h3 className="text-sm font-medium text-neutral-300 mb-3">{label}</h3>
)}
{addPosition === "top" && (
<button
type="button"
onClick={() => {
onChange([createItem(), ...items]);
setNewItemIndex(0);
// Shift collapsed indices and ensure new item is expanded
setCollapsed(prev => {
const next = new Set<number>();
for (const idx of prev) next.add(idx + 1);
return next;
});
}}
className="mb-3 flex items-center gap-2 rounded-lg border border-dashed border-white/20 px-4 py-2.5 text-sm text-neutral-400 hover:text-white hover:border-white/40 transition-colors"
>
<Plus size={16} />
{addLabel}
</button>
)}
<div>
{renderList()}
</div>
{addPosition === "bottom" && (
<button
type="button"
onClick={() => { onChange([...items, createItem()]); setNewItemIndex(items.length); }}
onClick={() => {
onChange([...items, createItem()]);
setNewItemIndex(items.length);
setCollapsed(prev => { const next = new Set(prev); next.delete(items.length); return next; });
}}
className="mt-3 flex items-center gap-2 rounded-lg border border-dashed border-white/20 px-4 py-2.5 text-sm text-neutral-400 hover:text-white hover:border-white/40 transition-colors"
>
<Plus size={16} />
{addLabel}
</button>
)}
{/* Floating clone following cursor */}
{mounted && dragIndex !== null &&

View File

@@ -1,11 +1,11 @@
"use client";
import { useState } from "react";
import { useState, useRef } from "react";
import Image from "next/image";
import { SectionEditor } from "../_components/SectionEditor";
import { InputField, TextareaField } from "../_components/FormField";
import { ArrayEditor } from "../_components/ArrayEditor";
import { Upload, Loader2, X } from "lucide-react";
import { Upload, Loader2, X, AlertCircle } from "lucide-react";
import { adminFetch } from "@/lib/csrf";
import type { NewsItem } from "@/types/content";
@@ -14,14 +14,22 @@ interface NewsData {
items: NewsItem[];
}
function PhotoPreview({
value,
onChange,
function CropPreview({
image,
focalX,
focalY,
onImageChange,
onFocalChange,
}: {
value: string;
onChange: (path: string) => void;
image: string;
focalX: number;
focalY: number;
onImageChange: (path: string) => void;
onFocalChange: (x: number, y: number) => void;
}) {
const [uploading, setUploading] = useState(false);
const [dragging, setDragging] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
@@ -36,7 +44,7 @@ function PhotoPreview({
body: formData,
});
const result = await res.json();
if (result.path) onChange(result.path);
if (result.path) onImageChange(result.path);
} catch {
/* upload failed */
} finally {
@@ -44,39 +52,72 @@ function PhotoPreview({
}
}
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);
}
function handlePointerMove(e: React.PointerEvent) {
if (!dragging) return;
updateFocalFromEvent(e.clientX, e.clientY);
}
function handlePointerUp() {
setDragging(false);
}
return (
<div>
<label className="block text-sm text-neutral-400 mb-1.5">Изображение</label>
{value ? (
<div className="relative">
<label className="relative block w-full aspect-[16/9] overflow-hidden rounded-xl border border-white/10 cursor-pointer group">
<label className="block text-sm text-neutral-400 mb-1.5">
Изображение <span className="text-neutral-600">(перетащите для кадрирования)</span>
</label>
{image ? (
<div className="space-y-2">
{/* Crop area — drag 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"
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
>
<Image
src={value}
src={image}
alt="Превью"
fill
className="object-cover"
sizes="(max-width: 768px) 100vw, 500px"
className="object-cover pointer-events-none"
style={{ objectPosition: `${focalX}% ${focalY}%` }}
sizes="(max-width: 768px) 100vw, 600px"
/>
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex flex-col items-center justify-center gap-1">
{uploading ? (
<Loader2 size={20} className="animate-spin text-white" />
) : (
<>
<Upload size={20} className="text-white" />
<span className="text-[11px] text-white/80">Изменить</span>
</>
)}
</div>
{/* Actions */}
<div className="flex items-center gap-2">
<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} />}
Заменить
<input type="file" accept="image/*" onChange={handleUpload} className="hidden" />
</label>
<button
type="button"
onClick={() => onChange("")}
className="absolute top-2 right-2 rounded-lg bg-black/60 p-1.5 text-neutral-400 hover:text-red-400 transition-colors"
onClick={() => onImageChange("")}
className="rounded-lg px-3 py-1.5 text-xs text-neutral-500 hover:text-red-400 transition-colors ml-auto"
>
<X size={14} />
Удалить
</button>
</div>
</div>
) : (
<label className="flex cursor-pointer items-center justify-center gap-2 w-full aspect-[16/9] rounded-xl border-2 border-dashed border-white/20 text-sm text-neutral-400 hover:text-white hover:border-white/40 transition-colors">
{uploading ? (
@@ -115,38 +156,56 @@ export default function NewsEditorPage() {
if (item.date) {
try {
const d = new Date(item.date);
const formatted = d.toLocaleDateString("ru-RU", { day: "numeric", month: "short", year: "numeric" });
return `${title} · ${formatted}`;
const date = d.toLocaleDateString("ru-RU", { day: "numeric", month: "short" });
const time = item.date.includes("T") ? ` ${d.toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" })}` : "";
return `${title} · ${date}${time}`;
} catch { /* ignore */ }
}
return title;
}}
renderItem={(item, _i, updateItem) => (
getItemBadge={(item) => {
const missing = [
!item.title.trim() && "заголовок",
!item.text.trim() && "текст",
!item.image && "фото",
].filter(Boolean);
if (missing.length === 0) return null;
return (
<span className="shrink-0 rounded-full bg-red-500/10 border border-red-500/20 px-2 py-0.5 text-[10px] font-medium text-red-400">
Черновик
</span>
);
}}
renderItem={(item, _i, updateItem) => {
const missing = [
!item.title.trim() && "Заголовок",
!item.text.trim() && "Текст",
!item.image && "Изображение",
].filter(Boolean);
return (
<div className="space-y-3">
<div className="grid gap-3 sm:grid-cols-2">
{missing.length > 0 && (
<div className="flex items-start gap-1.5 rounded-lg bg-red-500/10 border border-red-500/20 px-3 py-2 text-xs text-red-400">
<AlertCircle size={12} className="shrink-0 mt-0.5" />
<span>Не опубликовано не заполнено: {missing.join(", ")}</span>
</div>
)}
<InputField
label="Заголовок"
value={item.title}
onChange={(v) => updateItem({ ...item, title: v })}
/>
<div>
<label className="block text-sm text-neutral-400 mb-1.5">Дата</label>
<input
type="date"
value={item.date}
onChange={(e) => updateItem({ ...item, date: e.target.value })}
className="w-full rounded-lg border border-white/10 bg-neutral-800 px-4 py-2.5 text-white outline-none focus:border-gold transition-colors [color-scheme:dark]"
/>
</div>
</div>
<TextareaField
label="Текст"
value={item.text}
onChange={(v) => updateItem({ ...item, text: v })}
/>
<PhotoPreview
value={item.image || ""}
onChange={(v) => updateItem({ ...item, image: v || undefined })}
<CropPreview
image={item.image || ""}
focalX={item.imageFocalX ?? 50}
focalY={item.imageFocalY ?? 50}
onImageChange={(v) => updateItem({ ...item, image: v || undefined })}
onFocalChange={(x, y) => updateItem({ ...item, imageFocalX: x, imageFocalY: y })}
/>
<InputField
label="Ссылка (необязательно)"
@@ -155,13 +214,15 @@ export default function NewsEditorPage() {
placeholder="https://instagram.com/p/..."
/>
</div>
)}
);
}}
createItem={(): NewsItem => ({
title: "",
text: "",
date: new Date().toISOString().slice(0, 10),
date: new Date().toISOString(),
})}
addLabel="Добавить новость"
addPosition="top"
/>
</>
)}

View File

@@ -14,11 +14,18 @@ interface NewsProps {
function formatDate(iso: string): string {
try {
return new Date(iso).toLocaleDateString("ru-RU", {
const d = new Date(iso);
const date = d.toLocaleDateString("ru-RU", {
day: "numeric",
month: "long",
year: "numeric",
});
// Show time only if it's a full ISO timestamp (not just date)
if (iso.includes("T")) {
const time = d.toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" });
return `${date}, ${time}`;
}
return date;
} catch {
return iso;
}
@@ -45,6 +52,7 @@ 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}%` }}
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/30 to-transparent" />
</div>
@@ -88,6 +96,7 @@ 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}%` }}
/>
</div>
)}
@@ -114,8 +123,11 @@ export function News({ data }: NewsProps) {
if (!data.items || data.items.length === 0) return null;
// Sort by date, newest first
const sorted = [...data.items].sort((a, b) => (b.date || "").localeCompare(a.date || ""));
// Filter out empty/draft items, sort by date newest first
const sorted = [...data.items]
.filter((item) => item.title.trim() && item.text.trim() && item.image)
.sort((a, b) => (b.date || "").localeCompare(a.date || ""));
if (sorted.length === 0) return null;
const [featured, ...rest] = sorted;
const visibleRest = showAll ? rest : rest.slice(0, INITIAL_VISIBLE - 1);
const hasMore = rest.length > INITIAL_VISIBLE - 1 && !showAll;

View File

@@ -13,11 +13,17 @@ interface NewsModalProps {
function formatDate(iso: string): string {
try {
return new Date(iso).toLocaleDateString("ru-RU", {
const d = new Date(iso);
const date = d.toLocaleDateString("ru-RU", {
day: "numeric",
month: "long",
year: "numeric",
});
if (iso.includes("T")) {
const time = d.toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" });
return `${date}, ${time}`;
}
return date;
} catch {
return iso;
}
@@ -76,6 +82,7 @@ export function NewsModal({ item, onClose }: NewsModalProps) {
fill
sizes="(min-width: 768px) 672px, 100vw"
className="object-cover"
style={{ objectPosition: `${item.imageFocalX ?? 50}% ${item.imageFocalY ?? 50}%` }}
/>
<div className="absolute inset-0 bg-gradient-to-t from-[#0a0a0a] via-transparent to-transparent" />
</div>

View File

@@ -84,6 +84,8 @@ export interface NewsItem {
text: string;
date: string;
image?: string;
imageFocalX?: number; // 0-100, default 50
imageFocalY?: number; // 0-100, default 50
link?: string;
}