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"; "use client";
import { useState, useRef } from "react"; import { useState } from "react";
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, ImageIcon, X } from "lucide-react"; import { Upload, Loader2, X } 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";
@@ -13,7 +14,7 @@ interface NewsData {
items: NewsItem[]; items: NewsItem[];
} }
function ImageUploadField({ function PhotoPreview({
value, value,
onChange, onChange,
}: { }: {
@@ -21,7 +22,6 @@ function ImageUploadField({
onChange: (path: string) => void; onChange: (path: string) => void;
}) { }) {
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const inputRef = useRef<HTMLInputElement>(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];
@@ -46,54 +46,48 @@ function ImageUploadField({
return ( return (
<div> <div>
<label className="block text-sm text-neutral-400 mb-1.5"> <label className="block text-sm text-neutral-400 mb-1.5">Изображение</label>
Изображение
</label>
{value ? ( {value ? (
<div className="flex items-center gap-2"> <div className="relative">
<div className="flex items-center gap-1.5 rounded-lg bg-neutral-700/50 px-3 py-2 text-sm text-neutral-300"> <label className="relative block w-full aspect-[16/9] overflow-hidden rounded-xl border border-white/10 cursor-pointer group">
<ImageIcon size={14} className="text-gold" /> <Image
<span className="max-w-[200px] truncate"> src={value}
{value.split("/").pop()} alt="Превью"
</span> fill
</div> 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 <button
type="button" type="button"
onClick={() => onChange("")} 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} /> <X size={14} />
</button> </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> </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 ? ( {uploading ? (
<Loader2 size={16} className="animate-spin" /> <Loader2 size={20} className="animate-spin" />
) : ( ) : (
<Upload size={16} /> <>
<Upload size={20} />
<span>Загрузить изображение</span>
</>
)} )}
{uploading ? "Загрузка..." : "Загрузить изображение"} <input type="file" accept="image/*" onChange={handleUpload} className="hidden" />
<input
ref={inputRef}
type="file"
accept="image/*"
onChange={handleUpload}
className="hidden"
/>
</label> </label>
)} )}
</div> </div>
@@ -115,6 +109,18 @@ export default function NewsEditorPage() {
label="Новости" label="Новости"
items={data.items} items={data.items}
onChange={(items) => update({ ...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) => ( renderItem={(item, _i, updateItem) => (
<div className="space-y-3"> <div className="space-y-3">
<div className="grid gap-3 sm:grid-cols-2"> <div className="grid gap-3 sm:grid-cols-2">
@@ -138,18 +144,16 @@ export default function NewsEditorPage() {
value={item.text} value={item.text}
onChange={(v) => updateItem({ ...item, text: v })} onChange={(v) => updateItem({ ...item, text: v })}
/> />
<div className="grid gap-3 sm:grid-cols-2"> <PhotoPreview
<ImageUploadField value={item.image || ""}
value={item.image || ""} onChange={(v) => updateItem({ ...item, image: v || undefined })}
onChange={(v) => updateItem({ ...item, image: v || undefined })} />
/> <InputField
<InputField label="Ссылка (необязательно)"
label="Ссылка (необязательно)" value={item.link || ""}
value={item.link || ""} onChange={(v) => updateItem({ ...item, link: v || undefined })}
onChange={(v) => updateItem({ ...item, link: v || undefined })} placeholder="https://instagram.com/p/..."
placeholder="https://instagram.com/p/..." />
/>
</div>
</div> </div>
)} )}
createItem={(): NewsItem => ({ createItem={(): NewsItem => ({

View File

@@ -106,12 +106,19 @@ function CompactArticle({
); );
} }
const INITIAL_VISIBLE = 4;
export function News({ data }: NewsProps) { export function News({ data }: NewsProps) {
const [selected, setSelected] = useState<NewsItem | null>(null); const [selected, setSelected] = useState<NewsItem | null>(null);
const [showAll, setShowAll] = useState(false);
if (!data.items || data.items.length === 0) return null; 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 ( return (
<section id="news" className="section-glow relative section-padding"> <section id="news" className="section-glow relative section-padding">
@@ -129,12 +136,12 @@ export function News({ data }: NewsProps) {
/> />
</Reveal> </Reveal>
{rest.length > 0 && ( {visibleRest.length > 0 && (
<Reveal> <Reveal>
<div className="rounded-2xl bg-neutral-50/80 px-5 sm:px-6 dark:bg-white/[0.02]"> <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 <CompactArticle
key={item.title} key={item.title + item.date}
item={item} item={item}
onClick={() => setSelected(item)} onClick={() => setSelected(item)}
/> />
@@ -142,6 +149,19 @@ export function News({ data }: NewsProps) {
</div> </div>
</Reveal> </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>
</div> </div>