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:
@@ -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>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { onChange([...items, createItem()]); setNewItemIndex(items.length); }}
|
||||
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>
|
||||
{addPosition === "bottom" && (
|
||||
<button
|
||||
type="button"
|
||||
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 &&
|
||||
|
||||
@@ -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,38 +52,71 @@ 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>
|
||||
<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"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</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={() => onImageChange("")}
|
||||
className="rounded-lg px-3 py-1.5 text-xs text-neutral-500 hover:text-red-400 transition-colors ml-auto"
|
||||
>
|
||||
Удалить
|
||||
</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">
|
||||
@@ -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">
|
||||
<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]"
|
||||
/>
|
||||
{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>
|
||||
</div>
|
||||
)}
|
||||
<InputField
|
||||
label="Заголовок"
|
||||
value={item.title}
|
||||
onChange={(v) => updateItem({ ...item, title: v })}
|
||||
/>
|
||||
<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"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user