feat: news admin — collapsible cards, photo preview; user side — sort by date, show more
This commit is contained in:
@@ -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: "",
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user