feat: add news section with admin editor and public display

- NewsItem type with title, text, date, optional image and link
- Admin page at /admin/news with image upload and auto-date
- Public section between Pricing and FAQ, hidden when empty
- Nav link auto-hides when no news items exist

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-15 23:19:03 +03:00
parent f29dbe0c9f
commit b9800c1cc2
9 changed files with 277 additions and 4 deletions

162
src/app/admin/news/page.tsx Normal file
View File

@@ -0,0 +1,162 @@
"use client";
import { useState, useRef } from "react";
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 type { NewsItem } from "@/types/content";
interface NewsData {
title: string;
items: NewsItem[];
}
function ImageUploadField({
value,
onChange,
}: {
value: string;
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];
if (!file) return;
setUploading(true);
const formData = new FormData();
formData.append("file", file);
formData.append("folder", "news");
try {
const res = await fetch("/api/admin/upload", {
method: "POST",
body: formData,
});
const result = await res.json();
if (result.path) onChange(result.path);
} catch {
/* upload failed */
} finally {
setUploading(false);
}
}
return (
<div>
<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>
<button
type="button"
onClick={() => onChange("")}
className="rounded-lg p-2 text-neutral-500 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">
{uploading ? (
<Loader2 size={16} className="animate-spin" />
) : (
<Upload size={16} />
)}
{uploading ? "Загрузка..." : "Загрузить изображение"}
<input
ref={inputRef}
type="file"
accept="image/*"
onChange={handleUpload}
className="hidden"
/>
</label>
)}
</div>
);
}
export default function NewsEditorPage() {
return (
<SectionEditor<NewsData> sectionKey="news" title="Новости">
{(data, update) => (
<>
<InputField
label="Заголовок секции"
value={data.title}
onChange={(v) => update({ ...data, title: v })}
/>
<ArrayEditor
label="Новости"
items={data.items}
onChange={(items) => update({ ...data, items })}
renderItem={(item, _i, updateItem) => (
<div className="space-y-3">
<div className="grid gap-3 sm:grid-cols-2">
<InputField
label="Заголовок"
value={item.title}
onChange={(v) => updateItem({ ...item, title: v })}
/>
<InputField
label="Дата"
value={item.date}
onChange={(v) => updateItem({ ...item, date: v })}
placeholder="2026-03-15"
/>
</div>
<TextareaField
label="Текст"
value={item.text}
onChange={(v) => updateItem({ ...item, text: v })}
/>
<div className="grid gap-3 sm:grid-cols-2">
<ImageUploadField
value={item.image || ""}
onChange={(v) => updateItem({ ...item, image: v || undefined })}
/>
<InputField
label="Ссылка (необязательно)"
value={item.link || ""}
onChange={(v) => updateItem({ ...item, link: v || undefined })}
placeholder="https://instagram.com/p/..."
/>
</div>
</div>
)}
createItem={(): NewsItem => ({
title: "",
text: "",
date: new Date().toISOString().slice(0, 10),
})}
addLabel="Добавить новость"
/>
</>
)}
</SectionEditor>
);
}