feat: admin panel with SQLite, auth, and calendar-style schedule editor
Complete admin panel for content management: - SQLite database with better-sqlite3, seed script from content.ts - Simple password auth with HMAC-signed cookies (Edge + Node compatible) - 9 section editors: meta, hero, about, team, classes, schedule, pricing, FAQ, contact - Team CRUD with image upload and drag reorder - Schedule editor with Google Calendar-style visual timeline (colored blocks, overlap detection, click-to-add) - All public components refactored to accept data props from DB (with fallback to static content) - Middleware protecting /admin/* and /api/admin/* routes Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,16 +1,25 @@
|
||||
import { Users, Layers, MapPin } from "lucide-react";
|
||||
import { siteContent } from "@/data/content";
|
||||
import { SectionHeading } from "@/components/ui/SectionHeading";
|
||||
import { Reveal } from "@/components/ui/Reveal";
|
||||
import type { SiteContent } from "@/types/content";
|
||||
|
||||
const stats = [
|
||||
{ icon: <Users size={22} />, value: "16", label: "тренеров" },
|
||||
{ icon: <Layers size={22} />, value: "6", label: "направлений" },
|
||||
{ icon: <MapPin size={22} />, value: "2", label: "зала в Минске" },
|
||||
];
|
||||
interface AboutStats {
|
||||
trainers: number;
|
||||
classes: number;
|
||||
locations: number;
|
||||
}
|
||||
|
||||
export function About() {
|
||||
const { about } = siteContent;
|
||||
interface AboutProps {
|
||||
data: SiteContent["about"];
|
||||
stats: AboutStats;
|
||||
}
|
||||
|
||||
export function About({ data: about, stats }: AboutProps) {
|
||||
const statItems = [
|
||||
{ icon: <Users size={22} />, value: String(stats.trainers), label: "тренеров" },
|
||||
{ icon: <Layers size={22} />, value: String(stats.classes), label: "направлений" },
|
||||
{ icon: <MapPin size={22} />, value: String(stats.locations), label: "зала в Минске" },
|
||||
];
|
||||
|
||||
return (
|
||||
<section id="about" className="section-glow relative section-padding bg-neutral-100 dark:bg-[#080808]">
|
||||
@@ -33,7 +42,7 @@ export function About() {
|
||||
{/* Stats */}
|
||||
<Reveal>
|
||||
<div className="mx-auto mt-14 grid max-w-3xl grid-cols-3 gap-4 sm:gap-8">
|
||||
{stats.map((stat, i) => (
|
||||
{statItems.map((stat, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="group flex flex-col items-center gap-3 rounded-2xl border border-neutral-200 bg-white/50 p-6 transition-all duration-300 hover:border-gold/30 sm:p-8 dark:border-white/[0.06] dark:bg-white/[0.02] dark:hover:border-gold/20"
|
||||
|
||||
@@ -2,12 +2,11 @@
|
||||
|
||||
import Image from "next/image";
|
||||
import { Flame, Sparkles, Wind, Zap, Star, Monitor } from "lucide-react";
|
||||
import { siteContent } from "@/data/content";
|
||||
import { SectionHeading } from "@/components/ui/SectionHeading";
|
||||
import { Reveal } from "@/components/ui/Reveal";
|
||||
import { ShowcaseLayout } from "@/components/ui/ShowcaseLayout";
|
||||
import { useShowcaseRotation } from "@/hooks/useShowcaseRotation";
|
||||
import type { ClassItem } from "@/types";
|
||||
import type { ClassItem, SiteContent } from "@/types";
|
||||
import { UI_CONFIG } from "@/lib/config";
|
||||
|
||||
const iconMap: Record<string, React.ReactNode> = {
|
||||
@@ -19,8 +18,11 @@ const iconMap: Record<string, React.ReactNode> = {
|
||||
monitor: <Monitor size={20} />,
|
||||
};
|
||||
|
||||
export function Classes() {
|
||||
const { classes } = siteContent;
|
||||
interface ClassesProps {
|
||||
data: SiteContent["classes"];
|
||||
}
|
||||
|
||||
export function Classes({ data: classes }: ClassesProps) {
|
||||
const { activeIndex, select, setHovering } = useShowcaseRotation({
|
||||
totalItems: classes.items.length,
|
||||
autoPlayInterval: UI_CONFIG.showcase.autoPlayInterval,
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { MapPin, Phone, Clock, Instagram } from "lucide-react";
|
||||
import { siteContent } from "@/data/content";
|
||||
import { BRAND } from "@/lib/constants";
|
||||
import { SectionHeading } from "@/components/ui/SectionHeading";
|
||||
import { Reveal } from "@/components/ui/Reveal";
|
||||
import { IconBadge } from "@/components/ui/IconBadge";
|
||||
import type { ContactInfo } from "@/types/content";
|
||||
|
||||
export function Contact() {
|
||||
const { contact } = siteContent;
|
||||
interface ContactProps {
|
||||
data: ContactInfo;
|
||||
}
|
||||
|
||||
export function Contact({ data: contact }: ContactProps) {
|
||||
|
||||
return (
|
||||
<section id="contact" className="relative section-padding bg-neutral-50 dark:bg-[#050505]">
|
||||
|
||||
@@ -2,16 +2,18 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { siteContent } from "@/data/content";
|
||||
import { SectionHeading } from "@/components/ui/SectionHeading";
|
||||
import { Reveal } from "@/components/ui/Reveal";
|
||||
|
||||
import { UI_CONFIG } from "@/lib/config";
|
||||
import type { SiteContent } from "@/types/content";
|
||||
|
||||
const VISIBLE_COUNT = UI_CONFIG.faq.visibleCount;
|
||||
|
||||
export function FAQ() {
|
||||
const { faq } = siteContent;
|
||||
interface FAQProps {
|
||||
data: SiteContent["faq"];
|
||||
}
|
||||
|
||||
export function FAQ({ data: faq }: FAQProps) {
|
||||
const [openIndex, setOpenIndex] = useState<number | null>(null);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { siteContent } from "@/data/content";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { FloatingHearts } from "@/components/ui/FloatingHearts";
|
||||
import { HeroLogo } from "@/components/ui/HeroLogo";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import type { SiteContent } from "@/types/content";
|
||||
|
||||
export function Hero() {
|
||||
const { hero } = siteContent;
|
||||
interface HeroProps {
|
||||
data: SiteContent["hero"];
|
||||
}
|
||||
|
||||
export function Hero({ data: hero }: HeroProps) {
|
||||
|
||||
return (
|
||||
<section className="relative flex min-h-svh items-center justify-center overflow-hidden bg-[#050505]">
|
||||
|
||||
@@ -2,15 +2,18 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { CreditCard, Building2, ScrollText, Crown, Sparkles } from "lucide-react";
|
||||
import { siteContent } from "@/data/content";
|
||||
import { SectionHeading } from "@/components/ui/SectionHeading";
|
||||
import { Reveal } from "@/components/ui/Reveal";
|
||||
import { BookingModal } from "@/components/ui/BookingModal";
|
||||
import type { SiteContent } from "@/types/content";
|
||||
|
||||
type Tab = "prices" | "rental" | "rules";
|
||||
|
||||
export function Pricing() {
|
||||
const { pricing } = siteContent;
|
||||
interface PricingProps {
|
||||
data: SiteContent["pricing"];
|
||||
}
|
||||
|
||||
export function Pricing({ data: pricing }: PricingProps) {
|
||||
const [activeTab, setActiveTab] = useState<Tab>("prices");
|
||||
const [bookingOpen, setBookingOpen] = useState(false);
|
||||
|
||||
|
||||
@@ -2,16 +2,19 @@
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import { MapPin } from "lucide-react";
|
||||
import { siteContent } from "@/data/content";
|
||||
import { SectionHeading } from "@/components/ui/SectionHeading";
|
||||
import { Reveal } from "@/components/ui/Reveal";
|
||||
import { DayCard } from "./schedule/DayCard";
|
||||
import { ScheduleFilters } from "./schedule/ScheduleFilters";
|
||||
import { MobileSchedule } from "./schedule/MobileSchedule";
|
||||
import type { StatusFilter } from "./schedule/constants";
|
||||
import type { SiteContent } from "@/types/content";
|
||||
|
||||
export function Schedule() {
|
||||
const { schedule } = siteContent;
|
||||
interface ScheduleProps {
|
||||
data: SiteContent["schedule"];
|
||||
}
|
||||
|
||||
export function Schedule({ data: schedule }: ScheduleProps) {
|
||||
const [locationIndex, setLocationIndex] = useState(0);
|
||||
const [filterTrainer, setFilterTrainer] = useState<string | null>(null);
|
||||
const [filterType, setFilterType] = useState<string | null>(null);
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { siteContent } from "@/data/content";
|
||||
import { SectionHeading } from "@/components/ui/SectionHeading";
|
||||
import { Reveal } from "@/components/ui/Reveal";
|
||||
import { TeamCarousel } from "@/components/sections/team/TeamCarousel";
|
||||
import { TeamMemberInfo } from "@/components/sections/team/TeamMemberInfo";
|
||||
import type { SiteContent } from "@/types/content";
|
||||
|
||||
export function Team() {
|
||||
const { team } = siteContent;
|
||||
interface TeamProps {
|
||||
data: SiteContent["team"];
|
||||
}
|
||||
|
||||
export function Team({ data: team }: TeamProps) {
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user