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:
2026-03-11 16:59:12 +03:00
parent d5afaf92ba
commit 27c1348f89
44 changed files with 3709 additions and 69 deletions

View File

@@ -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"

View File

@@ -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,

View File

@@ -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]">

View File

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

View File

@@ -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]">

View File

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

View File

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

View File

@@ -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 (