feat: news admin — collapsible cards, photo preview; user side — sort by date, show more

This commit is contained in:
2026-03-26 00:59:37 +03:00
parent 30398d2aeb
commit ad1715acb8
2 changed files with 80 additions and 56 deletions

View File

@@ -1,10 +1,11 @@
"use client";
import { useState, useRef } from "react";
import { useState } 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, ImageIcon, X } from "lucide-react";
import { Upload, Loader2, X } from "lucide-react";
import { adminFetch } from "@/lib/csrf";
import type { NewsItem } from "@/types/content";
@@ -13,7 +14,7 @@ interface NewsData {
items: NewsItem[];
}
function ImageUploadField({
function PhotoPreview({
value,
onChange,
}: {
@@ -21,7 +22,6 @@ function ImageUploadField({
onChange: (path: string) => void;
}) {
const [uploading, setUploading] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
@@ -46,54 +46,48 @@ function ImageUploadField({
return (
<div>
<label className="block text-sm text-neutral-400 mb-1.5">
Изображение
</label>
<label className="block text-sm text-neutral-400 mb-1.5">Изображение</label>
{value ? (
<div className="flex items-center gap-2">
<div className="flex items-center gap-1.5 rounded-lg bg-neutral-700/50 px-3 py-2 text-sm text-neutral-300">
<ImageIcon size={14} className="text-gold" />
<span className="max-w-[200px] truncate">
{value.split("/").pop()}
</span>
<div className="relative">
<label className="relative block w-full aspect-[16/9] overflow-hidden rounded-xl border border-white/10 cursor-pointer group">
<Image
src={value}
alt="Превью"
fill
className="object-cover"
sizes="(max-width: 768px) 100vw, 500px"
/>
<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="rounded-lg p-2 text-neutral-500 hover:text-red-400 transition-colors"
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>
<label className="flex cursor-pointer items-center gap-1.5 rounded-lg border border-white/10 px-3 py-2 text-sm text-neutral-400 hover:text-white hover:border-white/25 transition-colors">
{uploading ? (
<Loader2 size={14} className="animate-spin" />
) : (
<Upload size={14} />
)}
Заменить
<input
type="file"
accept="image/*"
onChange={handleUpload}
className="hidden"
/>
</label>
</div>
) : (
<label className="flex cursor-pointer items-center gap-2 rounded-lg border border-dashed border-white/20 px-4 py-3 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 ? (
<Loader2 size={16} className="animate-spin" />
<Loader2 size={20} className="animate-spin" />
) : (
<Upload size={16} />
<>
<Upload size={20} />
<span>Загрузить изображение</span>
</>
)}
{uploading ? "Загрузка..." : "Загрузить изображение"}
<input
ref={inputRef}
type="file"
accept="image/*"
onChange={handleUpload}
className="hidden"
/>
<input type="file" accept="image/*" onChange={handleUpload} className="hidden" />
</label>
)}
</div>
@@ -115,6 +109,18 @@ export default function NewsEditorPage() {
label="Новости"
items={data.items}
onChange={(items) => update({ ...data, items })}
collapsible
getItemTitle={(item) => {
const title = item.title || "Без заголовка";
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}`;
} catch { /* ignore */ }
}
return title;
}}
renderItem={(item, _i, updateItem) => (
<div className="space-y-3">
<div className="grid gap-3 sm:grid-cols-2">
@@ -138,8 +144,7 @@ export default function NewsEditorPage() {
value={item.text}
onChange={(v) => updateItem({ ...item, text: v })}
/>
<div className="grid gap-3 sm:grid-cols-2">
<ImageUploadField
<PhotoPreview
value={item.image || ""}
onChange={(v) => updateItem({ ...item, image: v || undefined })}
/>
@@ -150,7 +155,6 @@ export default function NewsEditorPage() {
placeholder="https://instagram.com/p/..."
/>
</div>
</div>
)}
createItem={(): NewsItem => ({
title: "",

View File

@@ -106,12 +106,19 @@ function CompactArticle({
);
}
const INITIAL_VISIBLE = 4;
export function News({ data }: NewsProps) {
const [selected, setSelected] = useState<NewsItem | null>(null);
const [showAll, setShowAll] = useState(false);
if (!data.items || data.items.length === 0) return null;
const [featured, ...rest] = data.items;
// Sort by date, newest first
const sorted = [...data.items].sort((a, b) => (b.date || "").localeCompare(a.date || ""));
const [featured, ...rest] = sorted;
const visibleRest = showAll ? rest : rest.slice(0, INITIAL_VISIBLE - 1);
const hasMore = rest.length > INITIAL_VISIBLE - 1 && !showAll;
return (
<section id="news" className="section-glow relative section-padding">
@@ -129,12 +136,12 @@ export function News({ data }: NewsProps) {
/>
</Reveal>
{rest.length > 0 && (
{visibleRest.length > 0 && (
<Reveal>
<div className="rounded-2xl bg-neutral-50/80 px-5 sm:px-6 dark:bg-white/[0.02]">
{rest.map((item) => (
{visibleRest.map((item) => (
<CompactArticle
key={item.title}
key={item.title + item.date}
item={item}
onClick={() => setSelected(item)}
/>
@@ -142,6 +149,19 @@ export function News({ data }: NewsProps) {
</div>
</Reveal>
)}
{hasMore && (
<Reveal>
<div className="text-center">
<button
onClick={() => setShowAll(true)}
className="rounded-full border border-white/10 bg-white/[0.03] px-6 py-2.5 text-sm font-medium text-neutral-400 hover:text-white hover:border-white/25 transition-colors cursor-pointer"
>
Показать ещё ({rest.length - INITIAL_VISIBLE + 1})
</button>
</div>
</Reveal>
)}
</div>
</div>