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:
@@ -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
162
src/app/admin/news/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 />
|
||||
|
||||
@@ -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
|
||||
|
||||
82
src/components/sections/News.tsx
Normal file
82
src/components/sections/News.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -442,6 +442,10 @@ export const siteContent: SiteContent = {
|
||||
},
|
||||
],
|
||||
},
|
||||
news: {
|
||||
title: "Новости",
|
||||
items: [],
|
||||
},
|
||||
contact: {
|
||||
title: "Контакты",
|
||||
addresses: [
|
||||
|
||||
@@ -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" },
|
||||
];
|
||||
|
||||
@@ -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 || "",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user