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

View File

@@ -15,6 +15,7 @@ import {
Phone,
FileText,
Globe,
Newspaper,
LogOut,
Menu,
X,
@@ -32,6 +33,7 @@ const NAV_ITEMS = [
{ href: "/admin/schedule", label: "Расписание", icon: Calendar },
{ href: "/admin/pricing", label: "Цены", icon: DollarSign },
{ href: "/admin/faq", label: "FAQ", icon: HelpCircle },
{ href: "/admin/news", label: "Новости", icon: Newspaper },
{ href: "/admin/contact", label: "Контакты", icon: Phone },
];

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>
);
}

View File

@@ -5,6 +5,7 @@ import { Classes } from "@/components/sections/Classes";
import { MasterClasses } from "@/components/sections/MasterClasses";
import { Schedule } from "@/components/sections/Schedule";
import { Pricing } from "@/components/sections/Pricing";
import { News } from "@/components/sections/News";
import { FAQ } from "@/components/sections/FAQ";
import { Contact } from "@/components/sections/Contact";
import { BackToTop } from "@/components/ui/BackToTop";
@@ -33,6 +34,7 @@ export default function HomePage() {
<MasterClasses data={content.masterClasses} />
<Schedule data={content.schedule} classItems={content.classes.items} />
<Pricing data={content.pricing} />
<News data={content.news} />
<FAQ data={content.faq} />
<Contact data={content.contact} />
<BackToTop />

View File

@@ -31,8 +31,14 @@ export function Header() {
return () => window.removeEventListener("open-booking", onOpenBooking);
}, []);
// Filter out nav links whose target section doesn't exist on the page
const [visibleLinks, setVisibleLinks] = useState(NAV_LINKS);
useEffect(() => {
const sectionIds = NAV_LINKS.map((l) => l.href.replace("#", ""));
setVisibleLinks(NAV_LINKS.filter((l) => document.getElementById(l.href.replace("#", ""))));
}, []);
useEffect(() => {
const sectionIds = visibleLinks.map((l) => l.href.replace("#", ""));
const observers: IntersectionObserver[] = [];
// Observe hero — when visible, clear active section
@@ -65,7 +71,7 @@ export function Header() {
});
return () => observers.forEach((o) => o.disconnect());
}, []);
}, [visibleLinks]);
return (
<header
@@ -95,7 +101,7 @@ export function Header() {
</Link>
<nav className="hidden items-center gap-8 md:flex">
{NAV_LINKS.map((link) => {
{visibleLinks.map((link) => {
const isActive = activeSection === link.href.replace("#", "");
return (
<a
@@ -137,7 +143,7 @@ export function Header() {
}`}
>
<nav className="border-t border-white/[0.06] bg-black/40 px-6 py-4 backdrop-blur-xl sm:px-8">
{NAV_LINKS.map((link) => {
{visibleLinks.map((link) => {
const isActive = activeSection === link.href.replace("#", "");
return (
<a

View File

@@ -0,0 +1,82 @@
import Image from "next/image";
import { Calendar, ExternalLink } from "lucide-react";
import { SectionHeading } from "@/components/ui/SectionHeading";
import { Reveal } from "@/components/ui/Reveal";
import type { SiteContent } from "@/types/content";
interface NewsProps {
data: SiteContent["news"];
}
function formatDate(iso: string): string {
try {
return new Date(iso).toLocaleDateString("ru-RU", {
day: "numeric",
month: "long",
year: "numeric",
});
} catch {
return iso;
}
}
export function News({ data }: NewsProps) {
if (!data.items || data.items.length === 0) return null;
return (
<section id="news" className="section-glow relative section-padding">
<div className="section-divider absolute top-0 left-0 right-0" />
<div className="section-container">
<Reveal>
<SectionHeading centered>{data.title}</SectionHeading>
</Reveal>
<Reveal>
<div className="mx-auto mt-10 max-w-4xl grid gap-4 sm:grid-cols-2">
{data.items.map((item, i) => (
<article
key={i}
className="group rounded-2xl border border-neutral-200 bg-white overflow-hidden transition-all duration-300 hover:shadow-lg dark:border-white/[0.06] dark:bg-[#0a0a0a] dark:hover:border-white/[0.12]"
>
{item.image && (
<div className="relative aspect-[2/1] overflow-hidden">
<Image
src={item.image}
alt={item.title}
fill
sizes="(min-width: 640px) 50vw, 100vw"
className="object-cover transition-transform duration-500 group-hover:scale-105"
/>
</div>
)}
<div className="p-5">
<div className="flex items-center gap-1.5 text-xs text-neutral-400 dark:text-white/30">
<Calendar size={12} />
{formatDate(item.date)}
</div>
<h3 className="mt-2 text-base font-bold text-neutral-900 dark:text-white">
{item.title}
</h3>
<p className="mt-2 text-sm leading-relaxed text-neutral-600 dark:text-neutral-400 line-clamp-4">
{item.text}
</p>
{item.link && (
<a
href={item.link}
target="_blank"
rel="noopener noreferrer"
className="mt-3 inline-flex items-center gap-1.5 text-sm font-medium text-gold-dark hover:text-gold transition-colors dark:text-gold-light"
>
<ExternalLink size={14} />
Подробнее
</a>
)}
</div>
</article>
))}
</div>
</Reveal>
</div>
</section>
);
}

View File

@@ -442,6 +442,10 @@ export const siteContent: SiteContent = {
},
],
},
news: {
title: "Новости",
items: [],
},
contact: {
title: "Контакты",
addresses: [

View File

@@ -15,5 +15,6 @@ export const NAV_LINKS: NavLink[] = [
{ label: "Расписание", href: "#schedule" },
{ label: "Стоимость", href: "#pricing" },
{ label: "FAQ", href: "#faq" },
{ label: "Новости", href: "#news" },
{ label: "Контакты", href: "#contact" },
];

View File

@@ -295,6 +295,7 @@ const SECTION_KEYS = [
"faq",
"pricing",
"schedule",
"news",
"contact",
] as const;
@@ -325,6 +326,7 @@ export function getSiteContent(): SiteContent | null {
faq: sections.faq,
pricing: sections.pricing,
schedule: sections.schedule,
news: sections.news ?? { title: "Новости", items: [] },
contact: sections.contact,
team: {
title: teamSection.title || "",

View File

@@ -88,6 +88,14 @@ export interface MasterClassItem {
instagramUrl?: string;
}
export interface NewsItem {
title: string;
text: string;
date: string;
image?: string;
link?: string;
}
export interface ContactInfo {
title: string;
addresses: string[];
@@ -141,5 +149,9 @@ export interface SiteContent {
title: string;
locations: ScheduleLocation[];
};
news: {
title: string;
items: NewsItem[];
};
contact: ContactInfo;
}