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;
|
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 &&
|
||||||
|
|||||||
@@ -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"
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user