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,
|
Phone,
|
||||||
FileText,
|
FileText,
|
||||||
Globe,
|
Globe,
|
||||||
|
Newspaper,
|
||||||
LogOut,
|
LogOut,
|
||||||
Menu,
|
Menu,
|
||||||
X,
|
X,
|
||||||
@@ -32,6 +33,7 @@ const NAV_ITEMS = [
|
|||||||
{ href: "/admin/schedule", label: "Расписание", icon: Calendar },
|
{ href: "/admin/schedule", label: "Расписание", icon: Calendar },
|
||||||
{ href: "/admin/pricing", label: "Цены", icon: DollarSign },
|
{ href: "/admin/pricing", label: "Цены", icon: DollarSign },
|
||||||
{ href: "/admin/faq", label: "FAQ", icon: HelpCircle },
|
{ href: "/admin/faq", label: "FAQ", icon: HelpCircle },
|
||||||
|
{ href: "/admin/news", label: "Новости", icon: Newspaper },
|
||||||
{ href: "/admin/contact", label: "Контакты", icon: Phone },
|
{ 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 { MasterClasses } from "@/components/sections/MasterClasses";
|
||||||
import { Schedule } from "@/components/sections/Schedule";
|
import { Schedule } from "@/components/sections/Schedule";
|
||||||
import { Pricing } from "@/components/sections/Pricing";
|
import { Pricing } from "@/components/sections/Pricing";
|
||||||
|
import { News } from "@/components/sections/News";
|
||||||
import { FAQ } from "@/components/sections/FAQ";
|
import { FAQ } from "@/components/sections/FAQ";
|
||||||
import { Contact } from "@/components/sections/Contact";
|
import { Contact } from "@/components/sections/Contact";
|
||||||
import { BackToTop } from "@/components/ui/BackToTop";
|
import { BackToTop } from "@/components/ui/BackToTop";
|
||||||
@@ -33,6 +34,7 @@ export default function HomePage() {
|
|||||||
<MasterClasses data={content.masterClasses} />
|
<MasterClasses data={content.masterClasses} />
|
||||||
<Schedule data={content.schedule} classItems={content.classes.items} />
|
<Schedule data={content.schedule} classItems={content.classes.items} />
|
||||||
<Pricing data={content.pricing} />
|
<Pricing data={content.pricing} />
|
||||||
|
<News data={content.news} />
|
||||||
<FAQ data={content.faq} />
|
<FAQ data={content.faq} />
|
||||||
<Contact data={content.contact} />
|
<Contact data={content.contact} />
|
||||||
<BackToTop />
|
<BackToTop />
|
||||||
|
|||||||
@@ -31,8 +31,14 @@ export function Header() {
|
|||||||
return () => window.removeEventListener("open-booking", onOpenBooking);
|
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(() => {
|
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[] = [];
|
const observers: IntersectionObserver[] = [];
|
||||||
|
|
||||||
// Observe hero — when visible, clear active section
|
// Observe hero — when visible, clear active section
|
||||||
@@ -65,7 +71,7 @@ export function Header() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return () => observers.forEach((o) => o.disconnect());
|
return () => observers.forEach((o) => o.disconnect());
|
||||||
}, []);
|
}, [visibleLinks]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header
|
<header
|
||||||
@@ -95,7 +101,7 @@ export function Header() {
|
|||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<nav className="hidden items-center gap-8 md:flex">
|
<nav className="hidden items-center gap-8 md:flex">
|
||||||
{NAV_LINKS.map((link) => {
|
{visibleLinks.map((link) => {
|
||||||
const isActive = activeSection === link.href.replace("#", "");
|
const isActive = activeSection === link.href.replace("#", "");
|
||||||
return (
|
return (
|
||||||
<a
|
<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 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("#", "");
|
const isActive = activeSection === link.href.replace("#", "");
|
||||||
return (
|
return (
|
||||||
<a
|
<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: {
|
contact: {
|
||||||
title: "Контакты",
|
title: "Контакты",
|
||||||
addresses: [
|
addresses: [
|
||||||
|
|||||||
@@ -15,5 +15,6 @@ export const NAV_LINKS: NavLink[] = [
|
|||||||
{ label: "Расписание", href: "#schedule" },
|
{ label: "Расписание", href: "#schedule" },
|
||||||
{ label: "Стоимость", href: "#pricing" },
|
{ label: "Стоимость", href: "#pricing" },
|
||||||
{ label: "FAQ", href: "#faq" },
|
{ label: "FAQ", href: "#faq" },
|
||||||
|
{ label: "Новости", href: "#news" },
|
||||||
{ label: "Контакты", href: "#contact" },
|
{ label: "Контакты", href: "#contact" },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -295,6 +295,7 @@ const SECTION_KEYS = [
|
|||||||
"faq",
|
"faq",
|
||||||
"pricing",
|
"pricing",
|
||||||
"schedule",
|
"schedule",
|
||||||
|
"news",
|
||||||
"contact",
|
"contact",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
@@ -325,6 +326,7 @@ export function getSiteContent(): SiteContent | null {
|
|||||||
faq: sections.faq,
|
faq: sections.faq,
|
||||||
pricing: sections.pricing,
|
pricing: sections.pricing,
|
||||||
schedule: sections.schedule,
|
schedule: sections.schedule,
|
||||||
|
news: sections.news ?? { title: "Новости", items: [] },
|
||||||
contact: sections.contact,
|
contact: sections.contact,
|
||||||
team: {
|
team: {
|
||||||
title: teamSection.title || "",
|
title: teamSection.title || "",
|
||||||
|
|||||||
@@ -88,6 +88,14 @@ export interface MasterClassItem {
|
|||||||
instagramUrl?: string;
|
instagramUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface NewsItem {
|
||||||
|
title: string;
|
||||||
|
text: string;
|
||||||
|
date: string;
|
||||||
|
image?: string;
|
||||||
|
link?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ContactInfo {
|
export interface ContactInfo {
|
||||||
title: string;
|
title: string;
|
||||||
addresses: string[];
|
addresses: string[];
|
||||||
@@ -141,5 +149,9 @@ export interface SiteContent {
|
|||||||
title: string;
|
title: string;
|
||||||
locations: ScheduleLocation[];
|
locations: ScheduleLocation[];
|
||||||
};
|
};
|
||||||
|
news: {
|
||||||
|
title: string;
|
||||||
|
items: NewsItem[];
|
||||||
|
};
|
||||||
contact: ContactInfo;
|
contact: ContactInfo;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user