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; getItemTitle?: (item: T, index: number) => string;
getItemBadge?: (item: T, index: number) => React.ReactNode; getItemBadge?: (item: T, index: number) => React.ReactNode;
hiddenItems?: Set<number>; hiddenItems?: Set<number>;
addPosition?: "top" | "bottom";
} }
export function ArrayEditor<T>({ export function ArrayEditor<T>({
@@ -28,6 +29,7 @@ export function ArrayEditor<T>({
getItemTitle, getItemTitle,
getItemBadge, getItemBadge,
hiddenItems, hiddenItems,
addPosition = "bottom",
}: ArrayEditorProps<T>) { }: ArrayEditorProps<T>) {
const [dragIndex, setDragIndex] = useState<number | null>(null); const [dragIndex, setDragIndex] = useState<number | null>(null);
const [insertAt, setInsertAt] = 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> <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> <div>
{renderList()} {renderList()}
</div> </div>
{addPosition === "bottom" && (
<button <button
type="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" 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} /> <Plus size={16} />
{addLabel} {addLabel}
</button> </button>
)}
{/* Floating clone following cursor */} {/* Floating clone following cursor */}
{mounted && dragIndex !== null && {mounted && dragIndex !== null &&

View File

@@ -1,11 +1,11 @@
"use client"; "use client";
import { useState } from "react"; import { useState, useRef } from "react";
import Image from "next/image"; import Image from "next/image";
import { SectionEditor } from "../_components/SectionEditor"; import { SectionEditor } from "../_components/SectionEditor";
import { InputField, TextareaField } from "../_components/FormField"; import { InputField, TextareaField } from "../_components/FormField";
import { ArrayEditor } from "../_components/ArrayEditor"; 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 { adminFetch } from "@/lib/csrf";
import type { NewsItem } from "@/types/content"; import type { NewsItem } from "@/types/content";
@@ -14,14 +14,22 @@ interface NewsData {
items: NewsItem[]; items: NewsItem[];
} }
function PhotoPreview({ function CropPreview({
value, image,
onChange, focalX,
focalY,
onImageChange,
onFocalChange,
}: { }: {
value: string; image: string;
onChange: (path: string) => void; focalX: number;
focalY: number;
onImageChange: (path: string) => void;
onFocalChange: (x: number, y: number) => void;
}) { }) {
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [dragging, setDragging] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) { async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
@@ -36,7 +44,7 @@ function PhotoPreview({
body: formData, body: formData,
}); });
const result = await res.json(); const result = await res.json();
if (result.path) onChange(result.path); if (result.path) onImageChange(result.path);
} catch { } catch {
/* upload failed */ /* upload failed */
} finally { } 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 ( return (
<div> <div>
<label className="block text-sm text-neutral-400 mb-1.5">Изображение</label> <label className="block text-sm text-neutral-400 mb-1.5">
{value ? ( Изображение <span className="text-neutral-600">(перетащите для кадрирования)</span>
<div className="relative"> </label>
<label className="relative block w-full aspect-[16/9] overflow-hidden rounded-xl border border-white/10 cursor-pointer group"> {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 <Image
src={value} src={image}
alt="Превью" alt="Превью"
fill fill
className="object-cover" className="object-cover pointer-events-none"
sizes="(max-width: 768px) 100vw, 500px" 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> </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" /> <input type="file" accept="image/*" onChange={handleUpload} className="hidden" />
</label> </label>
<button <button
type="button" type="button"
onClick={() => onChange("")} onClick={() => onImageChange("")}
className="absolute top-2 right-2 rounded-lg bg-black/60 p-1.5 text-neutral-400 hover:text-red-400 transition-colors" 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> </button>
</div> </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"> <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 ? ( {uploading ? (
@@ -115,38 +156,56 @@ export default function NewsEditorPage() {
if (item.date) { if (item.date) {
try { try {
const d = new Date(item.date); const d = new Date(item.date);
const formatted = d.toLocaleDateString("ru-RU", { day: "numeric", month: "short", year: "numeric" }); const date = d.toLocaleDateString("ru-RU", { day: "numeric", month: "short" });
return `${title} · ${formatted}`; const time = item.date.includes("T") ? ` ${d.toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" })}` : "";
return `${title} · ${date}${time}`;
} catch { /* ignore */ } } catch { /* ignore */ }
} }
return title; 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="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 <InputField
label="Заголовок" label="Заголовок"
value={item.title} value={item.title}
onChange={(v) => updateItem({ ...item, title: v })} 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 <TextareaField
label="Текст" label="Текст"
value={item.text} value={item.text}
onChange={(v) => updateItem({ ...item, text: v })} onChange={(v) => updateItem({ ...item, text: v })}
/> />
<PhotoPreview <CropPreview
value={item.image || ""} image={item.image || ""}
onChange={(v) => updateItem({ ...item, image: v || undefined })} 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 <InputField
label="Ссылка (необязательно)" label="Ссылка (необязательно)"
@@ -155,13 +214,15 @@ export default function NewsEditorPage() {
placeholder="https://instagram.com/p/..." placeholder="https://instagram.com/p/..."
/> />
</div> </div>
)} );
}}
createItem={(): NewsItem => ({ createItem={(): NewsItem => ({
title: "", title: "",
text: "", text: "",
date: new Date().toISOString().slice(0, 10), date: new Date().toISOString(),
})} })}
addLabel="Добавить новость" addLabel="Добавить новость"
addPosition="top"
/> />
</> </>
)} )}

View File

@@ -14,11 +14,18 @@ interface NewsProps {
function formatDate(iso: string): string { function formatDate(iso: string): string {
try { try {
return new Date(iso).toLocaleDateString("ru-RU", { const d = new Date(iso);
const date = d.toLocaleDateString("ru-RU", {
day: "numeric", day: "numeric",
month: "long", month: "long",
year: "numeric", 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 { } catch {
return iso; return iso;
} }
@@ -45,6 +52,7 @@ 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}%` }}
/> />
<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>
@@ -88,6 +96,7 @@ 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}%` }}
/> />
</div> </div>
)} )}
@@ -114,8 +123,11 @@ export function News({ data }: NewsProps) {
if (!data.items || data.items.length === 0) return null; if (!data.items || data.items.length === 0) return null;
// Sort by date, newest first // Filter out empty/draft items, sort by date newest first
const sorted = [...data.items].sort((a, b) => (b.date || "").localeCompare(a.date || "")); 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 [featured, ...rest] = sorted;
const visibleRest = showAll ? rest : rest.slice(0, INITIAL_VISIBLE - 1); const visibleRest = showAll ? rest : rest.slice(0, INITIAL_VISIBLE - 1);
const hasMore = rest.length > INITIAL_VISIBLE - 1 && !showAll; const hasMore = rest.length > INITIAL_VISIBLE - 1 && !showAll;

View File

@@ -13,11 +13,17 @@ interface NewsModalProps {
function formatDate(iso: string): string { function formatDate(iso: string): string {
try { try {
return new Date(iso).toLocaleDateString("ru-RU", { const d = new Date(iso);
const date = d.toLocaleDateString("ru-RU", {
day: "numeric", day: "numeric",
month: "long", month: "long",
year: "numeric", year: "numeric",
}); });
if (iso.includes("T")) {
const time = d.toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" });
return `${date}, ${time}`;
}
return date;
} catch { } catch {
return iso; return iso;
} }
@@ -76,6 +82,7 @@ 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}%` }}
/> />
<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>

View File

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